Compare commits
273 Commits
copy-minor
...
v2.1.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 | ||
|
|
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 | ||
|
|
63d613a88e | ||
|
|
70a4d16a8a | ||
|
|
9960507318 | ||
|
|
a84b225247 | ||
|
|
a1f938eaaf | ||
|
|
f7027e9cfd | ||
|
|
8164526763 | ||
|
|
2ecc07ee58 | ||
|
|
8edfe041c3 | ||
|
|
e3e54b0188 | ||
|
|
602d457212 | ||
|
|
8bd4a5448b | ||
|
|
2257c09228 | ||
|
|
dac8a3ecf2 | ||
|
|
288f85b2f3 | ||
|
|
d5d9e5e6e8 | ||
|
|
e194f2efea | ||
|
|
686839adc1 | ||
|
|
0c52b5a8ec | ||
|
|
f80a139c93 | ||
|
|
eeadd6910e | ||
|
|
eed16d9604 | ||
|
|
3745db6da4 | ||
|
|
4ef694a2ed | ||
|
|
279bb89ca9 | ||
|
|
0bc5714392 | ||
|
|
764f358708 | ||
|
|
bf43fd5079 | ||
|
|
6adc62c72a | ||
|
|
2cf894df59 | ||
|
|
72053dbf56 | ||
|
|
0cd50ff1b6 | ||
|
|
d3f443014c | ||
|
|
ecd56609a0 | ||
|
|
1af10b7f96 | ||
|
|
55170361c1 | ||
|
|
daa42f146d | ||
|
|
67ebd30836 | ||
|
|
ac282cebfb | ||
|
|
4aea074041 | ||
|
|
888ea5a911 | ||
|
|
f02b9c09e6 | ||
|
|
5e91553190 | ||
|
|
326c77cdb9 | ||
|
|
e10af413b2 | ||
|
|
d4a15ade98 | ||
|
|
b18a3cb5e1 | ||
|
|
96028c9f42 | ||
|
|
8625ac048a | ||
|
|
30934f9eba | ||
|
|
8349f47cbe | ||
|
|
9d3d93443f | ||
|
|
4b6d1c296c | ||
|
|
0a3a48759f | ||
|
|
407bea4ab9 | ||
|
|
3ba34f36eb | ||
|
|
8d1c03d4c1 | ||
|
|
ed739b25e2 | ||
|
|
807c9b2225 | ||
|
|
7d3dc8df90 | ||
|
|
aafb8948d2 | ||
|
|
4f2dd7654c | ||
|
|
9c2bebb3d9 | ||
|
|
e93e82b56c | ||
|
|
f783b981e5 | ||
|
|
b5a904354a | ||
|
|
ed7c30057c | ||
|
|
05e8513ad1 | ||
|
|
775f8db31e | ||
|
|
4b6d3fe968 | ||
|
|
e397295b5e | ||
|
|
1d16c46003 | ||
|
|
3ba8805413 | ||
|
|
63f4dc0caa | ||
|
|
965bdd7890 | ||
|
|
a99c41a07b | ||
|
|
81feee887c | ||
|
|
77433ebb7c | ||
|
|
d5614322c5 | ||
|
|
479ff037c6 | ||
|
|
8a74f495e7 | ||
|
|
231f2cbc14 | ||
|
|
1f466482f8 | ||
|
|
103ecef9f4 | ||
|
|
2744002390 | ||
|
|
fd26d2bcd1 | ||
|
|
a55da8149a | ||
|
|
631f69bd75 | ||
|
|
621556263b | ||
|
|
6fdd1a5f09 | ||
|
|
a2b3bc8c1f | ||
|
|
d932baf896 | ||
|
|
a9b469d3bf | ||
|
|
e2bd9401a6 | ||
|
|
c6ada95b9d | ||
|
|
330a2f632a | ||
|
|
bf6a7a85a7 | ||
|
|
e609153f4f | ||
|
|
bedb1dc8d3 | ||
|
|
7f2821f639 | ||
|
|
844f4b5e8d | ||
|
|
1e69ff7de8 | ||
|
|
e6d58721f0 | ||
|
|
7c077ace95 | ||
|
|
d03dd3d20d | ||
|
|
a780668aac | ||
|
|
c0998ca8b3 | ||
|
|
b7dd488886 | ||
|
|
cb9125632a | ||
|
|
9a776dabed | ||
|
|
180c8941a4 | ||
|
|
f6b83d3518 | ||
|
|
97712dbdc0 | ||
|
|
febf38a47a | ||
|
|
753ae01efc | ||
|
|
cef638e37a | ||
|
|
850069d380 | ||
|
|
a748e2c2db | ||
|
|
f38aebbc9c | ||
|
|
8e1b871f87 | ||
|
|
76ea4fc1ae | ||
|
|
36c7c10d94 | ||
|
|
5148fcf25b | ||
|
|
d00d152fdc | ||
|
|
7123736707 | ||
|
|
0ad685c262 | ||
|
|
f2bef08568 | ||
|
|
7651eb5f97 | ||
|
|
a0fc1b0a9e | ||
|
|
a62fd757d4 | ||
|
|
0094809273 | ||
|
|
2ea5858716 | ||
|
|
753e62b441 | ||
|
|
68a1d1e436 | ||
|
|
0d89f51d78 | ||
|
|
9670dfa916 | ||
|
|
2a19fbc3d2 | ||
|
|
e98d7d29f7 | ||
|
|
3c5918d485 | ||
|
|
65e9d164f5 | ||
|
|
9d0b120cde | ||
|
|
10ed37ec67 | ||
|
|
528ab8f796 | ||
|
|
30973eb78d | ||
|
|
2be7645c0c | ||
|
|
0075c44918 | ||
|
|
4a321440d9 | ||
|
|
b4cc0c6807 | ||
|
|
98c748359a | ||
|
|
5c51e01c78 | ||
|
|
650f81c22b | ||
|
|
3478f278ff | ||
|
|
d53123cf07 | ||
|
|
cf5a088f5e | ||
|
|
7d937eb024 | ||
|
|
4edaad53a1 | ||
|
|
8a2991c4fb | ||
|
|
0c9fdc6534 | ||
|
|
889bc2b1c7 | ||
|
|
7355be2a8b | ||
|
|
0f64da69c0 | ||
|
|
d9ad642a31 | ||
|
|
180140c13f | ||
|
|
e7d7cffbc5 | ||
|
|
29ccbddea8 | ||
|
|
944020ca6e | ||
|
|
4bc07100f5 | ||
|
|
7dec8f019f | ||
|
|
1a2cb0fc3c | ||
|
|
5dc3b62b94 | ||
|
|
4a4c8b4e7a | ||
|
|
c3390b9005 | ||
|
|
804fc8e391 | ||
|
|
5b5d779f25 | ||
|
|
3fcf037db2 | ||
|
|
324ede5523 | ||
|
|
e6e8718bb4 | ||
|
|
93e42fbd86 | ||
|
|
8a6adae89c | ||
|
|
e3ad0baeb7 | ||
|
|
953eb74235 | ||
|
|
b3c76e311c | ||
|
|
f41eb30f3c | ||
|
|
dbe236f75e | ||
|
|
7d4a9eaf45 | ||
|
|
4f7c3f14df | ||
|
|
f15fdcc42e | ||
|
|
44b36599c3 | ||
|
|
86713db75e | ||
|
|
d2491b81c0 | ||
|
|
b98f6369ae | ||
|
|
a2a210d82b | ||
|
|
5b120ad248 | ||
|
|
4ec57349f8 | ||
|
|
f48f437075 | ||
|
|
255990b022 | ||
|
|
ac44c59e50 | ||
|
|
9252920a79 | ||
|
|
719e471678 | ||
|
|
39bc141133 | ||
|
|
f2c14d09d4 | ||
|
|
7e81b9d45d | ||
|
|
e56c8cc5f8 | ||
|
|
13d0621881 | ||
|
|
8e7c1da7af | ||
|
|
4f1f7c3fc0 | ||
|
|
a6ae5e0675 | ||
|
|
cbd5ae9969 | ||
|
|
7389d080b6 | ||
|
|
8defd664c5 |
40
.github/helper/update_pot_file.sh
vendored
Normal file
40
.github/helper/update_pot_file.sh
vendored
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
set -e
|
||||||
|
cd ~ || exit
|
||||||
|
|
||||||
|
echo "Setting Up Bench..."
|
||||||
|
|
||||||
|
pip install frappe-bench
|
||||||
|
bench -v init frappe-bench --skip-assets --skip-redis-config-generation --python "$(which python)" --frappe-branch "${BASE_BRANCH}"
|
||||||
|
cd ./frappe-bench || exit
|
||||||
|
|
||||||
|
echo "Get LMS..."
|
||||||
|
bench get-app --skip-assets lms "${GITHUB_WORKSPACE}"
|
||||||
|
|
||||||
|
echo "Generating POT file..."
|
||||||
|
bench generate-pot-file --app lms
|
||||||
|
|
||||||
|
cd ./apps/lms || exit
|
||||||
|
|
||||||
|
echo "Configuring git user..."
|
||||||
|
git config user.email "developers@erpnext.com"
|
||||||
|
git config user.name "frappe-pr-bot"
|
||||||
|
|
||||||
|
echo "Setting the correct git remote..."
|
||||||
|
# Here, the git remote is a local file path by default. Let's change it to the upstream repo.
|
||||||
|
git remote set-url upstream https://github.com/frappe/lms.git
|
||||||
|
|
||||||
|
echo "Creating a new branch..."
|
||||||
|
isodate=$(date -u +"%Y-%m-%d")
|
||||||
|
branch_name="pot_${BASE_BRANCH}_${isodate}"
|
||||||
|
git checkout -b "${branch_name}"
|
||||||
|
|
||||||
|
echo "Commiting changes..."
|
||||||
|
git add lms/locale/main.pot
|
||||||
|
git commit -m "chore: update POT file"
|
||||||
|
|
||||||
|
gh auth setup-git
|
||||||
|
git push -u upstream "${branch_name}"
|
||||||
|
|
||||||
|
echo "Creating a PR..."
|
||||||
|
gh pr create --fill --base "${BASE_BRANCH}" --head "${branch_name}" -R frappe/lms
|
||||||
34
.github/workflows/generate-pot-file.yml
vendored
Normal file
34
.github/workflows/generate-pot-file.yml
vendored
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
name: Regenerate POT file (translatable strings)
|
||||||
|
on:
|
||||||
|
schedule:
|
||||||
|
- cron: "00 16 * * 5"
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
regenerate-pot-file:
|
||||||
|
name: Release
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
strategy:
|
||||||
|
fail-fast: false
|
||||||
|
matrix:
|
||||||
|
branch: ["develop"]
|
||||||
|
permissions:
|
||||||
|
contents: write
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
ref: ${{ matrix.branch }}
|
||||||
|
|
||||||
|
- name: Setup Python
|
||||||
|
uses: actions/setup-python@v5
|
||||||
|
with:
|
||||||
|
python-version: "3.12"
|
||||||
|
|
||||||
|
- name: Run script to update POT file
|
||||||
|
run: |
|
||||||
|
bash ${GITHUB_WORKSPACE}/.github/helper/update_pot_file.sh
|
||||||
|
env:
|
||||||
|
GH_TOKEN: ${{ secrets.RELEASE_TOKEN }}
|
||||||
|
BASE_BRANCH: ${{ matrix.branch }}
|
||||||
2
.github/workflows/linters.yml
vendored
2
.github/workflows/linters.yml
vendored
@@ -30,4 +30,4 @@ jobs:
|
|||||||
run: pip install semgrep
|
run: pip install semgrep
|
||||||
|
|
||||||
- name: Run Semgrep rules
|
- name: Run Semgrep rules
|
||||||
run: semgrep ci --config ./frappe-semgrep-rules/rules
|
run: semgrep ci --config ./frappe-semgrep-rules/rules
|
||||||
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 }}
|
||||||
3
.gitmodules
vendored
Normal file
3
.gitmodules
vendored
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
[submodule "frappe-ui"]
|
||||||
|
path = frappe-ui
|
||||||
|
url = https://github.com/pateljannat/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"
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -75,7 +75,13 @@ cd apps/lms/docker
|
|||||||
docker-compose up
|
docker-compose up
|
||||||
```
|
```
|
||||||
|
|
||||||
Wait for some time until the setup script creates a site. After that, you can access `http://localhost:8000` in your browser and the app's login screen should show up.
|
Wait for some time until the setup script creates a site. After that, you can access `http://localhost:8000` in your browser and the app's login screen should appear.
|
||||||
|
You'll have to go through the setup wizard to set up the website the first time you access it. Log in using the following credentials to complete the setup wizard.
|
||||||
|
|
||||||
|
```
|
||||||
|
Username: Administrator
|
||||||
|
password: admin
|
||||||
|
```
|
||||||
|
|
||||||
### Frappe Bench
|
### Frappe Bench
|
||||||
|
|
||||||
|
|||||||
8
crowdin.yml
Normal file
8
crowdin.yml
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
files:
|
||||||
|
- source: /lms/locale/main.pot
|
||||||
|
translation: /lms/locale/%two_letters_code%.po
|
||||||
|
pull_request_title: "chore: sync translations from crowdin"
|
||||||
|
pull_request_labels:
|
||||||
|
- translation
|
||||||
|
commit_message: "chore: %language% translations"
|
||||||
|
append_commit_message: false
|
||||||
@@ -13,6 +13,6 @@ module.exports = defineConfig({
|
|||||||
openMode: 0,
|
openMode: 0,
|
||||||
},
|
},
|
||||||
e2e: {
|
e2e: {
|
||||||
baseUrl: "http://pyp:8000",
|
baseUrl: "http://test_site_ui:8000",
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,133 +1,156 @@
|
|||||||
describe("Course Creation", () => {
|
describe("Course Creation", () => {
|
||||||
it("creates a new course", () => {
|
it("creates a new course", () => {
|
||||||
cy.login();
|
cy.login();
|
||||||
cy.visit("/courses");
|
cy.wait(1000);
|
||||||
|
cy.visit("/lms/courses");
|
||||||
|
|
||||||
// Create a course
|
// Create a course
|
||||||
cy.get("a.btn").contains("Create a Course").click();
|
cy.get("a").contains("New Course").click();
|
||||||
cy.wait(1000);
|
cy.wait(1000);
|
||||||
cy.url().should("include", "/courses/new-course/edit");
|
cy.url().should("include", "/courses/new/edit");
|
||||||
cy.get("#title").type("Test Course");
|
|
||||||
cy.get("#intro").type("Test Course Short Introduction");
|
cy.get("label").contains("Title").type("Test Course");
|
||||||
cy.get("#description").type("Test Course Description");
|
cy.get("label")
|
||||||
cy.get("#video-link").type("-LPmw2Znl2c");
|
.contains("Short Introduction")
|
||||||
cy.get("#tags-input").type("Test");
|
.type("Test Course Short Introduction to test the UI");
|
||||||
cy.get("#published").check();
|
cy.get("div[contenteditable=true").invoke(
|
||||||
|
"text",
|
||||||
|
"Test Course Description. I need a very big description to test the UI. This is a very big description. It contains more than once sentence. Its meant to be this long as this is a UI test. Its unbearably long and I'm not sure why I'm typing this much. I'm just going to keep typing until I feel like its long enough. I think its long enough now. I'm going to stop typing now."
|
||||||
|
);
|
||||||
|
|
||||||
|
cy.fixture("profile.png", "base64").then((fileContent) => {
|
||||||
|
cy.get('input[type="file"]').attachFile({
|
||||||
|
fileContent,
|
||||||
|
fileName: "profile.png",
|
||||||
|
mimeType: "image/png",
|
||||||
|
encoding: "base64",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
cy.get("label")
|
||||||
|
.contains("Preview Video")
|
||||||
|
.type("https://www.youtube.com/embed/-LPmw2Znl2c");
|
||||||
|
cy.get("[id=tags]").type("Learning{enter}Frappe{enter}ERPNext{enter}");
|
||||||
|
cy.get(".search-input").click().type("frappe");
|
||||||
cy.wait(1000);
|
cy.wait(1000);
|
||||||
|
cy.get("[id^=headlessui-combobox-option-")
|
||||||
|
.should("be.visible")
|
||||||
|
.first()
|
||||||
|
.click();
|
||||||
|
cy.get("label").contains("Published").click();
|
||||||
|
cy.get("label").contains("Published On").type("2021-01-01");
|
||||||
cy.button("Save").click();
|
cy.button("Save").click();
|
||||||
|
|
||||||
// Add Chapter
|
// Add Chapter
|
||||||
cy.wait(1000);
|
cy.wait(1000);
|
||||||
cy.link("Course Outline").click();
|
cy.button("Add Chapter").click();
|
||||||
|
|
||||||
cy.wait(1000);
|
cy.wait(1000);
|
||||||
cy.get(".edit-header .btn-add-chapter").click();
|
cy.get("[id^=headlessui-dialog-panel-")
|
||||||
cy.wait(500);
|
.should("be.visible")
|
||||||
cy.get("#chapter-title").type("Test Chapter");
|
.within(() => {
|
||||||
cy.get("#chapter-description").type("Test Chapter Description");
|
cy.get("label").contains("Title").type("Test Chapter");
|
||||||
cy.button("Save").click();
|
cy.button("Add Chapter").click();
|
||||||
|
});
|
||||||
|
|
||||||
// Add Lesson
|
// Add Lesson
|
||||||
cy.wait(1000);
|
cy.wait(1000);
|
||||||
cy.link("Add Lesson").click();
|
cy.button("Add Lesson").click();
|
||||||
|
cy.wait(1000);
|
||||||
|
cy.url().should("include", "/learn/1-1/edit");
|
||||||
cy.wait(1000);
|
cy.wait(1000);
|
||||||
cy.get("#lesson-title").type("Test Lesson");
|
|
||||||
|
|
||||||
// Content
|
cy.get("label").contains("Title").type("Test Lesson");
|
||||||
cy.get(".collapse-section.collapsed:first").click();
|
/* cy.get("#content .ce-block")
|
||||||
cy.get("#lesson-content .ce-block")
|
|
||||||
.click()
|
.click()
|
||||||
.type(
|
.invoke("text", "https://www.youtube.com/watch?v=GoDtyItReto"); */
|
||||||
"This is an extremely big paragraph that is meant to test the UI. This is a very long paragraph. It contains more than once sentence. Its meant to be this long as this is a UI test. Its unbearably long and I'm not sure why I'm typing this much. I'm just going to keep typing until I feel like its long enough. I think its long enough now. I'm going to stop typing now. {enter}"
|
/* cy.get("#content .ce-block")
|
||||||
);
|
.click()
|
||||||
cy.get("#lesson-content .ce-toolbar__plus").click();
|
.paste("https://www.youtube.com/watch?v=GoDtyItReto"); */
|
||||||
cy.get('#lesson-content [data-item-name="youtube"]').click();
|
|
||||||
cy.get('input[data-fieldname="youtube"]').type("GoDtyItReto");
|
cy.fixture("Youtube.mov", "base64").then((fileContent) => {
|
||||||
cy.button("Insert").click();
|
cy.get('input[type="file"]').attachFile({
|
||||||
cy.wait(1000);
|
fileContent,
|
||||||
|
fileName: "Youtube.mov",
|
||||||
|
mimeType: "image/png",
|
||||||
|
encoding: "base64",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
cy.get("#content .ce-block").type(
|
||||||
|
"This is an extremely big paragraph that is meant to test the UI. This is a very long paragraph. It contains more than once sentence. Its meant to be this long as this is a UI test. Its unbearably long and I'm not sure why I'm typing this much. I'm just going to keep typing until I feel like its long enough. I think its long enough now. I'm going to stop typing now."
|
||||||
|
);
|
||||||
cy.button("Save").click();
|
cy.button("Save").click();
|
||||||
|
|
||||||
// View Course
|
// View Course
|
||||||
cy.wait(1000);
|
cy.wait(1000);
|
||||||
cy.visit("/courses");
|
cy.visit("/lms");
|
||||||
cy.get(".course-card-title:first").contains("Test Course");
|
cy.wait(500);
|
||||||
cy.get(".course-card:first").click();
|
cy.url().should("include", "/lms/courses");
|
||||||
cy.url().should("include", "/courses/test-course");
|
cy.get(".grid a:first").within(() => {
|
||||||
cy.get("#title").contains("Test Course");
|
cy.get("div").contains("Test Course");
|
||||||
cy.get(".preview-video").should(
|
cy.get("div").contains(
|
||||||
|
"Test Course Short Introduction to test the UI"
|
||||||
|
);
|
||||||
|
cy.get(".course-image")
|
||||||
|
.invoke("css", "background-image")
|
||||||
|
.should("include", "/files/profile");
|
||||||
|
});
|
||||||
|
cy.get(".grid a:first").click();
|
||||||
|
cy.url().should("include", "/lms/courses/test-course");
|
||||||
|
cy.get("div").contains("Test Course");
|
||||||
|
cy.get("div").contains("Test Course Short Introduction to test the UI");
|
||||||
|
cy.get("div").contains("Learning");
|
||||||
|
cy.get("div").contains("Frappe");
|
||||||
|
cy.get("div").contains("ERPNext");
|
||||||
|
cy.get("iframe").should(
|
||||||
"have.attr",
|
"have.attr",
|
||||||
"src",
|
"src",
|
||||||
"https://www.youtube.com/embed/-LPmw2Znl2c"
|
"https://www.youtube.com/embed/-LPmw2Znl2c"
|
||||||
);
|
);
|
||||||
cy.get("#intro").contains("Test Course Short Introduction");
|
|
||||||
|
|
||||||
// View Chapter
|
// View Chapter
|
||||||
cy.get(".chapter-title-main:first").contains("Test Chapter");
|
cy.get("div").contains("Test Chapter");
|
||||||
cy.get(".chapter-description:first").contains(
|
cy.get("[id^=headlessui-disclosure-panel-").within(() => {
|
||||||
"Test Chapter Description"
|
cy.get("div").contains("Test Lesson").click();
|
||||||
);
|
});
|
||||||
cy.get(".lesson-info:first").contains("Test Lesson");
|
cy.wait(3000);
|
||||||
cy.get(".lesson-info:first").click();
|
|
||||||
|
|
||||||
// View Lesson
|
// View Lesson
|
||||||
cy.wait(1000);
|
cy.url().should("include", "/learn/1-1");
|
||||||
cy.url().should("include", "learn/1.1");
|
cy.get("div").contains("Test Lesson");
|
||||||
cy.get("#title").contains("Test Lesson");
|
|
||||||
cy.get(".lesson-video iframe").should(
|
cy.get("video")
|
||||||
"have.attr",
|
.should("be.visible")
|
||||||
"src",
|
.children("source")
|
||||||
"https://www.youtube.com/embed/GoDtyItReto"
|
.invoke("attr", "src")
|
||||||
);
|
.should("include", "/files/Youtube");
|
||||||
cy.get(".lesson-content-card").contains(
|
|
||||||
|
cy.get("div").contains(
|
||||||
"This is an extremely big paragraph that is meant to test the UI. This is a very long paragraph. It contains more than once sentence. Its meant to be this long as this is a UI test. Its unbearably long and I'm not sure why I'm typing this much. I'm just going to keep typing until I feel like its long enough. I think its long enough now. I'm going to stop typing now."
|
"This is an extremely big paragraph that is meant to test the UI. This is a very long paragraph. It contains more than once sentence. Its meant to be this long as this is a UI test. Its unbearably long and I'm not sure why I'm typing this much. I'm just going to keep typing until I feel like its long enough. I think its long enough now. I'm going to stop typing now."
|
||||||
);
|
);
|
||||||
|
|
||||||
// Add Discussion
|
// Add Discussion
|
||||||
cy.get(".reply").click();
|
cy.button("New Question").click();
|
||||||
cy.wait(500);
|
cy.wait(500);
|
||||||
cy.get(".discussion-modal").should("be.visible");
|
cy.get("[id^=headlessui-dialog-panel-").within(() => {
|
||||||
|
cy.get("label").contains("Title").type("Test Discussion");
|
||||||
// Enter title
|
cy.get("div[contenteditable=true]").invoke(
|
||||||
cy.get(".modal .topic-title")
|
"text",
|
||||||
.type("Discussion from tests")
|
"This is a test discussion. This will check if the UI is working properly."
|
||||||
.should("have.value", "Discussion from tests");
|
|
||||||
|
|
||||||
// Enter comment
|
|
||||||
cy.get(".modal .discussions-comment").type(
|
|
||||||
"This is a discussion from the cypress ui tests."
|
|
||||||
);
|
|
||||||
|
|
||||||
// Submit
|
|
||||||
cy.get(".modal .submit-discussion").click();
|
|
||||||
cy.wait(2000);
|
|
||||||
|
|
||||||
// Check if discussion is added to page and content is visible
|
|
||||||
cy.get(".sidebar-parent:first .discussion-topic-title").should(
|
|
||||||
"have.text",
|
|
||||||
"Discussion from tests"
|
|
||||||
);
|
|
||||||
cy.get(".sidebar-parent:first .discussion-topic-title").click();
|
|
||||||
cy.get(".discussion-on-page:visible").should("have.class", "show");
|
|
||||||
cy.get(
|
|
||||||
".discussion-on-page:visible .reply-card .reply-text .ql-editor p"
|
|
||||||
).should(
|
|
||||||
"have.text",
|
|
||||||
"This is a discussion from the cypress ui tests."
|
|
||||||
);
|
|
||||||
|
|
||||||
cy.get(".discussion-form:visible .discussions-comment").type(
|
|
||||||
"This is a discussion from the cypress ui tests. \n\nThis comment was entered through the commentbox on the page."
|
|
||||||
);
|
|
||||||
|
|
||||||
cy.get(".discussion-form:visible .submit-discussion").click();
|
|
||||||
cy.wait(3000);
|
|
||||||
cy.get(".discussion-on-page:visible").should("have.class", "show");
|
|
||||||
cy.get(".discussion-on-page:visible")
|
|
||||||
.children(".reply-card")
|
|
||||||
.eq(1)
|
|
||||||
.find(".reply-text")
|
|
||||||
.should(
|
|
||||||
"have.text",
|
|
||||||
"This is a discussion from the cypress ui tests. This comment was entered through the commentbox on the page.\n"
|
|
||||||
);
|
);
|
||||||
|
cy.button("Post").click();
|
||||||
|
});
|
||||||
|
|
||||||
|
// View Discussion
|
||||||
|
cy.wait(500);
|
||||||
|
cy.get("div").contains("Test Discussion").click();
|
||||||
|
cy.get("div[contenteditable=true").invoke(
|
||||||
|
"text",
|
||||||
|
"This is a test comment. This will check if the UI is working properly."
|
||||||
|
);
|
||||||
|
|
||||||
|
cy.get("div").contains(
|
||||||
|
"This is a test comment. This will check if the UI is working properly."
|
||||||
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
BIN
cypress/fixtures/Youtube.mov
Normal file
BIN
cypress/fixtures/Youtube.mov
Normal file
Binary file not shown.
BIN
cypress/fixtures/profile.png
Normal file
BIN
cypress/fixtures/profile.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.2 MiB |
@@ -24,6 +24,8 @@
|
|||||||
// -- This will overwrite an existing command --
|
// -- This will overwrite an existing command --
|
||||||
// Cypress.Commands.overwrite('visit', (originalFn, url, options) => { ... })
|
// Cypress.Commands.overwrite('visit', (originalFn, url, options) => { ... })
|
||||||
|
|
||||||
|
import "cypress-file-upload";
|
||||||
|
|
||||||
Cypress.Commands.add("login", (email, password) => {
|
Cypress.Commands.add("login", (email, password) => {
|
||||||
if (!email) {
|
if (!email) {
|
||||||
email = Cypress.config("testUser") || "Administrator";
|
email = Cypress.config("testUser") || "Administrator";
|
||||||
@@ -53,3 +55,13 @@ Cypress.Commands.add("iconButton", (text) => {
|
|||||||
Cypress.Commands.add("dialog", (selector) => {
|
Cypress.Commands.add("dialog", (selector) => {
|
||||||
return cy.get(`[role=dialog] ${selector}`);
|
return cy.get(`[role=dialog] ${selector}`);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
Cypress.Commands.add("paste", { prevSubject: true }, (subject, text) => {
|
||||||
|
cy.wrap(subject).then(($element) => {
|
||||||
|
const element = $element[0];
|
||||||
|
element.focus();
|
||||||
|
element.textContent = text;
|
||||||
|
const event = new Event("paste", { bubbles: true });
|
||||||
|
element.dispatchEvent(event);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
Submodule frappe-ui deleted from c5faaae38e
@@ -10,24 +10,28 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@editorjs/checklist": "^1.6.0",
|
"@editorjs/checklist": "^1.6.0",
|
||||||
|
"@editorjs/code": "^2.9.0",
|
||||||
"@editorjs/editorjs": "^2.29.0",
|
"@editorjs/editorjs": "^2.29.0",
|
||||||
"@editorjs/embed": "^2.7.0",
|
"@editorjs/embed": "^2.7.0",
|
||||||
"@editorjs/header": "^2.8.1",
|
"@editorjs/header": "^2.8.1",
|
||||||
"@editorjs/image": "^2.9.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",
|
||||||
"chart.js": "^4.4.1",
|
"chart.js": "^4.4.1",
|
||||||
"dayjs": "^1.11.6",
|
"dayjs": "^1.11.6",
|
||||||
"feather-icons": "^4.28.0",
|
"feather-icons": "^4.28.0",
|
||||||
"frappe-ui": "^0.1.50",
|
"frappe-ui": "^0.1.56",
|
||||||
"lucide-vue-next": "^0.309.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",
|
||||||
"socket.io-client": "^4.7.2",
|
"socket.io-client": "^4.7.2",
|
||||||
"tailwindcss": "^3.3.3",
|
"tailwindcss": "^3.3.3",
|
||||||
"vue": "^3.2.25",
|
"vue": "^3.4.23",
|
||||||
"vue-chartjs": "^5.3.0",
|
"vue-chartjs": "^5.3.0",
|
||||||
"vue-router": "^4.0.12"
|
"vue-draggable-next": "^2.2.1",
|
||||||
|
"vue-router": "^4.0.12",
|
||||||
|
"vuedraggable": "4.1.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@vitejs/plugin-vue": "^5.0.3",
|
"@vitejs/plugin-vue": "^5.0.3",
|
||||||
|
|||||||
BIN
frontend/public/Youtube.mov
Normal file
BIN
frontend/public/Youtube.mov
Normal file
Binary file not shown.
@@ -8,10 +8,12 @@
|
|||||||
<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'
|
||||||
|
|
||||||
const screenSize = useScreenSize()
|
const screenSize = useScreenSize()
|
||||||
|
|
||||||
@@ -22,4 +24,12 @@ const Layout = computed(() => {
|
|||||||
return DesktopLayout
|
return DesktopLayout
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
await initTelemetry()
|
||||||
|
})
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
stopSession()
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -8,14 +8,59 @@
|
|||||||
:class="isSidebarCollapsed ? 'items-center' : ''"
|
:class="isSidebarCollapsed ? 'items-center' : ''"
|
||||||
>
|
>
|
||||||
<UserDropdown class="p-2" :isCollapsed="isSidebarCollapsed" />
|
<UserDropdown class="p-2" :isCollapsed="isSidebarCollapsed" />
|
||||||
<div class="flex flex-col overflow-y-auto">
|
<div class="flex flex-col" v-if="sidebarSettings.data">
|
||||||
<SidebarLink
|
<SidebarLink
|
||||||
v-for="link in links"
|
v-for="link in sidebarLinks"
|
||||||
:link="link"
|
:link="link"
|
||||||
:isCollapsed="isSidebarCollapsed"
|
:isCollapsed="isSidebarCollapsed"
|
||||||
class="mx-2 my-0.5"
|
class="mx-2 my-0.5"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
<div
|
||||||
|
v-if="sidebarSettings.data?.web_pages?.length || isModerator"
|
||||||
|
class="mt-4"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="flex items-center justify-between pr-2 cursor-pointer"
|
||||||
|
:class="isSidebarCollapsed ? 'pl-3' : 'pl-4'"
|
||||||
|
@click="showWebPages = !showWebPages"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
v-if="!isSidebarCollapsed"
|
||||||
|
class="flex items-center text-sm text-gray-600 my-1"
|
||||||
|
>
|
||||||
|
<span class="grid h-5 w-6 flex-shrink-0 place-items-center">
|
||||||
|
<ChevronRight
|
||||||
|
class="h-4 w-4 stroke-1.5 text-gray-900 transition-all duration-300 ease-in-out"
|
||||||
|
:class="{ 'rotate-90': showWebPages }"
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
<span class="ml-2">
|
||||||
|
{{ __('More') }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<Button v-if="isModerator" variant="ghost" @click="openPageModal()">
|
||||||
|
<template #icon>
|
||||||
|
<Plus class="h-4 w-4 text-gray-700 stroke-1.5" />
|
||||||
|
</template>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-if="sidebarSettings.data?.web_pages?.length"
|
||||||
|
class="flex flex-col transition-all duration-300 ease-in-out"
|
||||||
|
:class="showWebPages ? 'block' : 'hidden'"
|
||||||
|
>
|
||||||
|
<SidebarLink
|
||||||
|
v-for="link in sidebarSettings.data.web_pages"
|
||||||
|
:link="link"
|
||||||
|
:isCollapsed="isSidebarCollapsed"
|
||||||
|
class="mx-2 my-0.5"
|
||||||
|
:showControls="isModerator ? true : false"
|
||||||
|
@openModal="openPageModal"
|
||||||
|
@deletePage="deletePage"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<SidebarLink
|
<SidebarLink
|
||||||
:link="{
|
:link="{
|
||||||
@@ -35,6 +80,11 @@
|
|||||||
</template>
|
</template>
|
||||||
</SidebarLink>
|
</SidebarLink>
|
||||||
</div>
|
</div>
|
||||||
|
<PageModal
|
||||||
|
v-model="showPageModal"
|
||||||
|
v-model:reloadSidebar="sidebarSettings"
|
||||||
|
:page="pageToEdit"
|
||||||
|
/>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
@@ -42,14 +92,113 @@ import UserDropdown from '@/components/UserDropdown.vue'
|
|||||||
import CollapseSidebar from '@/components/Icons/CollapseSidebar.vue'
|
import CollapseSidebar from '@/components/Icons/CollapseSidebar.vue'
|
||||||
import SidebarLink from '@/components/SidebarLink.vue'
|
import SidebarLink from '@/components/SidebarLink.vue'
|
||||||
import { useStorage } from '@vueuse/core'
|
import { useStorage } from '@vueuse/core'
|
||||||
import { ref } from 'vue'
|
import { ref, onMounted, inject, watch } from 'vue'
|
||||||
import { getSidebarLinks } from '../utils'
|
import { getSidebarLinks } from '../utils'
|
||||||
|
import { usersStore } from '@/stores/user'
|
||||||
|
import { sessionStore } from '@/stores/session'
|
||||||
|
import { ChevronRight, Plus } from 'lucide-vue-next'
|
||||||
|
import { createResource, Button } from 'frappe-ui'
|
||||||
|
import PageModal from '@/components/Modals/PageModal.vue'
|
||||||
|
|
||||||
const links = getSidebarLinks()
|
const { user, sidebarSettings } = sessionStore()
|
||||||
|
const { userResource } = usersStore()
|
||||||
|
const socket = inject('$socket')
|
||||||
|
const unreadCount = ref(0)
|
||||||
|
const sidebarLinks = ref(getSidebarLinks())
|
||||||
|
const showPageModal = ref(false)
|
||||||
|
const isModerator = ref(false)
|
||||||
|
const pageToEdit = ref(null)
|
||||||
|
const showWebPages = ref(false)
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
socket.on('publish_lms_notifications', (data) => {
|
||||||
|
unreadNotifications.reload()
|
||||||
|
})
|
||||||
|
addNotifications()
|
||||||
|
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({
|
||||||
|
cache: 'Unread Notifications Count',
|
||||||
|
url: 'frappe.client.get_count',
|
||||||
|
makeParams(values) {
|
||||||
|
return {
|
||||||
|
doctype: 'Notification Log',
|
||||||
|
filters: {
|
||||||
|
for_user: user,
|
||||||
|
read: 0,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onSuccess(data) {
|
||||||
|
unreadCount.value = data
|
||||||
|
sidebarLinks.value = sidebarLinks.value.map((link) => {
|
||||||
|
if (link.label === 'Notifications') {
|
||||||
|
link.count = data
|
||||||
|
}
|
||||||
|
return link
|
||||||
|
})
|
||||||
|
},
|
||||||
|
auto: user ? true : false,
|
||||||
|
})
|
||||||
|
|
||||||
|
const addNotifications = () => {
|
||||||
|
if (user) {
|
||||||
|
sidebarLinks.value.push({
|
||||||
|
label: 'Notifications',
|
||||||
|
icon: 'Bell',
|
||||||
|
to: 'Notifications',
|
||||||
|
activeFor: ['Notifications'],
|
||||||
|
count: unreadCount.value,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const openPageModal = (link) => {
|
||||||
|
showPageModal.value = true
|
||||||
|
pageToEdit.value = link
|
||||||
|
}
|
||||||
|
|
||||||
|
const deletePage = (link) => {
|
||||||
|
createResource({
|
||||||
|
url: 'lms.lms.api.delete_sidebar_item',
|
||||||
|
makeParams(values) {
|
||||||
|
return {
|
||||||
|
webpage: link.web_page,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}).submit(
|
||||||
|
{},
|
||||||
|
{
|
||||||
|
onSuccess() {
|
||||||
|
sidebarSettings.reload()
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
const getSidebarFromStorage = () => {
|
const getSidebarFromStorage = () => {
|
||||||
return useStorage('sidebar_is_collapsed', false)
|
return useStorage('sidebar_is_collapsed', false)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
watch(userResource, () => {
|
||||||
|
if (userResource.data) {
|
||||||
|
isModerator.value = userResource.data.is_moderator
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
let isSidebarCollapsed = ref(getSidebarFromStorage())
|
let isSidebarCollapsed = ref(getSidebarFromStorage())
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
135
frontend/src/components/AudioBlock.vue
Normal file
135
frontend/src/components/AudioBlock.vue
Normal file
@@ -0,0 +1,135 @@
|
|||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<!-- <audio width="100%" controls controlsList="nodownload" class="mb-4">
|
||||||
|
<source :src="encodeURI(file)" type="audio/mp3" />
|
||||||
|
</audio> -->
|
||||||
|
<audio @ended="handleAudioEnd" controlsList="nodownload" class="mb-4">
|
||||||
|
<source :src="encodeURI(file)" type="audio/mp3" />
|
||||||
|
</audio>
|
||||||
|
<div class="flex items-center space-x-2 shadow rounded-lg p-1 w-1/2">
|
||||||
|
<Button variant="ghost" @click="togglePlay">
|
||||||
|
<template #icon>
|
||||||
|
<Play v-if="!isPlaying" class="w-4 h-4 text-gray-900" />
|
||||||
|
<Pause v-else class="w-4 h-4 text-gray-900" />
|
||||||
|
</template>
|
||||||
|
</Button>
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
min="0"
|
||||||
|
:max="duration"
|
||||||
|
step="0.1"
|
||||||
|
v-model="currentTime"
|
||||||
|
@input="changeCurrentTime"
|
||||||
|
class="duration-slider w-full h-1"
|
||||||
|
/>
|
||||||
|
<span class="text-xs text-gray-900 font-medium">
|
||||||
|
{{ formatTime(currentTime) }} / {{ formatTime(duration) }}
|
||||||
|
</span>
|
||||||
|
<Button variant="ghost" @click="toggleMute">
|
||||||
|
<template #icon>
|
||||||
|
<Volume2 v-if="!isMuted" class="w-4 h-4 text-gray-900" />
|
||||||
|
<VolumeX v-else class="w-4 h-4 text-gray-900" />
|
||||||
|
</template>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, onMounted, watch } from 'vue'
|
||||||
|
import { Play, Pause, Volume2, VolumeX } from 'lucide-vue-next'
|
||||||
|
import { Button } from 'frappe-ui'
|
||||||
|
|
||||||
|
const isPlaying = ref(false)
|
||||||
|
const audio = ref(null)
|
||||||
|
let isMuted = ref(false)
|
||||||
|
let currentTime = ref(0)
|
||||||
|
let duration = ref(0)
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
file: {
|
||||||
|
type: String,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
setTimeout(() => {
|
||||||
|
audio.value = document.querySelector('audio')
|
||||||
|
console.log(audio.value)
|
||||||
|
audio.value.onloadedmetadata = () => {
|
||||||
|
duration.value = audio.value.duration
|
||||||
|
}
|
||||||
|
audio.value.ontimeupdate = () => {
|
||||||
|
currentTime.value = audio.value.currentTime
|
||||||
|
}
|
||||||
|
}, 0)
|
||||||
|
})
|
||||||
|
|
||||||
|
const togglePlay = () => {
|
||||||
|
if (audio.value.paused) {
|
||||||
|
audio.value.play()
|
||||||
|
isPlaying.value = true
|
||||||
|
} else {
|
||||||
|
audio.value.pause()
|
||||||
|
isPlaying.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const toggleMute = () => {
|
||||||
|
audio.value.muted = !audio.value.muted
|
||||||
|
isMuted.value = audio.value.muted
|
||||||
|
}
|
||||||
|
|
||||||
|
const changeCurrentTime = () => {
|
||||||
|
audio.value.currentTime = currentTime.value
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleAudioEnd = () => {
|
||||||
|
isPlaying.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatTime = (time) => {
|
||||||
|
const minutes = Math.floor(time / 60)
|
||||||
|
const seconds = Math.floor(time % 60)
|
||||||
|
return `${minutes}:${seconds < 10 ? '0' : ''}${seconds}`
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(isPlaying, (newVal) => {
|
||||||
|
if (newVal) {
|
||||||
|
audio.value.play()
|
||||||
|
} else {
|
||||||
|
audio.value.pause()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
<style>
|
||||||
|
.duration-slider {
|
||||||
|
flex: 1;
|
||||||
|
-webkit-appearance: none;
|
||||||
|
appearance: none;
|
||||||
|
background-color: theme('colors.gray.400');
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.duration-slider::-webkit-slider-thumb {
|
||||||
|
height: 10px;
|
||||||
|
width: 10px;
|
||||||
|
-webkit-appearance: none;
|
||||||
|
background-color: theme('colors.gray.900');
|
||||||
|
}
|
||||||
|
|
||||||
|
@media screen and (-webkit-min-device-pixel-ratio: 0) {
|
||||||
|
input[type='range'] {
|
||||||
|
overflow: hidden;
|
||||||
|
width: 150px;
|
||||||
|
-webkit-appearance: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
input[type='range']::-webkit-slider-thumb {
|
||||||
|
-webkit-appearance: none;
|
||||||
|
cursor: pointer;
|
||||||
|
box-shadow: -150px 0 0 150px theme('colors.gray.900');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -1,14 +1,19 @@
|
|||||||
<template>
|
<template>
|
||||||
<div
|
<div
|
||||||
class="flex flex-col border border-gray-200 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"
|
||||||
class="self-start mb-2"
|
class="self-start mb-2"
|
||||||
>
|
>
|
||||||
{{ batch.seats_left }} {{ __('Seat Left') }}
|
{{ batch.seats_left }}
|
||||||
|
<span v-if="batch.seats_left > 1">{{ __('Seats Left') }}</span
|
||||||
|
><span v-else-if="batch.seats_left == 1">{{ __('Seat Left') }}</span>
|
||||||
</Badge>
|
</Badge>
|
||||||
<Badge
|
<Badge
|
||||||
v-else-if="batch.seat_count && batch.seats_left <= 0"
|
v-else-if="batch.seat_count && batch.seats_left <= 0"
|
||||||
@@ -17,43 +22,59 @@
|
|||||||
>
|
>
|
||||||
{{ __('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 class="mt-auto">
|
<div v-if="batch.amount" class="font-semibold mb-4">
|
||||||
<div v-if="batch.amount" class="font-semibold text-lg mb-4">
|
{{ batch.price }}
|
||||||
{{ batch.price }}
|
</div>
|
||||||
</div>
|
<div class="flex flex-col space-y-2 mt-auto">
|
||||||
<div class="flex items-center mb-3">
|
<DateRange
|
||||||
<BookOpen class="h-4 w-4 stroke-1.5 mr-2 text-gray-700" />
|
:startDate="batch.start_date"
|
||||||
<span> {{ batch.courses.length }} {{ __('Courses') }} </span>
|
:endDate="batch.end_date"
|
||||||
</div>
|
class="text-sm text-gray-700"
|
||||||
<div class="flex items-center mb-3">
|
/>
|
||||||
<Calendar class="h-4 w-4 stroke-1.5 mr-2 text-gray-700" />
|
<div class="flex items-center text-sm text-gray-700">
|
||||||
<span>
|
|
||||||
{{ dayjs(batch.start_date).format('DD MMM YYYY') }} -
|
|
||||||
{{ dayjs(batch.end_date).format('DD MMM YYYY') }}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div class="flex items-center">
|
|
||||||
<Clock class="h-4 w-4 stroke-1.5 mr-2 text-gray-700" />
|
<Clock class="h-4 w-4 stroke-1.5 mr-2 text-gray-700" />
|
||||||
<span>
|
<span>
|
||||||
{{ formatTime(batch.start_time) }} - {{ formatTime(batch.end_time) }}
|
{{ formatTime(batch.start_time) }} - {{ formatTime(batch.end_time) }}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
<div
|
||||||
|
v-if="batch.timezone"
|
||||||
|
class="flex items-center text-sm text-gray-700"
|
||||||
|
>
|
||||||
|
<Globe class="h-4 w-4 stroke-1.5 mr-2 text-gray-600" />
|
||||||
|
<span>
|
||||||
|
{{ batch.timezone }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-if="batch.instructors?.length"
|
||||||
|
class="flex avatar-group overlap mt-4"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="h-6 mr-1"
|
||||||
|
:class="{ 'avatar-group overlap': batch.instructors.length > 1 }"
|
||||||
|
>
|
||||||
|
<UserAvatar
|
||||||
|
v-for="instructor in batch.instructors"
|
||||||
|
:user="instructor"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<CourseInstructors :instructors="batch.instructors" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<script setup>
|
<script setup>
|
||||||
import { Calendar, Clock, BookOpen } from 'lucide-vue-next'
|
|
||||||
import { inject } from 'vue'
|
|
||||||
import { Badge } from 'frappe-ui'
|
import { Badge } from 'frappe-ui'
|
||||||
import { formatTime } from '../utils'
|
import { formatTime } from '../utils'
|
||||||
|
import { Clock, BookOpen, Globe } from 'lucide-vue-next'
|
||||||
|
import DateRange from '@/components/Common/DateRange.vue'
|
||||||
|
import CourseInstructors from '@/components/CourseInstructors.vue'
|
||||||
|
import UserAvatar from '@/components/UserAvatar.vue'
|
||||||
|
|
||||||
const dayjs = inject('$dayjs')
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
batch: {
|
batch: {
|
||||||
type: Object,
|
type: Object,
|
||||||
@@ -69,7 +90,20 @@ 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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.avatar-group {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.avatar-group .avatar {
|
||||||
|
transition: margin 0.1s ease-in-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
.avatar-group.overlap .avatar + .avatar {
|
||||||
|
margin-left: calc(-8px);
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -19,8 +19,14 @@
|
|||||||
<ListView
|
<ListView
|
||||||
:columns="getCoursesColumns()"
|
:columns="getCoursesColumns()"
|
||||||
:rows="courses.data"
|
:rows="courses.data"
|
||||||
row-key="name"
|
row-key="batch_course"
|
||||||
:options="{ showTooltip: false }"
|
:options="{
|
||||||
|
showTooltip: false,
|
||||||
|
getRowRoute: (row) => ({
|
||||||
|
name: 'CourseDetail',
|
||||||
|
params: { courseName: row.name },
|
||||||
|
}),
|
||||||
|
}"
|
||||||
>
|
>
|
||||||
<ListHeader
|
<ListHeader
|
||||||
class="mb-2 grid items-center space-x-4 rounded bg-gray-100 p-2"
|
class="mb-2 grid items-center space-x-4 rounded bg-gray-100 p-2"
|
||||||
@@ -49,7 +55,10 @@
|
|||||||
<ListSelectBanner>
|
<ListSelectBanner>
|
||||||
<template #actions="{ unselectAll, selections }">
|
<template #actions="{ unselectAll, selections }">
|
||||||
<div class="flex gap-2">
|
<div class="flex gap-2">
|
||||||
<Button variant="ghost" @click="removeCourses(selections)">
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
@click="removeCourses(selections, unselectAll)"
|
||||||
|
>
|
||||||
<Trash2 class="h-4 w-4 stroke-1.5" />
|
<Trash2 class="h-4 w-4 stroke-1.5" />
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
@@ -108,13 +117,16 @@ const getCoursesColumns = () => {
|
|||||||
{
|
{
|
||||||
label: 'Title',
|
label: 'Title',
|
||||||
key: 'title',
|
key: 'title',
|
||||||
|
width: 2,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'Lessons',
|
label: 'Lessons',
|
||||||
key: 'lesson_count',
|
key: 'lesson_count',
|
||||||
|
align: 'right',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'Enrollments',
|
label: 'Enrollments',
|
||||||
|
align: 'right',
|
||||||
key: 'enrollment_count',
|
key: 'enrollment_count',
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
@@ -130,12 +142,13 @@ const removeCourse = createResource({
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
const removeCourses = (selections) => {
|
const removeCourses = (selections, unselectAll) => {
|
||||||
console.log(selections)
|
|
||||||
selections.forEach(async (course) => {
|
selections.forEach(async (course) => {
|
||||||
removeCourse.submit({ course })
|
removeCourse.submit({ course })
|
||||||
await setTimeout(1000)
|
|
||||||
})
|
})
|
||||||
courses.reload()
|
setTimeout(() => {
|
||||||
|
courses.reload()
|
||||||
|
unselectAll()
|
||||||
|
}, 1000)
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
<template>
|
<template>
|
||||||
<div v-if="batch.data" class="shadow rounded-md p-5" style="width: 300px">
|
<div v-if="batch.data" class="shadow rounded-md p-5 lg:w-72">
|
||||||
<Badge
|
<Badge
|
||||||
v-if="batch.data.seat_count && seats_left > 0"
|
v-if="batch.data.seat_count && seats_left > 0"
|
||||||
theme="green"
|
theme="green"
|
||||||
class="self-start mb-2 float-right"
|
class="self-start mb-2 float-right"
|
||||||
>
|
>
|
||||||
{{ seats_left }} {{ __('Seat Left') }}
|
{{ seats_left }} <span v-if="seats_left > 1">{{ __('Seats Left') }}</span
|
||||||
|
><span v-else-if="seats_left == 1">{{ __('Seat Left') }}</span>
|
||||||
</Badge>
|
</Badge>
|
||||||
<Badge
|
<Badge
|
||||||
v-else-if="batch.data.seat_count && seats_left <= 0"
|
v-else-if="batch.data.seat_count && seats_left <= 0"
|
||||||
@@ -21,22 +22,26 @@
|
|||||||
<BookOpen class="h-4 w-4 stroke-1.5 mr-2 text-gray-700" />
|
<BookOpen class="h-4 w-4 stroke-1.5 mr-2 text-gray-700" />
|
||||||
<span> {{ batch.data.courses.length }} {{ __('Courses') }} </span>
|
<span> {{ batch.data.courses.length }} {{ __('Courses') }} </span>
|
||||||
</div>
|
</div>
|
||||||
|
<DateRange
|
||||||
|
:startDate="batch.data.start_date"
|
||||||
|
:endDate="batch.data.end_date"
|
||||||
|
class="mb-3"
|
||||||
|
/>
|
||||||
<div class="flex items-center mb-3">
|
<div class="flex items-center mb-3">
|
||||||
<Calendar class="h-4 w-4 stroke-1.5 mr-2 text-gray-700" />
|
|
||||||
<span>
|
|
||||||
{{ dayjs(batch.data.start_date).format('DD MMM YYYY') }} -
|
|
||||||
{{ dayjs(batch.data.end_date).format('DD MMM YYYY') }}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div class="flex items-center">
|
|
||||||
<Clock class="h-4 w-4 stroke-1.5 mr-2 text-gray-700" />
|
<Clock class="h-4 w-4 stroke-1.5 mr-2 text-gray-700" />
|
||||||
<span>
|
<span>
|
||||||
{{ formatTime(batch.data.start_time) }} -
|
{{ formatTime(batch.data.start_time) }} -
|
||||||
{{ formatTime(batch.data.end_time) }}
|
{{ formatTime(batch.data.end_time) }}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
<div v-if="batch.data.timezone" class="flex items-center">
|
||||||
|
<Globe class="h-4 w-4 stroke-1.5 mr-2 text-gray-700" />
|
||||||
|
<span>
|
||||||
|
{{ batch.data.timezone }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
<router-link
|
<router-link
|
||||||
v-if="user?.data?.is_moderator"
|
v-if="isModerator || isStudent"
|
||||||
:to="{
|
:to="{
|
||||||
name: 'Batch',
|
name: 'Batch',
|
||||||
params: {
|
params: {
|
||||||
@@ -46,7 +51,7 @@
|
|||||||
>
|
>
|
||||||
<Button variant="solid" class="w-full mt-4">
|
<Button variant="solid" class="w-full mt-4">
|
||||||
<span>
|
<span>
|
||||||
{{ __('Manage Batch') }}
|
{{ isModerator ? __('Manage Batch') : __('Visit Batch') }}
|
||||||
</span>
|
</span>
|
||||||
</Button>
|
</Button>
|
||||||
</router-link>
|
</router-link>
|
||||||
@@ -58,9 +63,9 @@
|
|||||||
name: batch.data.name,
|
name: batch.data.name,
|
||||||
},
|
},
|
||||||
}"
|
}"
|
||||||
v-else-if="batch.data.paid_batch"
|
v-else-if="batch.data.paid_batch && batch.data.seats_left"
|
||||||
>
|
>
|
||||||
<Button class="w-full mt-4" variant="solid">
|
<Button v-if="!isStudent" class="w-full mt-4" variant="solid">
|
||||||
<span>
|
<span>
|
||||||
{{ __('Register Now') }}
|
{{ __('Register Now') }}
|
||||||
</span>
|
</span>
|
||||||
@@ -69,14 +74,14 @@
|
|||||||
<Button
|
<Button
|
||||||
variant="solid"
|
variant="solid"
|
||||||
class="w-full mt-2"
|
class="w-full mt-2"
|
||||||
v-else-if="batch.data.allow_self_enrollment"
|
v-else-if="batch.data.allow_self_enrollment && batch.data.seats_left"
|
||||||
>
|
>
|
||||||
{{ __('Enroll Now') }}
|
{{ __('Enroll Now') }}
|
||||||
</Button>
|
</Button>
|
||||||
<router-link
|
<router-link
|
||||||
v-if="user?.data?.is_moderator"
|
v-if="isModerator"
|
||||||
:to="{
|
:to="{
|
||||||
name: 'BatchCreation',
|
name: 'BatchForm',
|
||||||
params: {
|
params: {
|
||||||
batchName: batch.data.name,
|
batchName: batch.data.name,
|
||||||
},
|
},
|
||||||
@@ -91,12 +96,12 @@
|
|||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<script setup>
|
<script setup>
|
||||||
import { formatNumberIntoCurrency, formatTime } from '@/utils'
|
|
||||||
import { BookOpen, Calendar, Clock } from 'lucide-vue-next'
|
|
||||||
import { inject, computed } from 'vue'
|
import { inject, computed } from 'vue'
|
||||||
import { Badge, Button } from 'frappe-ui'
|
import { Badge, Button } from 'frappe-ui'
|
||||||
|
import { BookOpen, Clock, Globe } from 'lucide-vue-next'
|
||||||
|
import { formatNumberIntoCurrency, formatTime } from '@/utils'
|
||||||
|
import DateRange from '@/components/Common/DateRange.vue'
|
||||||
|
|
||||||
const dayjs = inject('$dayjs')
|
|
||||||
const user = inject('$user')
|
const user = inject('$user')
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
@@ -112,4 +117,12 @@ const seats_left = computed(() => {
|
|||||||
}
|
}
|
||||||
return null
|
return null
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const isStudent = computed(() => {
|
||||||
|
return props.batch.data?.students?.includes(user.data?.name)
|
||||||
|
})
|
||||||
|
|
||||||
|
const isModerator = computed(() => {
|
||||||
|
return user.data?.is_moderator
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -52,7 +52,10 @@
|
|||||||
<ListSelectBanner>
|
<ListSelectBanner>
|
||||||
<template #actions="{ unselectAll, selections }">
|
<template #actions="{ unselectAll, selections }">
|
||||||
<div class="flex gap-2">
|
<div class="flex gap-2">
|
||||||
<Button variant="ghost" @click="removeStudents(selections)">
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
@click="removeStudents(selections, unselectAll)"
|
||||||
|
>
|
||||||
<Trash2 class="h-4 w-4 stroke-1.5" />
|
<Trash2 class="h-4 w-4 stroke-1.5" />
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
@@ -109,6 +112,7 @@ const getStudentColumns = () => {
|
|||||||
{
|
{
|
||||||
label: 'Full Name',
|
label: 'Full Name',
|
||||||
key: 'full_name',
|
key: 'full_name',
|
||||||
|
width: 2,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'Courses Done',
|
label: 'Courses Done',
|
||||||
@@ -141,11 +145,13 @@ const removeStudent = createResource({
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
const removeStudents = (selections) => {
|
const removeStudents = (selections, unselectAll) => {
|
||||||
selections.forEach(async (student) => {
|
selections.forEach(async (student) => {
|
||||||
removeStudent.submit({ student })
|
removeStudent.submit({ student })
|
||||||
await setTimeout(1000)
|
|
||||||
})
|
})
|
||||||
students.reload()
|
setTimeout(() => {
|
||||||
|
students.reload()
|
||||||
|
unselectAll()
|
||||||
|
}, 500)
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
22
frontend/src/components/Common/DateRange.vue
Normal file
22
frontend/src/components/Common/DateRange.vue
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
<template>
|
||||||
|
<div class="flex items-center">
|
||||||
|
<Calendar class="h-4 w-4 stroke-1.5 mr-2 text-gray-700" />
|
||||||
|
<span>
|
||||||
|
{{ getFormattedDateRange(props.startDate, props.endDate) }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { Calendar } from 'lucide-vue-next'
|
||||||
|
import { getFormattedDateRange } from '@/utils'
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
startDate: {
|
||||||
|
type: String,
|
||||||
|
},
|
||||||
|
endDate: {
|
||||||
|
type: String,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
</script>
|
||||||
@@ -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>
|
||||||
|
|||||||
114
frontend/src/components/Controls/IconPicker.vue
Normal file
114
frontend/src/components/Controls/IconPicker.vue
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
<template>
|
||||||
|
<div class="space-y-1.5">
|
||||||
|
<label class="block text-xs text-gray-600">
|
||||||
|
{{ label }}
|
||||||
|
</label>
|
||||||
|
<div class="w-full">
|
||||||
|
<Popover>
|
||||||
|
<template #target="{ togglePopover }">
|
||||||
|
<button
|
||||||
|
@click="openPopover(togglePopover)"
|
||||||
|
class="flex w-full items-center space-x-2 focus:outline-none bg-gray-100 rounded h-7 py-1.5 px-2 hover:bg-gray-200 focus:bg-white border border-gray-100 hover:border-gray-200 focus:border-gray-500"
|
||||||
|
>
|
||||||
|
<component
|
||||||
|
v-if="selectedIcon"
|
||||||
|
class="w-4 h-4 text-gray-700 stroke-1.5"
|
||||||
|
:is="icons[selectedIcon]"
|
||||||
|
/>
|
||||||
|
<component
|
||||||
|
v-else
|
||||||
|
class="w-4 h-4 text-gray-700 stroke-1.5"
|
||||||
|
:is="icons.Folder"
|
||||||
|
/>
|
||||||
|
<span v-if="selectedIcon">
|
||||||
|
{{ selectedIcon }}
|
||||||
|
</span>
|
||||||
|
<span v-else class="text-gray-600">
|
||||||
|
{{ __('Choose an icon') }}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</template>
|
||||||
|
<template #body-main="{ close, isOpen }" class="w-full">
|
||||||
|
<div class="p-3 max-h-56 overflow-auto w-full">
|
||||||
|
<FormControl
|
||||||
|
ref="search"
|
||||||
|
v-model="iconQuery"
|
||||||
|
:placeholder="__('Search for an icon')"
|
||||||
|
autocomplete="off"
|
||||||
|
/>
|
||||||
|
<div class="grid grid-cols-10 gap-4 mt-4">
|
||||||
|
<div v-for="(iconComponent, iconName) in filteredIcons">
|
||||||
|
<component
|
||||||
|
:is="iconComponent"
|
||||||
|
class="h-4 w-4 stroke-1.5 text-gray-700 cursor-pointer"
|
||||||
|
@click="setIcon(iconName, close)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</Popover>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<script setup>
|
||||||
|
import { FormControl, Popover } from 'frappe-ui'
|
||||||
|
import * as icons from 'lucide-vue-next'
|
||||||
|
import { ref, computed, onMounted, nextTick } from 'vue'
|
||||||
|
|
||||||
|
const iconQuery = ref('')
|
||||||
|
const selectedIcon = ref('')
|
||||||
|
const search = ref(null)
|
||||||
|
const emit = defineEmits(['update:modelValue', 'change'])
|
||||||
|
|
||||||
|
const iconArray = ref(
|
||||||
|
Object.keys(icons)
|
||||||
|
.sort(() => 0.5 - Math.random())
|
||||||
|
.slice(0, 100)
|
||||||
|
.reduce((result, key) => {
|
||||||
|
result[key] = icons[key]
|
||||||
|
return result
|
||||||
|
}, {})
|
||||||
|
)
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
label: {
|
||||||
|
type: String,
|
||||||
|
default: 'Icon',
|
||||||
|
},
|
||||||
|
modelValue: {
|
||||||
|
type: String,
|
||||||
|
default: '',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
selectedIcon.value = props.modelValue
|
||||||
|
})
|
||||||
|
|
||||||
|
const setIcon = (icon, close) => {
|
||||||
|
emit('update:modelValue', icon)
|
||||||
|
selectedIcon.value = icon
|
||||||
|
iconQuery.value = ''
|
||||||
|
close()
|
||||||
|
}
|
||||||
|
|
||||||
|
const filteredIcons = computed(() => {
|
||||||
|
if (!iconQuery.value) {
|
||||||
|
return iconArray.value
|
||||||
|
}
|
||||||
|
|
||||||
|
return Object.keys(icons)
|
||||||
|
.filter((icon) =>
|
||||||
|
icon.toLowerCase().includes(iconQuery.value.toLowerCase())
|
||||||
|
)
|
||||||
|
.reduce((result, key) => {
|
||||||
|
result[key] = icons[key]
|
||||||
|
return result
|
||||||
|
}, {})
|
||||||
|
})
|
||||||
|
|
||||||
|
const openPopover = (togglePopover) => {
|
||||||
|
togglePopover()
|
||||||
|
}
|
||||||
|
</script>
|
||||||
@@ -118,6 +118,7 @@ const options = createResource({
|
|||||||
return {
|
return {
|
||||||
label: option.value,
|
label: option.value,
|
||||||
value: option.value,
|
value: option.value,
|
||||||
|
description: option.description,
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
|
|||||||
255
frontend/src/components/Controls/MultiSelect.vue
Normal file
255
frontend/src/components/Controls/MultiSelect.vue
Normal file
@@ -0,0 +1,255 @@
|
|||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<label class="block mb-1" :class="labelClasses" v-if="label">
|
||||||
|
{{ label }}
|
||||||
|
</label>
|
||||||
|
<div class="grid grid-cols-3 gap-1">
|
||||||
|
<Button
|
||||||
|
ref="emails"
|
||||||
|
v-for="value in values"
|
||||||
|
:key="value"
|
||||||
|
:label="value"
|
||||||
|
theme="gray"
|
||||||
|
variant="subtle"
|
||||||
|
class="rounded-md"
|
||||||
|
@keydown.delete.capture.stop="removeLastValue"
|
||||||
|
>
|
||||||
|
<template #suffix>
|
||||||
|
<X @click="removeValue(value)" class="h-4 w-4 stroke-1.5" />
|
||||||
|
</template>
|
||||||
|
</Button>
|
||||||
|
<div class="">
|
||||||
|
<Combobox v-model="selectedValue" nullable>
|
||||||
|
<Popover class="w-full" v-model:show="showOptions">
|
||||||
|
<template #target="{ togglePopover }">
|
||||||
|
<ComboboxInput
|
||||||
|
ref="search"
|
||||||
|
class="search-input form-input w-full focus-visible:!ring-0"
|
||||||
|
type="text"
|
||||||
|
:value="query"
|
||||||
|
@change="
|
||||||
|
(e) => {
|
||||||
|
query = e.target.value
|
||||||
|
showOptions = true
|
||||||
|
}
|
||||||
|
"
|
||||||
|
autocomplete="off"
|
||||||
|
@focus="() => togglePopover()"
|
||||||
|
@keydown.delete.capture.stop="removeLastValue"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
<template #body="{ isOpen }">
|
||||||
|
<div v-show="isOpen">
|
||||||
|
<div class="mt-1 rounded-lg bg-white py-1 text-base shadow-2xl">
|
||||||
|
<ComboboxOptions
|
||||||
|
class="my-1 max-h-[12rem] overflow-y-auto px-1.5"
|
||||||
|
static
|
||||||
|
>
|
||||||
|
<ComboboxOption
|
||||||
|
v-for="option in options"
|
||||||
|
:key="option.value"
|
||||||
|
:value="option"
|
||||||
|
v-slot="{ active }"
|
||||||
|
>
|
||||||
|
<li
|
||||||
|
:class="[
|
||||||
|
'flex cursor-pointer items-center rounded px-2 py-1 text-base',
|
||||||
|
{ 'bg-gray-100': active },
|
||||||
|
]"
|
||||||
|
>
|
||||||
|
<div class="flex flex-col gap-1 p-1">
|
||||||
|
<div class="text-base font-medium">
|
||||||
|
{{ option.description }}
|
||||||
|
</div>
|
||||||
|
<div class="text-sm text-gray-600">
|
||||||
|
{{ option.value }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
</ComboboxOption>
|
||||||
|
</ComboboxOptions>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</Popover>
|
||||||
|
</Combobox>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- <ErrorMessage class="mt-2 pl-2" v-if="error" :message="error" /> -->
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import {
|
||||||
|
Combobox,
|
||||||
|
ComboboxInput,
|
||||||
|
ComboboxOptions,
|
||||||
|
ComboboxOption,
|
||||||
|
} from '@headlessui/vue'
|
||||||
|
import { createResource, Popover, Button } from 'frappe-ui'
|
||||||
|
import { ref, computed, nextTick } from 'vue'
|
||||||
|
import { watchDebounced } from '@vueuse/core'
|
||||||
|
import { X } from 'lucide-vue-next'
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
label: {
|
||||||
|
type: String,
|
||||||
|
},
|
||||||
|
size: {
|
||||||
|
type: String,
|
||||||
|
default: 'sm',
|
||||||
|
},
|
||||||
|
doctype: {
|
||||||
|
type: String,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
filters: {
|
||||||
|
type: Object,
|
||||||
|
default: () => ({}),
|
||||||
|
},
|
||||||
|
validate: {
|
||||||
|
type: Function,
|
||||||
|
default: null,
|
||||||
|
},
|
||||||
|
errorMessage: {
|
||||||
|
type: Function,
|
||||||
|
default: (value) => `${value} is an Invalid value`,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const values = defineModel()
|
||||||
|
|
||||||
|
const emails = ref([])
|
||||||
|
const search = ref(null)
|
||||||
|
const error = ref(null)
|
||||||
|
const query = ref('')
|
||||||
|
const text = ref('')
|
||||||
|
const showOptions = ref(false)
|
||||||
|
|
||||||
|
const selectedValue = computed({
|
||||||
|
get: () => query.value || '',
|
||||||
|
set: (val) => {
|
||||||
|
query.value = ''
|
||||||
|
if (val) {
|
||||||
|
showOptions.value = false
|
||||||
|
}
|
||||||
|
val?.value && addValue(val.value)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
watchDebounced(
|
||||||
|
query,
|
||||||
|
(val) => {
|
||||||
|
val = val || ''
|
||||||
|
if (text.value === val) return
|
||||||
|
text.value = val
|
||||||
|
reload(val)
|
||||||
|
},
|
||||||
|
{ debounce: 300, immediate: true }
|
||||||
|
)
|
||||||
|
|
||||||
|
const filterOptions = createResource({
|
||||||
|
url: 'frappe.desk.search.search_link',
|
||||||
|
method: 'POST',
|
||||||
|
cache: [text.value, props.doctype],
|
||||||
|
params: {
|
||||||
|
txt: text.value,
|
||||||
|
doctype: props.doctype,
|
||||||
|
},
|
||||||
|
/* transform: (data) => {
|
||||||
|
let allData = data
|
||||||
|
.filter((c) => {
|
||||||
|
return c.description.split(', ')[1]
|
||||||
|
})
|
||||||
|
.map((option) => {
|
||||||
|
let email = option.description.split(', ')[1]
|
||||||
|
return {
|
||||||
|
label: option.label || email,
|
||||||
|
value: email,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return allData
|
||||||
|
}, */
|
||||||
|
})
|
||||||
|
|
||||||
|
const options = computed(() => {
|
||||||
|
return filterOptions.data || []
|
||||||
|
})
|
||||||
|
|
||||||
|
function reload(val) {
|
||||||
|
filterOptions.update({
|
||||||
|
params: {
|
||||||
|
txt: val,
|
||||||
|
doctype: props.doctype,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
filterOptions.reload()
|
||||||
|
}
|
||||||
|
|
||||||
|
const addValue = (value) => {
|
||||||
|
error.value = null
|
||||||
|
if (value) {
|
||||||
|
const splitValues = value.split(',')
|
||||||
|
splitValues.forEach((value) => {
|
||||||
|
value = value.trim()
|
||||||
|
if (value) {
|
||||||
|
// check if value is not already in the values array
|
||||||
|
if (!values.value?.includes(value)) {
|
||||||
|
// check if value is valid
|
||||||
|
if (value && props.validate && !props.validate(value)) {
|
||||||
|
error.value = props.errorMessage(value)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// add value to values array
|
||||||
|
if (!values.value) {
|
||||||
|
values.value = [value]
|
||||||
|
} else {
|
||||||
|
values.value.push(value)
|
||||||
|
}
|
||||||
|
value = value.replace(value, '')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
!error.value && (value = '')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const removeValue = (value) => {
|
||||||
|
values.value = values.value.filter((v) => v !== value)
|
||||||
|
}
|
||||||
|
|
||||||
|
const removeLastValue = () => {
|
||||||
|
if (query.value) return
|
||||||
|
|
||||||
|
let emailRef = emails.value[emails.value.length - 1]?.$el
|
||||||
|
if (document.activeElement === emailRef) {
|
||||||
|
values.value.pop()
|
||||||
|
nextTick(() => {
|
||||||
|
if (values.value.length) {
|
||||||
|
emailRef = emails.value[emails.value.length - 1].$el
|
||||||
|
emailRef?.focus()
|
||||||
|
} else {
|
||||||
|
setFocus()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
emailRef?.focus()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function setFocus() {
|
||||||
|
search.value.$el.focus()
|
||||||
|
}
|
||||||
|
|
||||||
|
defineExpose({ setFocus })
|
||||||
|
|
||||||
|
const labelClasses = computed(() => {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
sm: 'text-xs',
|
||||||
|
md: 'text-base',
|
||||||
|
}[props.size || 'sm'],
|
||||||
|
'text-gray-600',
|
||||||
|
]
|
||||||
|
})
|
||||||
|
</script>
|
||||||
@@ -2,15 +2,25 @@
|
|||||||
<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 theme="gray" size="md" class="mr-2" v-for="tag in course.tags">
|
class="flex items-center flex-wrap space-y-1 space-x-1 relative top-4 px-2 w-fit"
|
||||||
|
>
|
||||||
|
<Badge v-if="course.featured" variant="subtle" theme="green" size="md">
|
||||||
|
{{ __('Featured') }}
|
||||||
|
</Badge>
|
||||||
|
<Badge
|
||||||
|
variant="outline"
|
||||||
|
theme="gray"
|
||||||
|
size="md"
|
||||||
|
v-for="tag in course.tags"
|
||||||
|
>
|
||||||
{{ tag }}
|
{{ tag }}
|
||||||
</Badge>
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
@@ -62,18 +72,15 @@
|
|||||||
{{ 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>
|
||||||
<div
|
|
||||||
|
<ProgressBar
|
||||||
v-if="user && course.membership"
|
v-if="user && course.membership"
|
||||||
class="w-full bg-gray-200 rounded-full h-1 mb-2"
|
:progress="course.membership.progress"
|
||||||
>
|
/>
|
||||||
<div
|
|
||||||
class="bg-gray-900 h-1 rounded-full"
|
|
||||||
:style="{ width: Math.ceil(course.membership.progress) + '%' }"
|
|
||||||
></div>
|
|
||||||
</div>
|
|
||||||
<div v-if="user && course.membership" class="text-sm mb-4">
|
<div v-if="user && course.membership" class="text-sm mb-4">
|
||||||
{{ Math.ceil(course.membership.progress) }}% completed
|
{{ Math.ceil(course.membership.progress) }}% completed
|
||||||
</div>
|
</div>
|
||||||
@@ -81,7 +88,7 @@
|
|||||||
<div class="flex items-center justify-between mt-auto">
|
<div class="flex items-center justify-between mt-auto">
|
||||||
<div class="flex avatar-group overlap">
|
<div class="flex avatar-group overlap">
|
||||||
<div
|
<div
|
||||||
class="mr-1"
|
class="h-6 mr-1"
|
||||||
:class="{ 'avatar-group overlap': course.instructors.length > 1 }"
|
:class="{ 'avatar-group overlap': course.instructors.length > 1 }"
|
||||||
>
|
>
|
||||||
<UserAvatar
|
<UserAvatar
|
||||||
@@ -89,17 +96,7 @@
|
|||||||
:user="instructor"
|
:user="instructor"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<span v-if="course.instructors.length == 1">
|
<CourseInstructors :instructors="course.instructors" />
|
||||||
{{ course.instructors[0].full_name }}
|
|
||||||
</span>
|
|
||||||
<span v-if="course.instructors.length == 2">
|
|
||||||
{{ course.instructors[0].first_name }} and
|
|
||||||
{{ course.instructors[1].first_name }}
|
|
||||||
</span>
|
|
||||||
<span v-if="course.instructors.length > 2">
|
|
||||||
{{ course.instructors[0].first_name }} and
|
|
||||||
{{ course.instructors.length - 1 }} others
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="font-semibold">
|
<div class="font-semibold">
|
||||||
@@ -114,6 +111,8 @@ import { BookOpen, Users, Star } from 'lucide-vue-next'
|
|||||||
import UserAvatar from '@/components/UserAvatar.vue'
|
import UserAvatar from '@/components/UserAvatar.vue'
|
||||||
import { sessionStore } from '@/stores/session'
|
import { sessionStore } from '@/stores/session'
|
||||||
import { Badge, Tooltip } from 'frappe-ui'
|
import { Badge, Tooltip } from 'frappe-ui'
|
||||||
|
import CourseInstructors from '@/components/CourseInstructors.vue'
|
||||||
|
import ProgressBar from '@/components/ProgressBar.vue'
|
||||||
|
|
||||||
const { user } = sessionStore()
|
const { user } = sessionStore()
|
||||||
|
|
||||||
@@ -150,8 +149,8 @@ const props = defineProps({
|
|||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
background-color: theme('colors.orange.100');
|
background-color: theme('colors.green.100');
|
||||||
color: theme('colors.orange.600');
|
color: theme('colors.green.600');
|
||||||
}
|
}
|
||||||
|
|
||||||
.avatar-group {
|
.avatar-group {
|
||||||
|
|||||||
@@ -16,10 +16,10 @@
|
|||||||
params: {
|
params: {
|
||||||
courseName: course.name,
|
courseName: course.name,
|
||||||
chapterNumber: course.data.current_lesson
|
chapterNumber: course.data.current_lesson
|
||||||
? course.data.current_lesson.split('.')[0]
|
? course.data.current_lesson.split('-')[0]
|
||||||
: 1,
|
: 1,
|
||||||
lessonNumber: course.data.current_lesson
|
lessonNumber: course.data.current_lesson
|
||||||
? course.data.current_lesson.split('.')[1]
|
? course.data.current_lesson.split('-')[1]
|
||||||
: 1,
|
: 1,
|
||||||
},
|
},
|
||||||
}"
|
}"
|
||||||
@@ -46,6 +46,12 @@
|
|||||||
</span>
|
</span>
|
||||||
</Button>
|
</Button>
|
||||||
</router-link>
|
</router-link>
|
||||||
|
<div
|
||||||
|
v-else-if="course.data.disable_self_learning"
|
||||||
|
class="bg-blue-100 text-blue-900 text-sm rounded-md py-1 px-3"
|
||||||
|
>
|
||||||
|
{{ __('Contact the Administrator to enroll for this course.') }}
|
||||||
|
</div>
|
||||||
<Button
|
<Button
|
||||||
v-else
|
v-else
|
||||||
@click="enrollStudent()"
|
@click="enrollStudent()"
|
||||||
@@ -57,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,
|
||||||
},
|
},
|
||||||
@@ -130,12 +145,11 @@ function enrollStudent() {
|
|||||||
})
|
})
|
||||||
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',
|
||||||
})
|
})
|
||||||
console.log(props.course)
|
|
||||||
enrollStudentResource
|
enrollStudentResource
|
||||||
.submit({
|
.submit({
|
||||||
course: props.course.data.name,
|
course: props.course.data.name,
|
||||||
@@ -160,5 +174,48 @@ function enrollStudent() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const is_instructor = () => {}
|
const is_instructor = () => {
|
||||||
|
let user_is_instructor = false
|
||||||
|
props.course.data.instructors.forEach((instructor) => {
|
||||||
|
if (!user_is_instructor && instructor.name == user.data?.name) {
|
||||||
|
user_is_instructor = true
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return user_is_instructor
|
||||||
|
}
|
||||||
|
|
||||||
|
const canGetCertificate = computed(() => {
|
||||||
|
if (
|
||||||
|
props.course.data?.enable_certification &&
|
||||||
|
props.course.data?.membership?.progress == 100
|
||||||
|
) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
})
|
||||||
|
|
||||||
|
const certificate = createResource({
|
||||||
|
url: 'lms.lms.doctype.lms_certificate.lms_certificate.create_certificate',
|
||||||
|
makeParams(values) {
|
||||||
|
return {
|
||||||
|
course: values.course,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onSuccess(data) {
|
||||||
|
console.log(data)
|
||||||
|
window.open(
|
||||||
|
`/api/method/frappe.utils.print_format.download_pdf?doctype=LMS+Certificate&name=${
|
||||||
|
data.name
|
||||||
|
}&format=${encodeURIComponent(data.template)}`,
|
||||||
|
'_blank'
|
||||||
|
)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const fetchCertificate = () => {
|
||||||
|
certificate.submit({
|
||||||
|
course: props.course.data?.name,
|
||||||
|
member: user.data?.name,
|
||||||
|
})
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
50
frontend/src/components/CourseInstructors.vue
Normal file
50
frontend/src/components/CourseInstructors.vue
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
<template>
|
||||||
|
<span v-if="instructors.length == 1">
|
||||||
|
<router-link
|
||||||
|
:to="{
|
||||||
|
name: 'Profile',
|
||||||
|
params: { username: instructors[0].username },
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
{{ instructors[0].full_name }}
|
||||||
|
</router-link>
|
||||||
|
</span>
|
||||||
|
<span v-if="instructors.length == 2">
|
||||||
|
<router-link
|
||||||
|
:to="{
|
||||||
|
name: 'Profile',
|
||||||
|
params: { username: instructors[0].username },
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
{{ instructors[0].first_name }}
|
||||||
|
</router-link>
|
||||||
|
and
|
||||||
|
<router-link
|
||||||
|
:to="{
|
||||||
|
name: 'Profile',
|
||||||
|
params: { username: instructors[1].username },
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
{{ instructors[1].first_name }}
|
||||||
|
</router-link>
|
||||||
|
</span>
|
||||||
|
<span v-if="instructors.length > 2">
|
||||||
|
<router-link
|
||||||
|
:to="{
|
||||||
|
name: 'Profile',
|
||||||
|
params: { username: instructors[0].username },
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
{{ instructors[0].first_name }}
|
||||||
|
</router-link>
|
||||||
|
and {{ instructors.length - 1 }} others
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
<script setup>
|
||||||
|
const props = defineProps({
|
||||||
|
instructors: {
|
||||||
|
type: Array,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
</script>
|
||||||
@@ -2,9 +2,9 @@
|
|||||||
<div class="text-base">
|
<div class="text-base">
|
||||||
<div
|
<div
|
||||||
v-if="title && (outline.data?.length || allowEdit)"
|
v-if="title && (outline.data?.length || allowEdit)"
|
||||||
class="flex items-center justify-between mb-4"
|
class="grid grid-cols-[70%,30%] mb-4 px-2"
|
||||||
>
|
>
|
||||||
<div class="font-semibold" :class="allowEdit ? 'text-base' : 'text-lg'">
|
<div class="font-semibold text-lg">
|
||||||
{{ __(title) }}
|
{{ __(title) }}
|
||||||
</div>
|
</div>
|
||||||
<Button size="sm" v-if="allowEdit" @click="openChapterModal()">
|
<Button size="sm" v-if="allowEdit" @click="openChapterModal()">
|
||||||
@@ -25,7 +25,7 @@
|
|||||||
:key="chapter.name"
|
:key="chapter.name"
|
||||||
:defaultOpen="openChapterDetail(chapter.idx)"
|
:defaultOpen="openChapterDetail(chapter.idx)"
|
||||||
>
|
>
|
||||||
<DisclosureButton ref="" class="flex w-full px-2 py-3">
|
<DisclosureButton ref="" class="flex w-full p-2">
|
||||||
<ChevronRight
|
<ChevronRight
|
||||||
:class="{
|
:class="{
|
||||||
'rotate-90 transform duration-200': open,
|
'rotate-90 transform duration-200': open,
|
||||||
@@ -38,45 +38,58 @@
|
|||||||
{{ chapter.title }}
|
{{ chapter.title }}
|
||||||
</div>
|
</div>
|
||||||
</DisclosureButton>
|
</DisclosureButton>
|
||||||
<DisclosurePanel class="pb-2">
|
<DisclosurePanel>
|
||||||
<div v-for="lesson in chapter.lessons" :key="lesson.name">
|
<Draggable
|
||||||
<div class="outline-lesson pl-8 py-2">
|
:list="chapter.lessons"
|
||||||
<router-link
|
item-key="name"
|
||||||
:to="{
|
group="items"
|
||||||
name: allowEdit ? 'CreateLesson' : 'Lesson',
|
@end="updateOutline"
|
||||||
params: {
|
:data-chapter="chapter.name"
|
||||||
courseName: courseName,
|
>
|
||||||
chapterNumber: lesson.number.split('.')[0],
|
<template #item="{ element: lesson }">
|
||||||
lessonNumber: lesson.number.split('.')[1],
|
<div class="outline-lesson pl-8 py-2 pr-4">
|
||||||
},
|
<router-link
|
||||||
}"
|
:to="{
|
||||||
>
|
name: allowEdit ? 'LessonForm' : 'Lesson',
|
||||||
<div class="flex items-center text-sm leading-5">
|
params: {
|
||||||
<MonitorPlay
|
courseName: courseName,
|
||||||
v-if="lesson.icon === 'icon-youtube'"
|
chapterNumber: lesson.number.split('.')[0],
|
||||||
class="h-4 w-4 text-gray-900 stroke-1 mr-2"
|
lessonNumber: lesson.number.split('.')[1],
|
||||||
/>
|
},
|
||||||
<HelpCircle
|
}"
|
||||||
v-else-if="lesson.icon === 'icon-quiz'"
|
>
|
||||||
class="h-4 w-4 text-gray-900 stroke-1 mr-2"
|
<div class="flex items-center text-sm leading-5 group">
|
||||||
/>
|
<MonitorPlay
|
||||||
<FileText
|
v-if="lesson.icon === 'icon-youtube'"
|
||||||
v-else-if="lesson.icon === 'icon-list'"
|
class="h-4 w-4 text-gray-900 stroke-1 mr-2"
|
||||||
class="h-4 w-4 text-gray-900 stroke-1 mr-2"
|
/>
|
||||||
/>
|
<HelpCircle
|
||||||
{{ lesson.title }}
|
v-else-if="lesson.icon === 'icon-quiz'"
|
||||||
<Check
|
class="h-4 w-4 text-gray-900 stroke-1 mr-2"
|
||||||
v-if="lesson.is_complete"
|
/>
|
||||||
class="h-4 w-4 text-green-500 stroke-1.5 ml-2"
|
<FileText
|
||||||
/>
|
v-else-if="lesson.icon === 'icon-list'"
|
||||||
</div>
|
class="h-4 w-4 text-gray-900 stroke-1 mr-2"
|
||||||
</router-link>
|
/>
|
||||||
</div>
|
{{ lesson.title }}
|
||||||
</div>
|
<Trash2
|
||||||
<div v-if="allowEdit" class="flex mt-2 pl-8">
|
v-if="allowEdit"
|
||||||
|
@click.prevent="trashLesson(lesson.name, chapter.name)"
|
||||||
|
class="h-4 w-4 stroke-1.5 text-gray-700 ml-auto invisible group-hover:visible"
|
||||||
|
/>
|
||||||
|
<Check
|
||||||
|
v-if="lesson.is_complete"
|
||||||
|
class="h-4 w-4 text-green-700 ml-2"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</router-link>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</Draggable>
|
||||||
|
<div v-if="allowEdit" class="flex mt-2 mb-4 pl-8">
|
||||||
<router-link
|
<router-link
|
||||||
:to="{
|
:to="{
|
||||||
name: 'CreateLesson',
|
name: 'LessonForm',
|
||||||
params: {
|
params: {
|
||||||
courseName: courseName,
|
courseName: courseName,
|
||||||
chapterNumber: chapter.idx,
|
chapterNumber: chapter.idx,
|
||||||
@@ -106,6 +119,7 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { Button, createResource } from 'frappe-ui'
|
import { Button, createResource } from 'frappe-ui'
|
||||||
import { ref } from 'vue'
|
import { ref } from 'vue'
|
||||||
|
import Draggable from 'vuedraggable'
|
||||||
import { Disclosure, DisclosureButton, DisclosurePanel } from '@headlessui/vue'
|
import { Disclosure, DisclosureButton, DisclosurePanel } from '@headlessui/vue'
|
||||||
import {
|
import {
|
||||||
ChevronRight,
|
ChevronRight,
|
||||||
@@ -113,9 +127,11 @@ import {
|
|||||||
HelpCircle,
|
HelpCircle,
|
||||||
FileText,
|
FileText,
|
||||||
Check,
|
Check,
|
||||||
|
Trash2,
|
||||||
} from 'lucide-vue-next'
|
} from 'lucide-vue-next'
|
||||||
import { useRoute } from 'vue-router'
|
import { useRoute } from 'vue-router'
|
||||||
import ChapterModal from '@/components/Modals/ChapterModal.vue'
|
import ChapterModal from '@/components/Modals/ChapterModal.vue'
|
||||||
|
import { showToast } from '@/utils'
|
||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const expandAll = ref(true)
|
const expandAll = ref(true)
|
||||||
@@ -139,6 +155,10 @@ const props = defineProps({
|
|||||||
type: Boolean,
|
type: Boolean,
|
||||||
default: false,
|
default: false,
|
||||||
},
|
},
|
||||||
|
getProgress: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
const outline = createResource({
|
const outline = createResource({
|
||||||
@@ -146,10 +166,47 @@ const outline = createResource({
|
|||||||
cache: ['course_outline', props.courseName],
|
cache: ['course_outline', props.courseName],
|
||||||
params: {
|
params: {
|
||||||
course: props.courseName,
|
course: props.courseName,
|
||||||
|
progress: props.getProgress,
|
||||||
},
|
},
|
||||||
auto: true,
|
auto: true,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const deleteLesson = createResource({
|
||||||
|
url: 'lms.lms.api.delete_lesson',
|
||||||
|
makeParams(values) {
|
||||||
|
return {
|
||||||
|
lesson: values.lesson,
|
||||||
|
chapter: values.chapter,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onSuccess() {
|
||||||
|
outline.reload()
|
||||||
|
showToast('Success', 'Lesson deleted successfully', 'check')
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const updateLessonIndex = createResource({
|
||||||
|
url: 'lms.lms.api.update_lesson_index',
|
||||||
|
makeParams(values) {
|
||||||
|
return {
|
||||||
|
lesson: values.lesson,
|
||||||
|
sourceChapter: values.sourceChapter,
|
||||||
|
targetChapter: values.targetChapter,
|
||||||
|
idx: values.idx,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onSuccess() {
|
||||||
|
showToast('Success', 'Lesson moved successfully', 'check')
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const trashLesson = (lessonName, chapterName) => {
|
||||||
|
deleteLesson.submit({
|
||||||
|
lesson: lessonName,
|
||||||
|
chapter: chapterName,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
const openChapterDetail = (index) => {
|
const openChapterDetail = (index) => {
|
||||||
return index == route.params.chapterNumber || index == 1
|
return index == route.params.chapterNumber || index == 1
|
||||||
}
|
}
|
||||||
@@ -162,6 +219,15 @@ const openChapterModal = (chapter = null) => {
|
|||||||
const getCurrentChapter = () => {
|
const getCurrentChapter = () => {
|
||||||
return currentChapter.value
|
return currentChapter.value
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const updateOutline = (e) => {
|
||||||
|
updateLessonIndex.submit({
|
||||||
|
lesson: e.item.__draggable_context.element.name,
|
||||||
|
sourceChapter: e.from.dataset.chapter,
|
||||||
|
targetChapter: e.to.dataset.chapter,
|
||||||
|
idx: e.newIndex,
|
||||||
|
})
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
<style>
|
<style>
|
||||||
.outline-lesson:has(.router-link-active) {
|
.outline-lesson:has(.router-link-active) {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<template>
|
<template>
|
||||||
<div v-if="reviews.data" class="mt-20 mb-10">
|
<div v-if="reviews.data?.length || membership" class="mt-20 mb-10">
|
||||||
<Button
|
<Button
|
||||||
v-if="membership && !hasReviewed.data"
|
v-if="membership && !hasReviewed.data"
|
||||||
@click="openReviewModal()"
|
@click="openReviewModal()"
|
||||||
@@ -8,18 +8,30 @@
|
|||||||
{{ __('Write a Review') }}
|
{{ __('Write a Review') }}
|
||||||
</Button>
|
</Button>
|
||||||
<div class="flex items-center font-semibold text-2xl">
|
<div class="flex items-center font-semibold text-2xl">
|
||||||
<Star class="h-6 w-6 stroke-1 text-gray-50 fill-orange-500 mr-1" />
|
{{ __('Student Reviews') }}
|
||||||
{{ avg_rating }} {{ __('ratings and ') }} {{ reviews.data.length }}
|
|
||||||
{{ __('reviews') }}
|
|
||||||
</div>
|
</div>
|
||||||
<div class="grid gap-8 mt-10">
|
<div class="grid gap-8 mt-10">
|
||||||
<div v-for="(review, index) in reviews.data">
|
<div v-for="(review, index) in reviews.data">
|
||||||
<div class="flex items-center">
|
<div class="flex items-center">
|
||||||
<UserAvatar :user="review.owner_details" :size="'2xl'" />
|
<router-link
|
||||||
|
:to="{
|
||||||
|
name: 'Profile',
|
||||||
|
params: { username: review.owner_details.username },
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
<UserAvatar :user="review.owner_details" :size="'2xl'" />
|
||||||
|
</router-link>
|
||||||
<div class="mx-4">
|
<div class="mx-4">
|
||||||
<span class="text-lg font-medium mr-4">
|
<router-link
|
||||||
{{ review.owner_details.full_name }}
|
:to="{
|
||||||
</span>
|
name: 'Profile',
|
||||||
|
params: { username: review.owner_details.username },
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
<span class="text-lg font-medium mr-4">
|
||||||
|
{{ review.owner_details.full_name }}
|
||||||
|
</span>
|
||||||
|
</router-link>
|
||||||
<span>
|
<span>
|
||||||
{{ review.creation }}
|
{{ review.creation }}
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
@@ -5,7 +5,6 @@
|
|||||||
<div
|
<div
|
||||||
class="relative block min-h-0 flex-shrink-0 overflow-hidden hover:overflow-auto"
|
class="relative block min-h-0 flex-shrink-0 overflow-hidden hover:overflow-auto"
|
||||||
>
|
>
|
||||||
<slot name="sidebar" />
|
|
||||||
<AppSidebar />
|
<AppSidebar />
|
||||||
</div>
|
</div>
|
||||||
<div class="w-full overflow-auto" id="scrollContainer">
|
<div class="w-full overflow-auto" id="scrollContainer">
|
||||||
|
|||||||
@@ -69,9 +69,11 @@
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<TextEditor
|
<TextEditor
|
||||||
class="mt-5"
|
class="mt-5"
|
||||||
:content="newReply"
|
:content="newReply"
|
||||||
|
:mentions="mentionUsers"
|
||||||
@change="(val) => (newReply = val)"
|
@change="(val) => (newReply = val)"
|
||||||
placeholder="Type your reply here..."
|
placeholder="Type your reply here..."
|
||||||
:fixedMenu="true"
|
:fixedMenu="true"
|
||||||
@@ -92,13 +94,14 @@ import { createResource, TextEditor, Button, Dropdown } from 'frappe-ui'
|
|||||||
import { timeAgo } from '../utils'
|
import { timeAgo } from '../utils'
|
||||||
import UserAvatar from '@/components/UserAvatar.vue'
|
import UserAvatar from '@/components/UserAvatar.vue'
|
||||||
import { ChevronLeft, MoreHorizontal } from 'lucide-vue-next'
|
import { ChevronLeft, MoreHorizontal } from 'lucide-vue-next'
|
||||||
import { ref, inject, onMounted } from 'vue'
|
import { ref, inject, onMounted, computed } from 'vue'
|
||||||
import { createToast } from '../utils'
|
import { createToast } from '../utils'
|
||||||
|
|
||||||
const showTopics = defineModel('showTopics')
|
const showTopics = defineModel('showTopics')
|
||||||
const newReply = ref('')
|
const newReply = ref('')
|
||||||
const socket = inject('$socket')
|
const socket = inject('$socket')
|
||||||
const user = inject('$user')
|
const user = inject('$user')
|
||||||
|
const allUsers = inject('$allUsers')
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
topic: {
|
topic: {
|
||||||
@@ -147,6 +150,16 @@ const newReplyResource = createResource({
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const mentionUsers = computed(() => {
|
||||||
|
let users = Object.values(allUsers.data).map((user) => {
|
||||||
|
return {
|
||||||
|
value: user.name,
|
||||||
|
label: user.full_name,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return users
|
||||||
|
})
|
||||||
|
|
||||||
const postReply = () => {
|
const postReply = () => {
|
||||||
newReplyResource.submit(
|
newReplyResource.submit(
|
||||||
{},
|
{},
|
||||||
|
|||||||
@@ -40,13 +40,16 @@
|
|||||||
<div v-else-if="singleThread && topics.data">
|
<div v-else-if="singleThread && topics.data">
|
||||||
<DiscussionReplies :topic="topics.data" :singleThread="singleThread" />
|
<DiscussionReplies :topic="topics.data" :singleThread="singleThread" />
|
||||||
</div>
|
</div>
|
||||||
<div v-else class="flex justify-center border mt-5 p-5 rounded-md">
|
<div
|
||||||
<MessageSquareIcon class="w-10 h-10 stroke-1.5 text-gray-800 mr-2" />
|
v-else
|
||||||
<div>
|
class="flex flex-col items-center justify-center border-2 border-dashed mt-5 py-8 rounded-md"
|
||||||
<div class="text-xl font-semibold mb-2">
|
>
|
||||||
|
<MessageSquareText class="w-7 h-7 text-gray-500 stroke-1.5 mr-2" />
|
||||||
|
<div class="">
|
||||||
|
<div v-if="emptyStateTitle" class="font-medium mb-2">
|
||||||
{{ __(emptyStateTitle) }}
|
{{ __(emptyStateTitle) }}
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div class="text-gray-600">
|
||||||
{{ __(emptyStateText) }}
|
{{ __(emptyStateText) }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -60,13 +63,14 @@
|
|||||||
/>
|
/>
|
||||||
</template>
|
</template>
|
||||||
<script setup>
|
<script setup>
|
||||||
import { createResource, Button, TextEditor } 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 { timeAgo } from '../utils'
|
||||||
import { ref, onMounted, inject } from 'vue'
|
import { ref, onMounted, inject } from 'vue'
|
||||||
import DiscussionReplies from '@/components/DiscussionReplies.vue'
|
import DiscussionReplies from '@/components/DiscussionReplies.vue'
|
||||||
import DiscussionModal from '@/components/Modals/DiscussionModal.vue'
|
import DiscussionModal from '@/components/Modals/DiscussionModal.vue'
|
||||||
import { MessageSquareIcon } from 'lucide-vue-next'
|
import { MessageSquareText } from 'lucide-vue-next'
|
||||||
|
import { getScrollContainer } from '@/utils/scrollContainer'
|
||||||
|
|
||||||
const showTopics = ref(true)
|
const showTopics = ref(true)
|
||||||
const currentTopic = ref(null)
|
const currentTopic = ref(null)
|
||||||
@@ -89,16 +93,20 @@ const props = defineProps({
|
|||||||
},
|
},
|
||||||
emptyStateTitle: {
|
emptyStateTitle: {
|
||||||
type: String,
|
type: String,
|
||||||
default: 'No topics yet',
|
default: '',
|
||||||
},
|
},
|
||||||
emptyStateText: {
|
emptyStateText: {
|
||||||
type: String,
|
type: String,
|
||||||
default: 'Be the first to start a discussion',
|
default: 'Start a discussion',
|
||||||
},
|
},
|
||||||
singleThread: {
|
singleThread: {
|
||||||
type: Boolean,
|
type: Boolean,
|
||||||
default: false,
|
default: false,
|
||||||
},
|
},
|
||||||
|
scrollToBottom: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
@@ -107,8 +115,19 @@ onMounted(() => {
|
|||||||
socket.on('new_discussion_topic', (data) => {
|
socket.on('new_discussion_topic', (data) => {
|
||||||
topics.refresh()
|
topics.refresh()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
if (props.scrollToBottom) {
|
||||||
|
setTimeout(() => {
|
||||||
|
scrollToEnd()
|
||||||
|
}, 100)
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const scrollToEnd = () => {
|
||||||
|
let scrollContainer = getScrollContainer()
|
||||||
|
scrollContainer.scrollTop = scrollContainer.scrollHeight
|
||||||
|
}
|
||||||
|
|
||||||
const topics = createResource({
|
const topics = createResource({
|
||||||
url: 'lms.lms.utils.get_discussion_topics',
|
url: 'lms.lms.utils.get_discussion_topics',
|
||||||
cache: ['topics', props.doctype, props.docname],
|
cache: ['topics', props.doctype, props.docname],
|
||||||
|
|||||||
@@ -1,35 +1,31 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="flex shadow rounded-md p-4 h-full">
|
<div class="flex rounded p-1 lg:px-2 lg:py-2.5 hover:bg-gray-100">
|
||||||
<img
|
<div class="flex w-3/5 md:w-2/5">
|
||||||
:src="job.company_logo"
|
<img
|
||||||
class="w-12 h-12 rounded-lg object-contain mr-4"
|
:src="job.company_logo"
|
||||||
:alt="job.company_name"
|
class="w-12 h-12 rounded-lg object-contain mr-4"
|
||||||
/>
|
:alt="job.company_name"
|
||||||
<div>
|
/>
|
||||||
<div class="text-xl font-semibold mb-2">
|
|
||||||
{{ job.job_title }}
|
|
||||||
</div>
|
|
||||||
<div>
|
<div>
|
||||||
{{ __('posted by') }}
|
<div class="font-medium mb-1">
|
||||||
<span class="font-medium">
|
{{ job.job_title }}
|
||||||
|
</div>
|
||||||
|
<div class="text-gray-700">
|
||||||
{{ job.company_name }}
|
{{ job.company_name }}
|
||||||
</span>
|
</div>
|
||||||
</div>
|
|
||||||
<div class="flex items-center my-4">
|
|
||||||
<Badge :label="job.type" theme="green" size="lg" class="mr-4" />
|
|
||||||
<Badge :label="job.location.split(' ')[0]" theme="gray" size="lg">
|
|
||||||
<template #prefix>
|
|
||||||
<MapPin class="h-4 w-4 stroke-1.5" />
|
|
||||||
</template>
|
|
||||||
</Badge>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
{{ __('posted on') }}
|
|
||||||
<span class="font-medium">
|
|
||||||
{{ dayjs(job.creation).format('DD MMM YYYY') }}
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="flex justify-end w-1/5 text-gray-700">
|
||||||
|
{{ job.location.replace(',', '').split(' ')[0] }}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
class="flex justify-end w-1/5 text-gray-700 text-right hidden md:block"
|
||||||
|
>
|
||||||
|
{{ job.type }}
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-end w-1/5 text-sm text-gray-700 text-right">
|
||||||
|
{{ dayjs(job.creation).format('DD MMM YYYY') }}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<!-- <div class="flex flex-col shadow rounded-md p-4 h-full">
|
<!-- <div class="flex flex-col shadow rounded-md p-4 h-full">
|
||||||
<div class="flex justify-between">
|
<div class="flex justify-between">
|
||||||
|
|||||||
@@ -24,7 +24,12 @@
|
|||||||
<Quiz :quiz="getId(block)" />
|
<Quiz :quiz="getId(block)" />
|
||||||
</div>
|
</div>
|
||||||
<div v-else-if="block.includes('{{ Video')">
|
<div v-else-if="block.includes('{{ Video')">
|
||||||
<video controls width="100%" controlsList="nodownload">
|
<video
|
||||||
|
controls
|
||||||
|
width="100%"
|
||||||
|
controlsList="nodownload"
|
||||||
|
oncontextmenu="return false;"
|
||||||
|
>
|
||||||
<source :src="getId(block)" type="video/mp4" />
|
<source :src="getId(block)" type="video/mp4" />
|
||||||
</video>
|
</video>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
<div class="text-lg font-semibold">
|
<div class="text-lg font-semibold">
|
||||||
{{ __('Components') }}
|
{{ __('Components') }}
|
||||||
</div>
|
</div>
|
||||||
<div class="mt-5">
|
<div class="mt-5 space-y-4">
|
||||||
<Tooltip
|
<Tooltip
|
||||||
:text="
|
:text="
|
||||||
__(
|
__(
|
||||||
@@ -18,20 +18,31 @@
|
|||||||
<Select v-model="currentEditor" :options="getEditorOptions()" />
|
<Select v-model="currentEditor" :options="getEditorOptions()" />
|
||||||
</div>
|
</div>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
<div class="flex mt-4">
|
<div class="flex">
|
||||||
<Link
|
<Link
|
||||||
v-model="quiz"
|
:value="quiz"
|
||||||
class="flex-1"
|
class="flex-1"
|
||||||
doctype="LMS Quiz"
|
doctype="LMS Quiz"
|
||||||
:label="__('Select a Quiz')"
|
:label="__('Add an existing quiz')"
|
||||||
|
@change="(option) => addQuiz(option)"
|
||||||
/>
|
/>
|
||||||
<Button @click="addQuiz()" class="self-end ml-2">
|
<router-link
|
||||||
<template #icon>
|
:to="{
|
||||||
<Plus class="h-4 w-4 stroke-1.5" />
|
name: 'QuizCreation',
|
||||||
</template>
|
params: {
|
||||||
</Button>
|
quizID: 'new',
|
||||||
|
},
|
||||||
|
}"
|
||||||
|
class="self-end ml-2"
|
||||||
|
>
|
||||||
|
<Button>
|
||||||
|
<template #icon>
|
||||||
|
<Plus class="h-4 w-4 stroke-1.5" />
|
||||||
|
</template>
|
||||||
|
</Button>
|
||||||
|
</router-link>
|
||||||
</div>
|
</div>
|
||||||
<div class="mt-4">
|
<div class="">
|
||||||
<div class="text-xs text-gray-600 mb-1">
|
<div class="text-xs text-gray-600 mb-1">
|
||||||
{{ __('Add an image, video, pdf or audio.') }}
|
{{ __('Add an image, video, pdf or audio.') }}
|
||||||
</div>
|
</div>
|
||||||
@@ -48,7 +59,7 @@
|
|||||||
{{
|
{{
|
||||||
uploading
|
uploading
|
||||||
? __('Uploading {0}%').format(progress)
|
? __('Uploading {0}%').format(progress)
|
||||||
: __('Upload an File')
|
: __('Upload a File')
|
||||||
}}
|
}}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
@@ -68,13 +79,34 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="">
|
||||||
|
<div class="text-xs text-gray-600 mb-1">
|
||||||
|
{{
|
||||||
|
__(
|
||||||
|
'To add a YouTube video, paste the URL of the video in the editor.'
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
</div>
|
||||||
|
<YouTubeExplanation>
|
||||||
|
<template v-slot="{ togglePopover }">
|
||||||
|
<div
|
||||||
|
@click="togglePopover()"
|
||||||
|
class="flex items-center text-sm underline cursor-pointer"
|
||||||
|
>
|
||||||
|
<Info class="w-3 h-3 stroke-1.5 text-gray-700 mr-1" />
|
||||||
|
{{ __('Learn More') }}
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</YouTubeExplanation>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<script setup>
|
<script setup>
|
||||||
import Link from '@/components/Controls/Link.vue'
|
import Link from '@/components/Controls/Link.vue'
|
||||||
import { FileUploader, Button, Select, Tooltip } from 'frappe-ui'
|
import { FileUploader, Button, Select, Tooltip } from 'frappe-ui'
|
||||||
import { Plus, FileText } from 'lucide-vue-next'
|
import { Plus, FileText, Info } from 'lucide-vue-next'
|
||||||
import { ref, watch } from 'vue'
|
import { ref, watch } from 'vue'
|
||||||
|
import YouTubeExplanation from '@/components/Modals/YouTubeExplanation.vue'
|
||||||
|
|
||||||
const quiz = ref(null)
|
const quiz = ref(null)
|
||||||
const file = ref(null)
|
const file = ref(null)
|
||||||
@@ -91,11 +123,11 @@ const props = defineProps({
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
const addQuiz = () => {
|
const addQuiz = (value) => {
|
||||||
getCurrentEditor().caret.setToLastBlock('end', 0)
|
getCurrentEditor().caret.setToLastBlock('end', 0)
|
||||||
if (quiz.value) {
|
if (value) {
|
||||||
getCurrentEditor().blocks.insert('quiz', {
|
getCurrentEditor().blocks.insert('quiz', {
|
||||||
quiz: quiz.value,
|
quiz: value,
|
||||||
})
|
})
|
||||||
quiz.value = null
|
quiz.value = null
|
||||||
}
|
}
|
||||||
@@ -108,7 +140,7 @@ const addFile = (data) => {
|
|||||||
|
|
||||||
const validateFile = (file) => {
|
const validateFile = (file) => {
|
||||||
let extension = file.name.split('.').pop().toLowerCase()
|
let extension = file.name.split('.').pop().toLowerCase()
|
||||||
if (!['jpg', 'jpeg', 'png', 'mp4', 'mov', 'mp3'].includes(extension)) {
|
if (!['jpg', 'jpeg', 'png', 'mp4', 'mov', 'mp3', 'pdf'].includes(extension)) {
|
||||||
return 'Only image and video files are allowed.'
|
return 'Only image and video files are allowed.'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
<Button
|
<Button
|
||||||
v-if="user.data.is_moderator"
|
v-if="user.data.is_moderator"
|
||||||
variant="solid"
|
variant="solid"
|
||||||
class="float-right mb-3"
|
class="float-right mb-5"
|
||||||
@click="openLiveClassModal"
|
@click="openLiveClassModal"
|
||||||
>
|
>
|
||||||
<template #prefix>
|
<template #prefix>
|
||||||
@@ -12,48 +12,49 @@
|
|||||||
{{ __('Add Live Class') }}
|
{{ __('Add Live Class') }}
|
||||||
</span>
|
</span>
|
||||||
</Button>
|
</Button>
|
||||||
<div class="text-lg font-semibold mb-4">
|
<div class="text-lg font-semibold mb-5">
|
||||||
{{ __('Live Class') }}
|
{{ __('Live Class') }}
|
||||||
</div>
|
</div>
|
||||||
<div v-if="liveClasses.data?.length" class="grid grid-cols-2 gap-5">
|
<div v-if="liveClasses.data?.length" class="grid grid-cols-2 gap-5">
|
||||||
<div v-for="cls in liveClasses.data">
|
<div
|
||||||
<div class="border rounded-md p-3">
|
v-for="cls in liveClasses.data"
|
||||||
<div class="font-semibold text-lg mb-4">
|
class="flex flex-col border rounded-md h-full p-3"
|
||||||
{{ cls.title }}
|
>
|
||||||
</div>
|
<div class="font-semibold text-lg mb-4">
|
||||||
<div class="flex items-center mb-2">
|
{{ cls.title }}
|
||||||
<Calendar class="w-4 h-4 stroke-1.5" />
|
</div>
|
||||||
<span class="ml-2">
|
<div class="mb-4">
|
||||||
{{ dayjs(cls.date).format('DD MMMM YYYY') }}
|
{{ cls.description }}
|
||||||
</span>
|
</div>
|
||||||
</div>
|
<div class="flex items-center mb-2">
|
||||||
<div class="flex items-center mb-5">
|
<Calendar class="w-4 h-4 stroke-1.5" />
|
||||||
<Clock class="w-4 h-4 stroke-1.5" />
|
<span class="ml-2">
|
||||||
<span class="ml-2">
|
{{ dayjs(cls.date).format('DD MMMM YYYY') }}
|
||||||
{{ formatTime(cls.time) }}
|
</span>
|
||||||
</span>
|
</div>
|
||||||
</div>
|
<div class="flex items-center mb-5">
|
||||||
<div class="mb-5">
|
<Clock class="w-4 h-4 stroke-1.5" />
|
||||||
{{ cls.description }}
|
<span class="ml-2">
|
||||||
</div>
|
{{ formatTime(cls.time) }}
|
||||||
<div class="flex items-center gap-2">
|
</span>
|
||||||
<a
|
</div>
|
||||||
:href="cls.start_url"
|
<div class="flex items-center space-x-2 mt-auto">
|
||||||
target="_blank"
|
<a
|
||||||
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"
|
:href="cls.start_url"
|
||||||
>
|
target="_blank"
|
||||||
<Monitor class="h-4 w-4 stroke-1.5" />
|
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"
|
||||||
{{ __('Start') }}
|
>
|
||||||
</a>
|
<Monitor class="h-4 w-4 stroke-1.5" />
|
||||||
<a
|
{{ __('Start') }}
|
||||||
:href="cls.join_url"
|
</a>
|
||||||
target="_blank"
|
<a
|
||||||
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"
|
:href="cls.join_url"
|
||||||
>
|
target="_blank"
|
||||||
<Video class="h-4 w-4 stroke-1.5" />
|
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"
|
||||||
{{ __('Join') }}
|
>
|
||||||
</a>
|
<Video class="h-4 w-4 stroke-1.5" />
|
||||||
</div>
|
{{ __('Join') }}
|
||||||
|
</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -4,21 +4,21 @@
|
|||||||
<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 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}, 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"
|
||||||
@click="handleClick(tab)"
|
@click="handleClick(tab)"
|
||||||
>
|
>
|
||||||
<component
|
<component
|
||||||
:is="tab.icon"
|
:is="icons[tab.icon]"
|
||||||
class="h-6 w-6 stroke-1.5"
|
class="h-6 w-6 stroke-1.5"
|
||||||
:class="[isActive(tab) ? 'text-gray-900' : 'text-gray-600']"
|
:class="[isActive(tab) ? 'text-gray-900' : 'text-gray-600']"
|
||||||
/>
|
/>
|
||||||
@@ -29,17 +29,60 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { getSidebarLinks } from '../utils'
|
import { getSidebarLinks } from '../utils'
|
||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
import { computed, inject } from 'vue'
|
import { computed, ref, onMounted } from 'vue'
|
||||||
import { sessionStore } from '@/stores/session'
|
import { sessionStore } from '@/stores/session'
|
||||||
|
import { usersStore } from '@/stores/user'
|
||||||
|
import * as icons from 'lucide-vue-next'
|
||||||
|
|
||||||
const { logout, user } = sessionStore()
|
const { logout, user, sidebarSettings } = sessionStore()
|
||||||
let { isLoggedIn } = sessionStore()
|
let { isLoggedIn } = sessionStore()
|
||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const tabs = computed(() => {
|
let { userResource } = usersStore()
|
||||||
return getSidebarLinks()
|
const sidebarLinks = ref(getSidebarLinks())
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
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
|
||||||
|
)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
addAccessLinks()
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const addAccessLinks = () => {
|
||||||
|
if (user) {
|
||||||
|
sidebarLinks.value.push({
|
||||||
|
label: 'Profile',
|
||||||
|
icon: 'UserRound',
|
||||||
|
activeFor: [
|
||||||
|
'Profile',
|
||||||
|
'ProfileAbout',
|
||||||
|
'ProfileCertification',
|
||||||
|
'ProfileEvaluator',
|
||||||
|
'ProfileRoles',
|
||||||
|
],
|
||||||
|
})
|
||||||
|
sidebarLinks.value.push({
|
||||||
|
label: 'Log out',
|
||||||
|
icon: 'LogOut',
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
sidebarLinks.value.push({
|
||||||
|
label: 'Log in',
|
||||||
|
icon: 'LogIn',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
let isActive = (tab) => {
|
let isActive = (tab) => {
|
||||||
return tab.activeFor?.includes(router.currentRoute.value.name)
|
return tab.activeFor?.includes(router.currentRoute.value.name)
|
||||||
}
|
}
|
||||||
@@ -50,6 +93,13 @@ const handleClick = (tab) => {
|
|||||||
logout.submit().then(() => {
|
logout.submit().then(() => {
|
||||||
isLoggedIn = false
|
isLoggedIn = false
|
||||||
})
|
})
|
||||||
|
else if (tab.label == 'Profile')
|
||||||
|
router.push({
|
||||||
|
name: 'Profile',
|
||||||
|
params: {
|
||||||
|
username: userResource.data?.username,
|
||||||
|
},
|
||||||
|
})
|
||||||
else router.push({ name: tab.to })
|
else router.push({ name: tab.to })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -14,7 +14,13 @@
|
|||||||
}"
|
}"
|
||||||
>
|
>
|
||||||
<template #body-content>
|
<template #body-content>
|
||||||
<Link doctype="LMS Course" v-model="course" />
|
<Link doctype="LMS Course" v-model="course" :label="__('Course')" />
|
||||||
|
<Link
|
||||||
|
doctype="Course Evaluator"
|
||||||
|
v-model="evaluator"
|
||||||
|
:label="__('Evaluator')"
|
||||||
|
class="mt-4"
|
||||||
|
/>
|
||||||
</template>
|
</template>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
</template>
|
</template>
|
||||||
@@ -26,6 +32,7 @@ import { showToast } from '@/utils'
|
|||||||
|
|
||||||
const show = defineModel()
|
const show = defineModel()
|
||||||
const course = ref(null)
|
const course = ref(null)
|
||||||
|
const evaluator = ref(null)
|
||||||
const courses = defineModel('courses')
|
const courses = defineModel('courses')
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
@@ -45,6 +52,7 @@ const createBatchCourse = createResource({
|
|||||||
parenttype: 'LMS Batch',
|
parenttype: 'LMS Batch',
|
||||||
parentfield: 'courses',
|
parentfield: 'courses',
|
||||||
course: course.value,
|
course: course.value,
|
||||||
|
evaluator: evaluator.value,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -58,6 +66,7 @@ const addCourse = (close) => {
|
|||||||
courses.value.reload()
|
courses.value.reload()
|
||||||
close()
|
close()
|
||||||
course.value = null
|
course.value = null
|
||||||
|
evaluator.value = null
|
||||||
},
|
},
|
||||||
onError(err) {
|
onError(err) {
|
||||||
showToast('Error', err.message[0] || err, 'x')
|
showToast('Error', err.message[0] || err, 'x')
|
||||||
|
|||||||
@@ -21,8 +21,9 @@
|
|||||||
</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 } 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')
|
||||||
@@ -91,6 +92,7 @@ const addChapter = (close) => {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
onSuccess: (data) => {
|
onSuccess: (data) => {
|
||||||
|
capture('chapter_created')
|
||||||
chapterReference.submit(
|
chapterReference.submit(
|
||||||
{ name: data.name },
|
{ name: data.name },
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
size: '2xl',
|
size: '2xl',
|
||||||
actions: [
|
actions: [
|
||||||
{
|
{
|
||||||
label: 'Submit',
|
label: 'Post',
|
||||||
variant: 'solid',
|
variant: 'solid',
|
||||||
onClick: (close) => submitTopic(close),
|
onClick: (close) => submitTopic(close),
|
||||||
},
|
},
|
||||||
@@ -15,10 +15,7 @@
|
|||||||
<template #body-content>
|
<template #body-content>
|
||||||
<div class="flex flex-col gap-4">
|
<div class="flex flex-col gap-4">
|
||||||
<div>
|
<div>
|
||||||
<div class="mb-1.5 text-sm text-gray-600">
|
<FormControl v-model="topic.title" :label="__('Title')" type="text" />
|
||||||
{{ __('Title') }}
|
|
||||||
</div>
|
|
||||||
<Input type="text" v-model="topic.title" />
|
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<div class="mb-1.5 text-sm text-gray-600">
|
<div class="mb-1.5 text-sm text-gray-600">
|
||||||
@@ -37,8 +34,9 @@
|
|||||||
</Dialog>
|
</Dialog>
|
||||||
</template>
|
</template>
|
||||||
<script setup>
|
<script setup>
|
||||||
import { Dialog, Input, TextEditor, createResource } from 'frappe-ui'
|
import { Dialog, FormControl, TextEditor, createResource } from 'frappe-ui'
|
||||||
import { reactive, defineModel } from 'vue'
|
import { reactive, defineModel, computed } from 'vue'
|
||||||
|
import { showToast } from '@/utils'
|
||||||
|
|
||||||
const topics = defineModel('reloadTopics')
|
const topics = defineModel('reloadTopics')
|
||||||
|
|
||||||
@@ -93,6 +91,14 @@ const submitTopic = (close) => {
|
|||||||
topicResource.submit(
|
topicResource.submit(
|
||||||
{},
|
{},
|
||||||
{
|
{
|
||||||
|
validate() {
|
||||||
|
if (!topic.title) {
|
||||||
|
return 'Title cannot be empty.'
|
||||||
|
}
|
||||||
|
if (!topic.reply) {
|
||||||
|
return 'Reply cannot be empty.'
|
||||||
|
}
|
||||||
|
},
|
||||||
onSuccess(data) {
|
onSuccess(data) {
|
||||||
replyResource.submit(
|
replyResource.submit(
|
||||||
{
|
{
|
||||||
@@ -108,6 +114,9 @@ const submitTopic = (close) => {
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
|
onError(err) {
|
||||||
|
showToast('Error', err.message, 'x')
|
||||||
|
},
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
109
frontend/src/components/Modals/EditCoverImage.vue
Normal file
109
frontend/src/components/Modals/EditCoverImage.vue
Normal file
@@ -0,0 +1,109 @@
|
|||||||
|
<template>
|
||||||
|
<Popover transition="default">
|
||||||
|
<template #target="{ isOpen, togglePopover }" class="flex w-full">
|
||||||
|
<slot v-bind="{ isOpen, togglePopover }"></slot>
|
||||||
|
</template>
|
||||||
|
<template #body>
|
||||||
|
<div
|
||||||
|
class="absolute left-1/2 mt-3 w-96 max-w-lg -translate-x-1/2 transform rounded-lg bg-white px-4 sm:px-0 lg:max-w-3xl"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="overflow-hidden rounded-lg p-3 shadow-2xl ring-1 ring-black ring-opacity-5"
|
||||||
|
>
|
||||||
|
<div class="flex items-center justify-center space-x-2">
|
||||||
|
<TextInput
|
||||||
|
type="text"
|
||||||
|
placeholder="search by keyword"
|
||||||
|
v-model="search"
|
||||||
|
:debounce="300"
|
||||||
|
class="flex-1"
|
||||||
|
/>
|
||||||
|
<FileUploader
|
||||||
|
:fileTypes="['image/*']"
|
||||||
|
:validateFile="validateFile"
|
||||||
|
@success="(file) => saveImage(file)"
|
||||||
|
>
|
||||||
|
<template
|
||||||
|
v-slot="{ file, progress, uploading, openFileSelector }"
|
||||||
|
>
|
||||||
|
<div class="">
|
||||||
|
<Button @click="openFileSelector" :loading="uploading">
|
||||||
|
{{ uploading ? `Uploading ${progress}%` : 'Upload Image' }}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</FileUploader>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
class="relative mt-2 grid w-[25.5rem] gap-2 bg-white lg:grid-cols-2"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
v-for="image in images.data"
|
||||||
|
:key="image.id"
|
||||||
|
class="h-[50px] w-[200px] overflow-hidden rounded hover:opacity-80"
|
||||||
|
@click="$emit('select', image.urls.raw)"
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
:src="
|
||||||
|
image.urls.raw +
|
||||||
|
'&w=200&h=50&fit=crop&crop=entropy,faces,focalpoint'
|
||||||
|
"
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-if="images.data"
|
||||||
|
class="mt-2 text-center text-sm text-gray-500"
|
||||||
|
>
|
||||||
|
{{ __('Image search powered by') }}
|
||||||
|
<a class="underline" target="_blank" href="https://unsplash.com">
|
||||||
|
{{ __('Unsplash') }}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</Popover>
|
||||||
|
</template>
|
||||||
|
<script setup>
|
||||||
|
import {
|
||||||
|
Popover,
|
||||||
|
TextInput,
|
||||||
|
FileUploader,
|
||||||
|
Button,
|
||||||
|
createResource,
|
||||||
|
} from 'frappe-ui'
|
||||||
|
import { ref, watch } from 'vue'
|
||||||
|
|
||||||
|
const search = ref(null)
|
||||||
|
const emit = defineEmits(['select'])
|
||||||
|
|
||||||
|
const images = createResource({
|
||||||
|
url: 'lms.lms.api.get_unsplash_photos',
|
||||||
|
makeParams: () => {
|
||||||
|
return {
|
||||||
|
keyword: search.value,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
auto: true,
|
||||||
|
debounce: 500,
|
||||||
|
})
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => search.value,
|
||||||
|
() => {
|
||||||
|
images.reload()
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
const saveImage = (file) => {
|
||||||
|
emit('select', file.file_url)
|
||||||
|
}
|
||||||
|
|
||||||
|
const validateFile = (file) => {
|
||||||
|
let extension = file.name.split('.').pop().toLowerCase()
|
||||||
|
if (!['jpg', 'jpeg', 'png'].includes(extension)) {
|
||||||
|
return 'Only image file is allowed.'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
178
frontend/src/components/Modals/EditProfile.vue
Normal file
178
frontend/src/components/Modals/EditProfile.vue
Normal file
@@ -0,0 +1,178 @@
|
|||||||
|
<template>
|
||||||
|
<Dialog
|
||||||
|
:options="{
|
||||||
|
title: 'Edit your profile',
|
||||||
|
size: 'xl',
|
||||||
|
actions: [
|
||||||
|
{
|
||||||
|
label: 'Save',
|
||||||
|
variant: 'solid',
|
||||||
|
onClick: (close) => saveProfile(close),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
<template #body-content>
|
||||||
|
<div>
|
||||||
|
<FileUploader
|
||||||
|
v-if="!profile.image"
|
||||||
|
:fileTypes="['image/*']"
|
||||||
|
:validateFile="validateFile"
|
||||||
|
@success="(file) => saveImage(file)"
|
||||||
|
>
|
||||||
|
<template v-slot="{ file, progress, uploading, openFileSelector }">
|
||||||
|
<div class="mb-4">
|
||||||
|
<Button @click="openFileSelector" :loading="uploading">
|
||||||
|
{{
|
||||||
|
uploading
|
||||||
|
? `Uploading ${progress}%`
|
||||||
|
: 'Upload a profile image'
|
||||||
|
}}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</FileUploader>
|
||||||
|
<div v-else class="mb-4">
|
||||||
|
<div class="text-xs text-gray-600 mb-1">
|
||||||
|
{{ __('Profile Image') }}
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center">
|
||||||
|
<div class="border rounded-md p-2 mr-2">
|
||||||
|
<FileText class="h-5 w-5 stroke-1.5 text-gray-700" />
|
||||||
|
</div>
|
||||||
|
<div class="text-base flex flex-col">
|
||||||
|
<span>
|
||||||
|
{{ profile.image.file_name }}
|
||||||
|
</span>
|
||||||
|
<span class="text-sm text-gray-500 mt-1">
|
||||||
|
{{ getFileSize(profile.image.file_size) }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<X
|
||||||
|
@click="removeImage()"
|
||||||
|
class="bg-gray-200 rounded-md cursor-pointer stroke-1.5 w-5 h-5 p-1 ml-4"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<FormControl
|
||||||
|
v-model="profile.first_name"
|
||||||
|
:label="__('First Name')"
|
||||||
|
class="mb-4"
|
||||||
|
/>
|
||||||
|
<FormControl
|
||||||
|
v-model="profile.last_name"
|
||||||
|
:label="__('Last Name')"
|
||||||
|
class="mb-4"
|
||||||
|
/>
|
||||||
|
<FormControl
|
||||||
|
v-model="profile.headline"
|
||||||
|
:label="__('Headline')"
|
||||||
|
class="mb-4"
|
||||||
|
/>
|
||||||
|
<FormControl type="textarea" v-model="profile.bio" :label="__('Bio')" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</Dialog>
|
||||||
|
</template>
|
||||||
|
<script setup>
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
FormControl,
|
||||||
|
FileUploader,
|
||||||
|
Button,
|
||||||
|
createResource,
|
||||||
|
} from 'frappe-ui'
|
||||||
|
import { reactive, watch, defineModel } from 'vue'
|
||||||
|
import { FileText, X } from 'lucide-vue-next'
|
||||||
|
import { getFileSize, showToast } from '@/utils'
|
||||||
|
|
||||||
|
const reloadProfile = defineModel('reloadProfile')
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
profile: {
|
||||||
|
type: Object,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const profile = reactive({
|
||||||
|
first_name: '',
|
||||||
|
last_name: '',
|
||||||
|
headline: '',
|
||||||
|
bio: '',
|
||||||
|
image: '',
|
||||||
|
})
|
||||||
|
|
||||||
|
const imageResource = createResource({
|
||||||
|
url: 'lms.lms.api.get_file_info',
|
||||||
|
makeParams(values) {
|
||||||
|
return {
|
||||||
|
file_url: values.image,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
auto: false,
|
||||||
|
onSuccess(data) {
|
||||||
|
profile.image = data
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const updateProfile = createResource({
|
||||||
|
url: 'frappe.client.set_value',
|
||||||
|
makeParams(values) {
|
||||||
|
return {
|
||||||
|
doctype: 'User',
|
||||||
|
name: props.profile.data.name,
|
||||||
|
fieldname: {
|
||||||
|
user_image: profile.image.file_url,
|
||||||
|
...profile,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onSuccess(data) {
|
||||||
|
props.profile.data = data
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const saveProfile = (close) => {
|
||||||
|
updateProfile.submit(
|
||||||
|
{},
|
||||||
|
{
|
||||||
|
onSuccess() {
|
||||||
|
close()
|
||||||
|
reloadProfile.value.reload()
|
||||||
|
},
|
||||||
|
onError(err) {
|
||||||
|
showToast('Error', err.messages?.[0] || err, 'x')
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const validateFile = (file) => {
|
||||||
|
let extension = file.name.split('.').pop().toLowerCase()
|
||||||
|
if (!['jpg', 'jpeg', 'png'].includes(extension)) {
|
||||||
|
return 'Only image file is allowed.'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const saveImage = (file) => {
|
||||||
|
profile.image = file
|
||||||
|
}
|
||||||
|
|
||||||
|
const removeImage = () => {
|
||||||
|
profile.image = null
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.profile.data,
|
||||||
|
(newVal) => {
|
||||||
|
if (newVal) {
|
||||||
|
profile.first_name = newVal.first_name
|
||||||
|
profile.last_name = newVal.last_name
|
||||||
|
profile.headline = newVal.headline
|
||||||
|
profile.bio = newVal.bio
|
||||||
|
if (newVal.user_image) imageResource.submit({ image: newVal.user_image })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
</script>
|
||||||
@@ -25,7 +25,7 @@
|
|||||||
<div class="mb-1.5 text-sm text-gray-600">
|
<div class="mb-1.5 text-sm text-gray-600">
|
||||||
{{ __('Date') }}
|
{{ __('Date') }}
|
||||||
</div>
|
</div>
|
||||||
<DatePicker v-model="evaluation.date" />
|
<FormControl type="date" v-model="evaluation.date" />
|
||||||
</div>
|
</div>
|
||||||
<div v-if="slots.data?.length">
|
<div v-if="slots.data?.length">
|
||||||
<div class="mb-1.5 text-sm text-gray-600">
|
<div class="mb-1.5 text-sm text-gray-600">
|
||||||
@@ -46,7 +46,10 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div v-else class="text-sm italic text-red-600">
|
<div
|
||||||
|
v-else-if="evaluation.course && evaluation.date"
|
||||||
|
class="text-sm italic text-red-600"
|
||||||
|
>
|
||||||
{{ __('No slots available for this date.') }}
|
{{ __('No slots available for this date.') }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -54,7 +57,7 @@
|
|||||||
</Dialog>
|
</Dialog>
|
||||||
</template>
|
</template>
|
||||||
<script setup>
|
<script setup>
|
||||||
import { Dialog, createResource, Select, DatePicker } from 'frappe-ui'
|
import { Dialog, createResource, Select, FormControl } from 'frappe-ui'
|
||||||
import { defineModel, reactive, watch, inject } from 'vue'
|
import { defineModel, reactive, watch, inject } from 'vue'
|
||||||
import { createToast, formatTime } from '@/utils/'
|
import { createToast, formatTime } from '@/utils/'
|
||||||
|
|
||||||
@@ -113,7 +116,7 @@ function submitEvaluation(close) {
|
|||||||
if (!evaluation.start_time) {
|
if (!evaluation.start_time) {
|
||||||
return 'Please select a slot.'
|
return 'Please select a slot.'
|
||||||
}
|
}
|
||||||
if (dayjs(evaluation.date).isSameOrBefore(dayjs(), 'day')) {
|
if (dayjs(evaluation.date).isBefore(dayjs(), 'day')) {
|
||||||
return 'Please select a future date.'
|
return 'Please select a future date.'
|
||||||
}
|
}
|
||||||
if (dayjs(evaluation.date).isAfter(dayjs(props.endDate), 'day')) {
|
if (dayjs(evaluation.date).isAfter(dayjs(props.endDate), 'day')) {
|
||||||
@@ -127,11 +130,14 @@ function submitEvaluation(close) {
|
|||||||
close()
|
close()
|
||||||
},
|
},
|
||||||
onError(err) {
|
onError(err) {
|
||||||
|
let message = err.messages?.[0] || err
|
||||||
|
let unavailabilityMessage = message.includes('unavailable')
|
||||||
|
|
||||||
createToast({
|
createToast({
|
||||||
title: 'Error',
|
title: unavailabilityMessage ? 'Evaluator is Unavailable' : 'Error',
|
||||||
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,
|
||||||
})
|
})
|
||||||
@@ -165,7 +171,7 @@ watch(
|
|||||||
() => evaluation.date,
|
() => evaluation.date,
|
||||||
(date) => {
|
(date) => {
|
||||||
evaluation.start_time = ''
|
evaluation.start_time = ''
|
||||||
if (date) {
|
if (date && evaluation.course) {
|
||||||
slots.submit(evaluation)
|
slots.submit(evaluation)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,78 +17,61 @@
|
|||||||
<div class="flex flex-col gap-4">
|
<div class="flex flex-col gap-4">
|
||||||
<div class="grid grid-cols-2 gap-4">
|
<div class="grid grid-cols-2 gap-4">
|
||||||
<div>
|
<div>
|
||||||
<div class="mb-4">
|
<FormControl
|
||||||
<div class="mb-1.5 text-sm text-gray-600">
|
type="text"
|
||||||
{{ __('Title') }}
|
v-model="liveClass.title"
|
||||||
</div>
|
:label="__('Title')"
|
||||||
<Input type="text" v-model="liveClass.title" />
|
class="mb-4"
|
||||||
</div>
|
/>
|
||||||
<div class="mb-4">
|
<Tooltip
|
||||||
<div class="mb-1.5 text-sm text-gray-600">
|
:text="
|
||||||
<Tooltip
|
__(
|
||||||
class="flex items-center"
|
'Time must be in 24 hour format (HH:mm). Example 11:30 or 22:00'
|
||||||
:text="
|
)
|
||||||
__(
|
"
|
||||||
'Time must be in 24 hour format (HH:mm). Example 11:30 or 22:00'
|
>
|
||||||
)
|
<FormControl
|
||||||
"
|
v-model="liveClass.time"
|
||||||
>
|
type="time"
|
||||||
<span>
|
:label="__('Time')"
|
||||||
{{ __('Time') }}
|
class="mb-4"
|
||||||
</span>
|
|
||||||
<Info class="stroke-2 w-3 h-3 ml-1" />
|
|
||||||
</Tooltip>
|
|
||||||
</div>
|
|
||||||
<Input v-model="liveClass.time" />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<div class="mb-1.5 text-sm text-gray-600">
|
|
||||||
{{ __('Timezone') }}
|
|
||||||
</div>
|
|
||||||
<Select
|
|
||||||
v-model="liveClass.timezone"
|
|
||||||
:options="getTimezoneOptions()"
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</Tooltip>
|
||||||
|
<FormControl
|
||||||
|
v-model="liveClass.timezone"
|
||||||
|
type="select"
|
||||||
|
:options="getTimezoneOptions()"
|
||||||
|
:label="__('Timezone')"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<div class="mb-4">
|
<FormControl
|
||||||
<div class="mb-1.5 text-sm text-gray-600">
|
v-model="liveClass.date"
|
||||||
{{ __('Date') }}
|
type="date"
|
||||||
</div>
|
class="mb-4"
|
||||||
<DatePicker v-model="liveClass.date" inputClass="w-full" />
|
:label="__('Date')"
|
||||||
</div>
|
/>
|
||||||
<div class="mb-4">
|
<Tooltip :text="__('Duration of the live class in minutes')">
|
||||||
<div class="mb-1.5 text-sm text-gray-600">
|
<FormControl
|
||||||
<Tooltip
|
type="number"
|
||||||
class="flex items-center"
|
v-model="liveClass.duration"
|
||||||
:text="__('Duration of the live class in minutes')"
|
:label="__('Duration')"
|
||||||
>
|
class="mb-4"
|
||||||
<span>
|
|
||||||
{{ __('Duration') }}
|
|
||||||
</span>
|
|
||||||
<Info class="stroke-2 w-3 h-3 ml-1" />
|
|
||||||
</Tooltip>
|
|
||||||
</div>
|
|
||||||
<Input type="number" v-model="liveClass.duration" />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<div class="mb-1.5 text-sm text-gray-600">
|
|
||||||
{{ __('Auto Recording') }}
|
|
||||||
</div>
|
|
||||||
<Select
|
|
||||||
v-model="liveClass.auto_recording"
|
|
||||||
:options="getRecordingOptions()"
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</Tooltip>
|
||||||
|
<FormControl
|
||||||
|
v-model="liveClass.auto_recording"
|
||||||
|
type="select"
|
||||||
|
:options="getRecordingOptions()"
|
||||||
|
:label="__('Auto Recording')"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<FormControl
|
||||||
<div class="mb-1.5 text-sm text-gray-600">
|
v-model="liveClass.description"
|
||||||
{{ __('Description') }}
|
type="textarea"
|
||||||
</div>
|
:label="__('Description')"
|
||||||
<Textarea v-model="liveClass.description" />
|
/>
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
@@ -102,6 +85,7 @@ import {
|
|||||||
Dialog,
|
Dialog,
|
||||||
createResource,
|
createResource,
|
||||||
Tooltip,
|
Tooltip,
|
||||||
|
FormControl,
|
||||||
} from 'frappe-ui'
|
} from 'frappe-ui'
|
||||||
import { reactive, inject } from 'vue'
|
import { reactive, inject } from 'vue'
|
||||||
import { getTimezones, createToast } from '@/utils/'
|
import { getTimezones, createToast } from '@/utils/'
|
||||||
@@ -169,7 +153,7 @@ const createLiveClass = createResource({
|
|||||||
})
|
})
|
||||||
|
|
||||||
const submitLiveClass = (close) => {
|
const submitLiveClass = (close) => {
|
||||||
createLiveClass.submit(liveClass, {
|
return createLiveClass.submit(liveClass, {
|
||||||
validate() {
|
validate() {
|
||||||
if (!liveClass.title) {
|
if (!liveClass.title) {
|
||||||
return 'Please enter a title.'
|
return 'Please enter a title.'
|
||||||
|
|||||||
90
frontend/src/components/Modals/PageModal.vue
Normal file
90
frontend/src/components/Modals/PageModal.vue
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
<template>
|
||||||
|
<Dialog
|
||||||
|
v-model="show"
|
||||||
|
class="text-base"
|
||||||
|
:options="{
|
||||||
|
title: __('Add web page to sidebar'),
|
||||||
|
size: 'lg',
|
||||||
|
actions: [
|
||||||
|
{
|
||||||
|
label: 'Add',
|
||||||
|
variant: 'solid',
|
||||||
|
onClick: (close) => {
|
||||||
|
addWebPage(close)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
<template #body-content>
|
||||||
|
<Link
|
||||||
|
v-model="page.webpage"
|
||||||
|
doctype="Web Page"
|
||||||
|
:label="__('Web Page')"
|
||||||
|
:filters="{
|
||||||
|
published: 1,
|
||||||
|
}"
|
||||||
|
/>
|
||||||
|
<IconPicker v-model="page.icon" :label="__('Icon')" class="mt-4" />
|
||||||
|
</template>
|
||||||
|
</Dialog>
|
||||||
|
</template>
|
||||||
|
<script setup>
|
||||||
|
import { Dialog, createResource } from 'frappe-ui'
|
||||||
|
import Link from '@/components/Controls/Link.vue'
|
||||||
|
import { reactive, watch } from 'vue'
|
||||||
|
import IconPicker from '@/components/Controls/IconPicker.vue'
|
||||||
|
import { showToast } from '@/utils'
|
||||||
|
|
||||||
|
const sidebar = defineModel('reloadSidebar')
|
||||||
|
const show = defineModel()
|
||||||
|
const page = reactive({
|
||||||
|
icon: '',
|
||||||
|
webpage: '',
|
||||||
|
})
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
page: {
|
||||||
|
type: Object,
|
||||||
|
default: null,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const webPage = createResource({
|
||||||
|
url: 'lms.lms.api.update_sidebar_item',
|
||||||
|
makeParams(values) {
|
||||||
|
return {
|
||||||
|
webpage: page.webpage,
|
||||||
|
icon: page.icon,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.page,
|
||||||
|
(newPage) => {
|
||||||
|
if (newPage) {
|
||||||
|
page.icon = newPage.icon
|
||||||
|
page.webpage = newPage.web_page
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ immediate: true }
|
||||||
|
)
|
||||||
|
|
||||||
|
const addWebPage = (close) => {
|
||||||
|
webPage.submit(
|
||||||
|
{},
|
||||||
|
{
|
||||||
|
onSuccess() {
|
||||||
|
sidebar.value.reload()
|
||||||
|
close()
|
||||||
|
showToast('Success', 'Web page added to sidebar', 'check')
|
||||||
|
},
|
||||||
|
onError(err) {
|
||||||
|
showToast('Error', err.message[0] || err, 'x')
|
||||||
|
close()
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
</script>
|
||||||
348
frontend/src/components/Modals/Question.vue
Normal file
348
frontend/src/components/Modals/Question.vue
Normal file
@@ -0,0 +1,348 @@
|
|||||||
|
<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']"
|
||||||
|
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 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 (questionData.data?.name) 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.message?.[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.message?.[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.message?.[0] || err), 'x')
|
||||||
|
close()
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
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>
|
||||||
31
frontend/src/components/Modals/YouTubeExplanation.vue
Normal file
31
frontend/src/components/Modals/YouTubeExplanation.vue
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
<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>
|
||||||
42
frontend/src/components/NoPermission.vue
Normal file
42
frontend/src/components/NoPermission.vue
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
<template>
|
||||||
|
<div class="border rounded-md w-1/3 mx-auto my-32">
|
||||||
|
<div class="border-b px-5 py-3 font-medium">
|
||||||
|
<span
|
||||||
|
class="inline-flex items-center before:bg-red-600 before:w-2 before:h-2 before:rounded-md before:mr-2"
|
||||||
|
></span>
|
||||||
|
{{ __('Not Permitted') }}
|
||||||
|
</div>
|
||||||
|
<div v-if="user.data" class="px-5 py-3">
|
||||||
|
<div>
|
||||||
|
{{ __('You do not have permission to access this page.') }}
|
||||||
|
</div>
|
||||||
|
<router-link
|
||||||
|
:to="{
|
||||||
|
name: 'Courses',
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
<Button variant="solid" class="mt-2">
|
||||||
|
{{ __('Checkout Courses') }}
|
||||||
|
</Button>
|
||||||
|
</router-link>
|
||||||
|
</div>
|
||||||
|
<div class="px-5 py-3">
|
||||||
|
<div>
|
||||||
|
{{ __('Please login to access this page.') }}
|
||||||
|
</div>
|
||||||
|
<Button variant="solid" @click="redirectToLogin()" class="mt-2">
|
||||||
|
{{ __('Login') }}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<script setup>
|
||||||
|
import { inject } from 'vue'
|
||||||
|
import { Button } from 'frappe-ui'
|
||||||
|
|
||||||
|
const user = inject('$user')
|
||||||
|
|
||||||
|
const redirectToLogin = () => {
|
||||||
|
window.location.href = '/login'
|
||||||
|
}
|
||||||
|
</script>
|
||||||
24
frontend/src/components/ProgressBar.vue
Normal file
24
frontend/src/components/ProgressBar.vue
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
<template>
|
||||||
|
<div class="w-full bg-gray-200 rounded-full h-1 my-2">
|
||||||
|
<div
|
||||||
|
class="bg-gray-900 h-1 rounded-full"
|
||||||
|
:style="{ width: progressBarWidth }"
|
||||||
|
></div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { computed } from 'vue'
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
progress: {
|
||||||
|
type: Number,
|
||||||
|
default: 0,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const progressBarWidth = computed(() => {
|
||||||
|
const formattedPercentage = Math.min(Math.ceil(props.progress), 100)
|
||||||
|
return `${formattedPercentage}%`
|
||||||
|
})
|
||||||
|
</script>
|
||||||
@@ -3,9 +3,7 @@
|
|||||||
<div class="bg-blue-100 py-2 px-2 mb-4 rounded-md text-sm text-blue-800">
|
<div class="bg-blue-100 py-2 px-2 mb-4 rounded-md text-sm text-blue-800">
|
||||||
<div class="leading-relaxed">
|
<div class="leading-relaxed">
|
||||||
{{
|
{{
|
||||||
__('This quiz consists of {0} questions.').format(
|
__('This quiz consists of {0} questions.').format(questions.length)
|
||||||
quiz.data.questions.length
|
|
||||||
)
|
|
||||||
}}
|
}}
|
||||||
</div>
|
</div>
|
||||||
<div v-if="quiz.data.passing_percentage" class="leading-relaxed">
|
<div v-if="quiz.data.passing_percentage" class="leading-relaxed">
|
||||||
@@ -59,7 +57,7 @@
|
|||||||
</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"
|
||||||
@@ -69,7 +67,10 @@
|
|||||||
<span class="mr-2">
|
<span class="mr-2">
|
||||||
{{ __('Question {0}').format(activeQuestion) }}:
|
{{ __('Question {0}').format(activeQuestion) }}:
|
||||||
</span>
|
</span>
|
||||||
<span>
|
<span v-if="questionDetails.data.type == 'User Input'">
|
||||||
|
{{ __('Type your answer') }}
|
||||||
|
</span>
|
||||||
|
<span v-else>
|
||||||
{{
|
{{
|
||||||
questionDetails.data.multiple
|
questionDetails.data.multiple
|
||||||
? __('Choose all answers that apply')
|
? __('Choose all answers that apply')
|
||||||
@@ -82,9 +83,10 @@
|
|||||||
{{ question.marks == 1 ? __('Mark') : __('Marks') }}
|
{{ question.marks == 1 ? __('Mark') : __('Marks') }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="text-gray-900 font-semibold mt-2">
|
<div
|
||||||
{{ questionDetails.data.question }}
|
class="text-gray-900 font-semibold mt-2"
|
||||||
</div>
|
v-html="questionDetails.data.question"
|
||||||
|
></div>
|
||||||
<div v-if="questionDetails.data.type == 'Choices'" v-for="index in 4">
|
<div v-if="questionDetails.data.type == 'Choices'" v-for="index in 4">
|
||||||
<label
|
<label
|
||||||
v-if="questionDetails.data[`option_${index}`]"
|
v-if="questionDetails.data[`option_${index}`]"
|
||||||
@@ -123,23 +125,46 @@
|
|||||||
<MinusCircle v-else class="w-4 h-4" />
|
<MinusCircle v-else class="w-4 h-4" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<span class="ml-2">
|
<span
|
||||||
{{ questionDetails.data[`option_${index}`] }}
|
class="ml-2"
|
||||||
|
v-html="questionDetails.data[`option_${index}`]"
|
||||||
|
>
|
||||||
</span>
|
</span>
|
||||||
</label>
|
</label>
|
||||||
<div
|
<div
|
||||||
v-if="questionDetails.data[`explanation_${index}`]"
|
v-if="questionDetails.data[`explanation_${index}`]"
|
||||||
class="mt-2 text-sm hidden"
|
class="mt-2 text-xs"
|
||||||
|
v-show="showAnswers.length"
|
||||||
>
|
>
|
||||||
{{ questionDetails.data[`explanation_${index}`] }}
|
{{ questionDetails.data[`explanation_${index}`] }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center justify-between mt-8">
|
<div v-else>
|
||||||
|
<FormControl
|
||||||
|
v-model="possibleAnswer"
|
||||||
|
type="textarea"
|
||||||
|
:disabled="showAnswers.length ? true : false"
|
||||||
|
class="my-2"
|
||||||
|
/>
|
||||||
|
<div v-if="showAnswers.length">
|
||||||
|
<Badge v-if="showAnswers[0]" :label="__('Correct')" theme="green">
|
||||||
|
<template #prefix>
|
||||||
|
<CheckCircle class="w-4 h-4 text-green-500 mr-1" />
|
||||||
|
</template>
|
||||||
|
</Badge>
|
||||||
|
<Badge v-else theme="red" :label="__('Incorrect')">
|
||||||
|
<template #prefix>
|
||||||
|
<XCircle class="w-4 h-4 text-red-500 mr-1" />
|
||||||
|
</template>
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center justify-between mt-5">
|
||||||
<div>
|
<div>
|
||||||
{{
|
{{
|
||||||
__('Question {0} of {1}').format(
|
__('Question {0} of {1}').format(
|
||||||
activeQuestion,
|
activeQuestion,
|
||||||
quiz.data.questions.length
|
questions.length
|
||||||
)
|
)
|
||||||
}}
|
}}
|
||||||
</div>
|
</div>
|
||||||
@@ -152,7 +177,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>
|
||||||
@@ -211,22 +236,20 @@
|
|||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<script setup>
|
<script setup>
|
||||||
import {
|
import { Badge, Button, createResource, ListView } from 'frappe-ui'
|
||||||
createDocumentResource,
|
|
||||||
Button,
|
|
||||||
createResource,
|
|
||||||
ListView,
|
|
||||||
} from 'frappe-ui'
|
|
||||||
import { ref, watch, reactive, inject } from 'vue'
|
import { ref, watch, reactive, inject } from 'vue'
|
||||||
import { createToast } from '@/utils/'
|
import { createToast } from '@/utils/'
|
||||||
import { CheckCircle, XCircle, MinusCircle } from 'lucide-vue-next'
|
import { CheckCircle, XCircle, MinusCircle } from 'lucide-vue-next'
|
||||||
import { timeAgo } from '@/utils'
|
import { timeAgo } from '@/utils'
|
||||||
|
import FormControl from 'frappe-ui/src/components/FormControl.vue'
|
||||||
const user = inject('$user')
|
const user = inject('$user')
|
||||||
|
|
||||||
const activeQuestion = ref(0)
|
const activeQuestion = ref(0)
|
||||||
const currentQuestion = ref('')
|
const currentQuestion = ref('')
|
||||||
const selectedOptions = reactive([0, 0, 0, 0])
|
const selectedOptions = reactive([0, 0, 0, 0])
|
||||||
const showAnswers = reactive([])
|
const showAnswers = reactive([])
|
||||||
|
let questions = reactive([])
|
||||||
|
const possibleAnswer = ref(null)
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
quizName: {
|
quizName: {
|
||||||
@@ -246,11 +269,30 @@ const quiz = createResource({
|
|||||||
cache: ['quiz', props.quizName],
|
cache: ['quiz', props.quizName],
|
||||||
auto: true,
|
auto: true,
|
||||||
onSuccess(data) {
|
onSuccess(data) {
|
||||||
attempts.reload()
|
populateQuestions()
|
||||||
resetQuiz()
|
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
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 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) {
|
||||||
@@ -279,6 +321,16 @@ const attempts = createResource({
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => quiz.data,
|
||||||
|
() => {
|
||||||
|
if (quiz.data && quiz.data.max_attempts) {
|
||||||
|
attempts.reload()
|
||||||
|
resetQuiz()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
const quizSubmission = createResource({
|
const quizSubmission = createResource({
|
||||||
url: 'lms.lms.doctype.lms_quiz.lms_quiz.quiz_summary',
|
url: 'lms.lms.doctype.lms_quiz.lms_quiz.quiz_summary',
|
||||||
makeParams(values) {
|
makeParams(values) {
|
||||||
@@ -308,7 +360,6 @@ watch(activeQuestion, (value) => {
|
|||||||
watch(
|
watch(
|
||||||
() => props.quizName,
|
() => props.quizName,
|
||||||
(newName) => {
|
(newName) => {
|
||||||
console.log(newName)
|
|
||||||
if (newName) {
|
if (newName) {
|
||||||
quiz.reload()
|
quiz.reload()
|
||||||
}
|
}
|
||||||
@@ -328,10 +379,17 @@ const markAnswer = (index) => {
|
|||||||
|
|
||||||
const getAnswers = () => {
|
const getAnswers = () => {
|
||||||
let answers = []
|
let answers = []
|
||||||
selectedOptions.forEach((value, index) => {
|
const type = questionDetails.data.type
|
||||||
if (selectedOptions[index])
|
|
||||||
answers.push(questionDetails.data[`option_${index + 1}`])
|
if (type == 'Choices') {
|
||||||
})
|
selectedOptions.forEach((value, index) => {
|
||||||
|
if (selectedOptions[index])
|
||||||
|
answers.push(questionDetails.data[`option_${index + 1}`])
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
answers.push(possibleAnswer.value)
|
||||||
|
}
|
||||||
|
|
||||||
return answers
|
return answers
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -341,7 +399,8 @@ const checkAnswer = () => {
|
|||||||
createToast({
|
createToast({
|
||||||
title: 'Please select an option',
|
title: 'Please select an option',
|
||||||
icon: 'alert-circle',
|
icon: 'alert-circle',
|
||||||
iconClasses: 'text-yellow-600 bg-yellow-100',
|
iconClasses: 'text-yellow-600 bg-yellow-100 rounded-full',
|
||||||
|
position: 'top-center',
|
||||||
})
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -355,15 +414,20 @@ const checkAnswer = () => {
|
|||||||
},
|
},
|
||||||
auto: true,
|
auto: true,
|
||||||
onSuccess(data) {
|
onSuccess(data) {
|
||||||
selectedOptions.forEach((option, index) => {
|
let type = questionDetails.data.type
|
||||||
if (option) {
|
if (type == 'Choices') {
|
||||||
showAnswers[index] = option && data[index]
|
selectedOptions.forEach((option, index) => {
|
||||||
} else if (questionDetails.data[`is_correct_${index + 1}`]) {
|
if (option) {
|
||||||
showAnswers[index] = 0
|
showAnswers[index] = option && data[index]
|
||||||
} else {
|
} else if (questionDetails.data[`is_correct_${index + 1}`]) {
|
||||||
showAnswers[index] = undefined
|
showAnswers[index] = 0
|
||||||
}
|
} else {
|
||||||
})
|
showAnswers[index] = undefined
|
||||||
|
}
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
showAnswers.push(data)
|
||||||
|
}
|
||||||
addToLocalStorage()
|
addToLocalStorage()
|
||||||
if (!quiz.data.show_answers) {
|
if (!quiz.data.show_answers) {
|
||||||
resetQuestion()
|
resetQuestion()
|
||||||
@@ -376,7 +440,7 @@ const addToLocalStorage = () => {
|
|||||||
let quizData = JSON.parse(localStorage.getItem(quiz.data.title))
|
let quizData = JSON.parse(localStorage.getItem(quiz.data.title))
|
||||||
let questionData = {
|
let questionData = {
|
||||||
question_index: activeQuestion.value,
|
question_index: activeQuestion.value,
|
||||||
answers: getAnswers().join(),
|
answer: getAnswers().join(),
|
||||||
is_correct: showAnswers.filter((answer) => {
|
is_correct: showAnswers.filter((answer) => {
|
||||||
return answer != undefined
|
return answer != undefined
|
||||||
}),
|
}),
|
||||||
@@ -398,6 +462,7 @@ const resetQuestion = () => {
|
|||||||
activeQuestion.value = activeQuestion.value + 1
|
activeQuestion.value = activeQuestion.value + 1
|
||||||
selectedOptions.splice(0, selectedOptions.length, ...[0, 0, 0, 0])
|
selectedOptions.splice(0, selectedOptions.length, ...[0, 0, 0, 0])
|
||||||
showAnswers.length = 0
|
showAnswers.length = 0
|
||||||
|
possibleAnswer.value = null
|
||||||
}
|
}
|
||||||
|
|
||||||
const submitQuiz = () => {
|
const submitQuiz = () => {
|
||||||
@@ -413,7 +478,7 @@ const submitQuiz = () => {
|
|||||||
|
|
||||||
const createSubmission = () => {
|
const createSubmission = () => {
|
||||||
quizSubmission.reload().then(() => {
|
quizSubmission.reload().then(() => {
|
||||||
attempts.reload()
|
if (quiz.data && quiz.data.max_attempts) attempts.reload()
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -422,6 +487,7 @@ 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()
|
||||||
}
|
}
|
||||||
|
|
||||||
const getSubmissionColumns = () => {
|
const getSubmissionColumns = () => {
|
||||||
|
|||||||
@@ -23,4 +23,8 @@ const props = defineProps({
|
|||||||
required: true,
|
required: true,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const redirectToLogin = () => {
|
||||||
|
window.location.href = `/login`
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -6,21 +6,21 @@
|
|||||||
@click="handleClick"
|
@click="handleClick"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
class="flex items-center duration-300 ease-in-out"
|
class="flex items-center w-full duration-300 ease-in-out group"
|
||||||
:class="isCollapsed ? 'p-1' : 'px-2 py-1'"
|
:class="isCollapsed ? 'p-1' : 'px-2 py-1'"
|
||||||
>
|
>
|
||||||
<Tooltip :text="link.label" placement="right">
|
<Tooltip :text="link.label" placement="right">
|
||||||
<slot name="icon">
|
<slot name="icon">
|
||||||
<span class="grid h-5 w-6 flex-shrink-0 place-items-center">
|
<span class="grid h-5 w-6 flex-shrink-0 place-items-center">
|
||||||
<component
|
<component
|
||||||
:is="link.icon"
|
:is="icons[link.icon]"
|
||||||
class="h-5 w-5 stroke-1.5 text-gray-800"
|
class="h-4 w-4 stroke-1.5 text-gray-800"
|
||||||
/>
|
/>
|
||||||
</span>
|
</span>
|
||||||
</slot>
|
</slot>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
<span
|
<span
|
||||||
class="flex-shrink-0 text-base duration-300 ease-in-out"
|
class="flex-shrink-0 text-sm duration-300 ease-in-out"
|
||||||
:class="
|
:class="
|
||||||
isCollapsed
|
isCollapsed
|
||||||
? 'ml-0 w-0 overflow-hidden opacity-0'
|
? 'ml-0 w-0 overflow-hidden opacity-0'
|
||||||
@@ -29,16 +29,35 @@
|
|||||||
>
|
>
|
||||||
{{ link.label }}
|
{{ link.label }}
|
||||||
</span>
|
</span>
|
||||||
|
<span v-if="link.count" class="!ml-auto block text-xs text-gray-600">
|
||||||
|
{{ link.count }}
|
||||||
|
</span>
|
||||||
|
<div
|
||||||
|
v-if="showControls && !isCollapsed"
|
||||||
|
class="flex items-center space-x-2 !ml-auto block text-xs text-gray-600 group-hover:visible invisible"
|
||||||
|
>
|
||||||
|
<component
|
||||||
|
:is="icons['Edit']"
|
||||||
|
class="h-3 w-3 stroke-1.5 text-gray-700"
|
||||||
|
@click.stop="openModal(link)"
|
||||||
|
/>
|
||||||
|
<component
|
||||||
|
:is="icons['X']"
|
||||||
|
class="h-3 w-3 stroke-1.5 text-gray-700"
|
||||||
|
@click.stop="deletePage(link)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { Tooltip } from 'frappe-ui'
|
import { Tooltip, Button } from 'frappe-ui'
|
||||||
import { computed } from 'vue'
|
import { computed } from 'vue'
|
||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
|
import * as icons from 'lucide-vue-next'
|
||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
const emit = defineEmits(['openModal', 'deletePage'])
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
link: {
|
link: {
|
||||||
@@ -49,13 +68,29 @@ const props = defineProps({
|
|||||||
type: Boolean,
|
type: Boolean,
|
||||||
default: false,
|
default: false,
|
||||||
},
|
},
|
||||||
|
showControls: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
function handleClick() {
|
function handleClick() {
|
||||||
router.push({ name: props.link.to })
|
if (router.hasRoute(props.link.to)) {
|
||||||
|
router.push({ name: props.link.to })
|
||||||
|
} else if (props.link.to) {
|
||||||
|
window.location.href = `/${props.link.to}`
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let isActive = computed(() => {
|
const isActive = computed(() => {
|
||||||
return props.link?.activeFor?.includes(router.currentRoute.value.name)
|
return props.link?.activeFor?.includes(router.currentRoute.value.name)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const openModal = (link) => {
|
||||||
|
emit('openModal', link)
|
||||||
|
}
|
||||||
|
|
||||||
|
const deletePage = (link) => {
|
||||||
|
emit('deletePage', link)
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -34,9 +34,7 @@ const props = defineProps({
|
|||||||
default: 'Tags',
|
default: 'Tags',
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
console.log(props.modelValue)
|
|
||||||
let tags = ref(props.modelValue)
|
let tags = ref(props.modelValue)
|
||||||
console.log(tags.value)
|
|
||||||
const emit = defineEmits(['update:modelValue'])
|
const emit = defineEmits(['update:modelValue'])
|
||||||
let newTag = ref('')
|
let newTag = ref('')
|
||||||
|
|
||||||
|
|||||||
90
frontend/src/components/UnsplashImageBrowser.vue
Normal file
90
frontend/src/components/UnsplashImageBrowser.vue
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
<template>
|
||||||
|
<Popover transition="default">
|
||||||
|
<template #target="{ isOpen, togglePopover }" class="flex w-full">
|
||||||
|
<slot v-bind="{ isOpen, togglePopover }"></slot>
|
||||||
|
</template>
|
||||||
|
<template #body>
|
||||||
|
<div
|
||||||
|
class="absolute left-1/2 mt-3 max-w-sm -translate-x-1/2 transform rounded-lg bg-white px-4 sm:px-0 lg:max-w-3xl"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="overflow-hidden rounded-lg p-3 shadow-2xl ring-1 ring-black ring-opacity-5"
|
||||||
|
>
|
||||||
|
<div class="flex items-center space-x-2">
|
||||||
|
<div class="flex-1">
|
||||||
|
<TextInput
|
||||||
|
type="text"
|
||||||
|
placeholder="search by keyword"
|
||||||
|
v-model="search"
|
||||||
|
:debounce="300"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<FileUploader @success="(file) => $emit('select', file.file_url)">
|
||||||
|
<template
|
||||||
|
v-slot="{ file, progress, uploading, openFileSelector }"
|
||||||
|
>
|
||||||
|
<div class="w-full text-center">
|
||||||
|
<Button @click="openFileSelector" :loading="uploading">
|
||||||
|
{{ uploading ? `Uploading ${progress}%` : 'Upload Image' }}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</FileUploader>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
class="relative mt-2 grid w-[25.5rem] gap-2 bg-white lg:grid-cols-2"
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
v-for="image in $resources.images.data"
|
||||||
|
:key="image.id"
|
||||||
|
class="h-[50px] w-[200px] overflow-hidden rounded hover:opacity-80"
|
||||||
|
@click="$emit('select', image.urls.raw)"
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
:src="
|
||||||
|
image.urls.raw +
|
||||||
|
'&w=200&h=50&fit=crop&crop=entropy,faces,focalpoint'
|
||||||
|
"
|
||||||
|
/>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<div class="mt-2 text-center text-sm text-gray-500">
|
||||||
|
{{ __('Image search powered by') }}
|
||||||
|
<a class="underline" target="_blank" href="https://unsplash.com">
|
||||||
|
{{ __('Unsplash') }}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</Popover>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
// import { Popover, PopoverButton, PopoverPanel } from '@headlessui/vue'
|
||||||
|
import { Popover, FileUploader, Button } from 'frappe-ui'
|
||||||
|
|
||||||
|
export default {
|
||||||
|
name: 'UnsplashImageBrowser',
|
||||||
|
components: {
|
||||||
|
Popover,
|
||||||
|
FileUploader,
|
||||||
|
},
|
||||||
|
emits: ['select'],
|
||||||
|
resources: {
|
||||||
|
images() {
|
||||||
|
return {
|
||||||
|
url: 'gameplan.api.get_unsplash_photos',
|
||||||
|
params: { keyword: this.search },
|
||||||
|
auto: true,
|
||||||
|
debounce: 500,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
search: '',
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
</script>
|
||||||
@@ -1,15 +1,17 @@
|
|||||||
<template>
|
<template>
|
||||||
<Avatar
|
<Tooltip :text="user.full_name">
|
||||||
class="avatar border border-gray-300"
|
<Avatar
|
||||||
v-if="user"
|
class="avatar border border-gray-300 cursor-auto"
|
||||||
:label="user.full_name"
|
v-if="user"
|
||||||
:image="user.user_image"
|
:label="user.full_name"
|
||||||
:size="size"
|
:image="user.user_image"
|
||||||
v-bind="$attrs"
|
:size="size"
|
||||||
/>
|
v-bind="$attrs"
|
||||||
|
/>
|
||||||
|
</Tooltip>
|
||||||
</template>
|
</template>
|
||||||
<script setup>
|
<script setup>
|
||||||
import { Avatar } from 'frappe-ui'
|
import { Avatar, Tooltip } from 'frappe-ui'
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
user: {
|
user: {
|
||||||
type: Object,
|
type: Object,
|
||||||
|
|||||||
@@ -26,13 +26,21 @@
|
|||||||
"
|
"
|
||||||
>
|
>
|
||||||
<div class="text-base font-medium text-gray-900 leading-none">
|
<div class="text-base font-medium text-gray-900 leading-none">
|
||||||
<span v-if="branding.data?.brand_name">
|
<span
|
||||||
|
v-if="
|
||||||
|
branding.data?.brand_name &&
|
||||||
|
branding.data?.brand_name != 'Frappe'
|
||||||
|
"
|
||||||
|
>
|
||||||
{{ branding.data?.brand_name }}
|
{{ branding.data?.brand_name }}
|
||||||
</span>
|
</span>
|
||||||
<span v-else> Learning </span>
|
<span v-else> Learning </span>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="user" class="mt-1 text-sm text-gray-700 leading-none">
|
<div
|
||||||
{{ convertToTitleCase(user.split('@')[0]) }}
|
v-if="userResource"
|
||||||
|
class="mt-1 text-sm text-gray-700 leading-none"
|
||||||
|
>
|
||||||
|
{{ convertToTitleCase(userResource.data?.full_name) }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
@@ -53,13 +61,23 @@
|
|||||||
<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, createResource } from 'frappe-ui'
|
import { Dropdown } from 'frappe-ui'
|
||||||
import { ChevronDown, LogIn, LogOut, User } from 'lucide-vue-next'
|
import {
|
||||||
|
ChevronDown,
|
||||||
|
LogIn,
|
||||||
|
LogOut,
|
||||||
|
User,
|
||||||
|
ArrowRightLeft,
|
||||||
|
} from 'lucide-vue-next'
|
||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
import { convertToTitleCase } from '../utils'
|
import { convertToTitleCase } from '../utils'
|
||||||
import { onMounted } from 'vue'
|
import { usersStore } from '@/stores/user'
|
||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
const { logout, branding } = sessionStore()
|
||||||
|
let { userResource } = usersStore()
|
||||||
|
let { isLoggedIn } = sessionStore()
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
isCollapsed: {
|
isCollapsed: {
|
||||||
type: Boolean,
|
type: Boolean,
|
||||||
@@ -67,28 +85,30 @@ const props = defineProps({
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
const branding = createResource({
|
|
||||||
url: 'lms.lms.api.get_branding',
|
|
||||||
cache: true,
|
|
||||||
auto: true,
|
|
||||||
onSuccess(data) {
|
|
||||||
document.querySelector("link[rel='icon']").href = data.favicon
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
const { logout, user } = sessionStore()
|
|
||||||
let { isLoggedIn } = sessionStore()
|
|
||||||
const userDropdownOptions = [
|
const userDropdownOptions = [
|
||||||
/* {
|
{
|
||||||
icon: User,
|
icon: User,
|
||||||
label: 'My Profile',
|
label: 'My Profile',
|
||||||
onClick: () => {
|
onClick: () => {
|
||||||
router.push(`/user/${user.data?.username}`)
|
router.push(`/user/${userResource.data?.username}`)
|
||||||
},
|
},
|
||||||
condition: () => {
|
condition: () => {
|
||||||
return isLoggedIn
|
return isLoggedIn
|
||||||
},
|
},
|
||||||
}, */
|
},
|
||||||
|
{
|
||||||
|
icon: ArrowRightLeft,
|
||||||
|
label: 'Switch to Desk',
|
||||||
|
onClick: () => {
|
||||||
|
window.location.href = '/app'
|
||||||
|
},
|
||||||
|
condition: () => {
|
||||||
|
let cookies = new URLSearchParams(document.cookie.split('; ').join('&'))
|
||||||
|
let system_user = cookies.get('system_user')
|
||||||
|
if (system_user === 'yes') return true
|
||||||
|
else return false
|
||||||
|
},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
icon: LogOut,
|
icon: LogOut,
|
||||||
label: 'Log out',
|
label: 'Log out',
|
||||||
|
|||||||
182
frontend/src/components/VideoBlock.vue
Normal file
182
frontend/src/components/VideoBlock.vue
Normal file
@@ -0,0 +1,182 @@
|
|||||||
|
<template>
|
||||||
|
<div ref="videoContainer" class="video-block group relative">
|
||||||
|
<video
|
||||||
|
@timeupdate="updateTime"
|
||||||
|
@ended="videoEnded"
|
||||||
|
class="rounded-lg border border-gray-100"
|
||||||
|
>
|
||||||
|
<source :src="fileURL" :type="type" />
|
||||||
|
</video>
|
||||||
|
<div
|
||||||
|
class="flex items-center space-x-2 bg-gray-200 rounded-md p-0.5 absolute bottom-3 w-[98%] left-0 right-0 mx-auto"
|
||||||
|
>
|
||||||
|
<Button variant="ghost">
|
||||||
|
<template #icon>
|
||||||
|
<Play
|
||||||
|
v-if="!playing"
|
||||||
|
@click="playVideo"
|
||||||
|
class="w-4 h-4 text-gray-900"
|
||||||
|
/>
|
||||||
|
<Pause v-else @click="pauseVideo" class="w-4 h-4 text-gray-900" />
|
||||||
|
</template>
|
||||||
|
</Button>
|
||||||
|
<Button variant="ghost" @click="toggleMute">
|
||||||
|
<template #icon>
|
||||||
|
<Volume2 v-if="!muted" class="w-4 h-4 text-gray-900" />
|
||||||
|
<VolumeX v-else class="w-4 h-4 text-gray-900" />
|
||||||
|
</template>
|
||||||
|
</Button>
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
min="0"
|
||||||
|
:max="duration"
|
||||||
|
step="0.1"
|
||||||
|
v-model="currentTime"
|
||||||
|
@input="changeCurrentTime"
|
||||||
|
class="duration-slider w-full h-1"
|
||||||
|
/>
|
||||||
|
<span class="text-xs font-medium">
|
||||||
|
{{ formatTime(currentTime) }} / {{ formatTime(duration) }}
|
||||||
|
</span>
|
||||||
|
<Button variant="ghost" @click="toggleFullscreen">
|
||||||
|
<template #icon>
|
||||||
|
<Maximize class="w-4 h-4 text-gray-900" />
|
||||||
|
</template>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<script setup>
|
||||||
|
import { ref, onMounted, computed } from 'vue'
|
||||||
|
import { Play, Pause, Maximize, Volume2, VolumeX } from 'lucide-vue-next'
|
||||||
|
import { Button } from 'frappe-ui'
|
||||||
|
|
||||||
|
const videoRef = ref(null)
|
||||||
|
const videoContainer = ref(null)
|
||||||
|
let playing = ref(false)
|
||||||
|
let currentTime = ref(0)
|
||||||
|
let duration = ref(0)
|
||||||
|
let muted = ref(false)
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
file: {
|
||||||
|
type: String,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
type: {
|
||||||
|
type: String,
|
||||||
|
default: 'video/mp4',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
setTimeout(() => {
|
||||||
|
videoRef.value = document.querySelector('video')
|
||||||
|
videoRef.value.onloadedmetadata = () => {
|
||||||
|
duration.value = videoRef.value.duration
|
||||||
|
}
|
||||||
|
videoRef.value.ontimeupdate = () => {
|
||||||
|
currentTime.value = videoRef.value.currentTime
|
||||||
|
}
|
||||||
|
}, 0)
|
||||||
|
})
|
||||||
|
|
||||||
|
const fileURL = computed(() => {
|
||||||
|
if (isYoutube) {
|
||||||
|
let url = props.file
|
||||||
|
if (url.includes('watch?v=')) {
|
||||||
|
url = url.replace('watch?v=', 'embed/')
|
||||||
|
}
|
||||||
|
return `${url}?autoplay=0&controls=0&disablekb=1&playsinline=1&cc_load_policy=1&cc_lang_pref=auto`
|
||||||
|
}
|
||||||
|
return props.file
|
||||||
|
})
|
||||||
|
|
||||||
|
const isYoutube = computed(() => {
|
||||||
|
return props.type == 'video/youtube'
|
||||||
|
})
|
||||||
|
|
||||||
|
const playVideo = () => {
|
||||||
|
videoRef.value.play()
|
||||||
|
playing.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
const pauseVideo = () => {
|
||||||
|
videoRef.value.pause()
|
||||||
|
playing.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
const videoEnded = () => {
|
||||||
|
playing.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
const toggleMute = () => {
|
||||||
|
videoRef.value.muted = !videoRef.value.muted
|
||||||
|
muted.value = videoRef.value.muted
|
||||||
|
}
|
||||||
|
|
||||||
|
const changeCurrentTime = () => {
|
||||||
|
videoRef.value.currentTime = currentTime.value
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatTime = (time) => {
|
||||||
|
const minutes = Math.floor(time / 60)
|
||||||
|
const seconds = Math.floor(time % 60)
|
||||||
|
return `${minutes}:${seconds < 10 ? '0' : ''}${seconds}`
|
||||||
|
}
|
||||||
|
|
||||||
|
const toggleFullscreen = () => {
|
||||||
|
if (document.fullscreenElement) {
|
||||||
|
document.exitFullscreen()
|
||||||
|
} else {
|
||||||
|
videoContainer.value.requestFullscreen()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.video-block {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 900px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.video-block video {
|
||||||
|
width: 100%;
|
||||||
|
height: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
iframe {
|
||||||
|
width: 100%;
|
||||||
|
min-height: 500px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.duration-slider {
|
||||||
|
flex: 1;
|
||||||
|
-webkit-appearance: none;
|
||||||
|
appearance: none;
|
||||||
|
background-color: theme('colors.gray.400');
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.duration-slider::-webkit-slider-thumb {
|
||||||
|
height: 10px;
|
||||||
|
width: 10px;
|
||||||
|
-webkit-appearance: none;
|
||||||
|
background-color: theme('colors.gray.900');
|
||||||
|
}
|
||||||
|
|
||||||
|
@media screen and (-webkit-min-device-pixel-ratio: 0) {
|
||||||
|
input[type='range'] {
|
||||||
|
overflow: hidden;
|
||||||
|
width: 150px;
|
||||||
|
-webkit-appearance: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
input[type='range']::-webkit-slider-thumb {
|
||||||
|
-webkit-appearance: none;
|
||||||
|
cursor: pointer;
|
||||||
|
box-shadow: -500px 0 0 500px theme('colors.gray.900');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -30,8 +30,9 @@ app.provide('$dayjs', dayjs)
|
|||||||
app.provide('$socket', initSocket())
|
app.provide('$socket', initSocket())
|
||||||
app.mount('#app')
|
app.mount('#app')
|
||||||
|
|
||||||
const { userResource } = usersStore()
|
const { userResource, allUsers } = usersStore()
|
||||||
let { isLoggedIn } = sessionStore()
|
let { isLoggedIn } = sessionStore()
|
||||||
|
|
||||||
app.provide('$user', userResource)
|
app.provide('$user', userResource)
|
||||||
|
app.provide('$allUsers', allUsers)
|
||||||
app.config.globalProperties.$user = userResource
|
app.config.globalProperties.$user = userResource
|
||||||
|
|||||||
90
frontend/src/pages/Badge.vue
Normal file
90
frontend/src/pages/Badge.vue
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
<template>
|
||||||
|
<div v-if="badge.doc">
|
||||||
|
<div class="p-5 flex flex-col items-center mt-40">
|
||||||
|
<div class="text-3xl font-semibold">
|
||||||
|
{{ badge.doc.title }}
|
||||||
|
</div>
|
||||||
|
<img :src="badge.doc.image" :alt="badge.doc.title" class="h-60 mt-2" />
|
||||||
|
<div class="text-lg">
|
||||||
|
{{
|
||||||
|
__('This badge has been awarded to {0} on {1}.').format(
|
||||||
|
userName,
|
||||||
|
dayjs(issuedOn.data?.issued_on).format('DD MMM YYYY')
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
</div>
|
||||||
|
<div class="text-lg mt-2">
|
||||||
|
{{ badge.doc.description }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<script setup>
|
||||||
|
import { createDocumentResource, createResource, Breadcrumbs } from 'frappe-ui'
|
||||||
|
import { computed, inject } from 'vue'
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
|
|
||||||
|
const allUsers = inject('$allUsers')
|
||||||
|
const dayjs = inject('$dayjs')
|
||||||
|
const router = useRouter()
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
badgeName: {
|
||||||
|
type: String,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
email: {
|
||||||
|
type: String,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const badge = createDocumentResource({
|
||||||
|
doctype: 'LMS Badge',
|
||||||
|
name: props.badgeName,
|
||||||
|
})
|
||||||
|
|
||||||
|
const userName = computed(() => {
|
||||||
|
const user = Object.values(allUsers.data).find(
|
||||||
|
(user) => user.name === props.email
|
||||||
|
)
|
||||||
|
return user ? user.full_name : props.email
|
||||||
|
})
|
||||||
|
|
||||||
|
const issuedOn = createResource({
|
||||||
|
url: 'frappe.client.get_value',
|
||||||
|
makeParams(values) {
|
||||||
|
return {
|
||||||
|
doctype: 'LMS Badge Assignment',
|
||||||
|
filters: {
|
||||||
|
member: props.email,
|
||||||
|
badge: props.badgeName,
|
||||||
|
},
|
||||||
|
fieldname: 'issued_on',
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onSuccess(data) {
|
||||||
|
if (!data.issued_on) {
|
||||||
|
router.push({ name: 'Courses' })
|
||||||
|
}
|
||||||
|
},
|
||||||
|
auto: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
const breadcrumbs = computed(() => {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
label: 'Badges',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: badge.doc.title,
|
||||||
|
route: {
|
||||||
|
name: 'Badge',
|
||||||
|
params: {
|
||||||
|
badge: badge.doc.name,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]
|
||||||
|
})
|
||||||
|
</script>
|
||||||
@@ -13,9 +13,9 @@
|
|||||||
</template>
|
</template>
|
||||||
</Button>
|
</Button>
|
||||||
</header>
|
</header>
|
||||||
<div v-if="batch.data" class="grid grid-cols-[70%,30%] h-full">
|
<div v-if="batch.data" class="grid grid-cols-[70%,30%] h-screen">
|
||||||
<div class="border-r-2">
|
<div class="border-r-2">
|
||||||
<Tabs v-model="tabIndex" :tabs="tabs" tablistClass="overflow-x-visible">
|
<Tabs v-model="tabIndex" :tabs="tabs" tablistClass="overflow-y-hidden">
|
||||||
<template #tab="{ tab, selected }" class="overflow-x-hidden">
|
<template #tab="{ tab, selected }" class="overflow-x-hidden">
|
||||||
<div>
|
<div>
|
||||||
<button
|
<button
|
||||||
@@ -43,7 +43,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<template #default="{ tab }">
|
<template #default="{ tab }">
|
||||||
<div class="pt-5 px-10 pb-10">
|
<div class="pt-5 px-5 pb-10">
|
||||||
<div v-if="tab.label == 'Courses'">
|
<div v-if="tab.label == 'Courses'">
|
||||||
<BatchCourses :batch="batch.data.name" />
|
<BatchCourses :batch="batch.data.name" />
|
||||||
</div>
|
</div>
|
||||||
@@ -66,9 +66,10 @@
|
|||||||
<Discussions
|
<Discussions
|
||||||
doctype="LMS Batch"
|
doctype="LMS Batch"
|
||||||
:docname="batch.data.name"
|
:docname="batch.data.name"
|
||||||
title="Discussions"
|
:title="__('Discussions')"
|
||||||
:key="batch.data.name"
|
:key="batch.data.name"
|
||||||
:singleThread="true"
|
:singleThread="true"
|
||||||
|
:scrollToBottom="true"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -79,21 +80,40 @@
|
|||||||
<div class="text-2xl font-semibold mb-2">
|
<div class="text-2xl font-semibold mb-2">
|
||||||
{{ batch.data.title }}
|
{{ batch.data.title }}
|
||||||
</div>
|
</div>
|
||||||
<div v-html="batch.data.description" class="leading-5 mb-4"></div>
|
<div v-html="batch.data.description" class="leading-5 mb-2"></div>
|
||||||
<div class="flex items-center mb-3">
|
|
||||||
<Calendar class="h-4 w-4 stroke-1.5 mr-2 text-gray-700" />
|
<div class="flex avatar-group overlap mb-5">
|
||||||
<span>
|
<div
|
||||||
{{ dayjs(batch.data.start_date).format('DD MMMM YYYY') }} -
|
class="h-6 mr-1"
|
||||||
{{ dayjs(batch.data.end_date).format('DD MMMM YYYY') }}
|
:class="{
|
||||||
</span>
|
'avatar-group overlap': batch.data.instructors.length > 1,
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
<UserAvatar
|
||||||
|
v-for="instructor in batch.data.instructors"
|
||||||
|
:user="instructor"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<CourseInstructors :instructors="batch.data.instructors" />
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center mb-6">
|
<DateRange
|
||||||
|
:startDate="batch.data.start_date"
|
||||||
|
:endDate="batch.data.end_date"
|
||||||
|
class="mb-3"
|
||||||
|
/>
|
||||||
|
<div class="flex items-center mb-4">
|
||||||
<Clock class="h-4 w-4 stroke-1.5 mr-2 text-gray-700" />
|
<Clock class="h-4 w-4 stroke-1.5 mr-2 text-gray-700" />
|
||||||
<span>
|
<span>
|
||||||
{{ formatTime(batch.data.start_time) }} -
|
{{ formatTime(batch.data.start_time) }} -
|
||||||
{{ formatTime(batch.data.end_time) }}
|
{{ formatTime(batch.data.end_time) }}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
<div v-if="batch.data.timezone" class="flex items-center mb-4">
|
||||||
|
<Globe class="h-4 w-4 stroke-1.5 mr-2 text-gray-700" />
|
||||||
|
<span>
|
||||||
|
{{ batch.data.timezone }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<AnnouncementModal
|
<AnnouncementModal
|
||||||
v-model="showAnnouncementModal"
|
v-model="showAnnouncementModal"
|
||||||
@@ -149,8 +169,9 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { Breadcrumbs, Button, createResource, Tabs, Badge } from 'frappe-ui'
|
import { Breadcrumbs, Button, createResource, Tabs, Badge } from 'frappe-ui'
|
||||||
import { computed, inject, ref } from 'vue'
|
import { computed, inject, ref } from 'vue'
|
||||||
|
import CourseInstructors from '@/components/CourseInstructors.vue'
|
||||||
|
import UserAvatar from '@/components/UserAvatar.vue'
|
||||||
import {
|
import {
|
||||||
Calendar,
|
|
||||||
Clock,
|
Clock,
|
||||||
LayoutDashboard,
|
LayoutDashboard,
|
||||||
BookOpen,
|
BookOpen,
|
||||||
@@ -160,8 +181,9 @@ import {
|
|||||||
Mail,
|
Mail,
|
||||||
SendIcon,
|
SendIcon,
|
||||||
MessageCircle,
|
MessageCircle,
|
||||||
|
Globe,
|
||||||
} from 'lucide-vue-next'
|
} from 'lucide-vue-next'
|
||||||
import { formatTime } from '@/utils'
|
import { formatTime, updateDocumentTitle } from '@/utils'
|
||||||
import BatchDashboard from '@/components/BatchDashboard.vue'
|
import BatchDashboard from '@/components/BatchDashboard.vue'
|
||||||
import BatchCourses from '@/components/BatchCourses.vue'
|
import BatchCourses from '@/components/BatchCourses.vue'
|
||||||
import LiveClass from '@/components/LiveClass.vue'
|
import LiveClass from '@/components/LiveClass.vue'
|
||||||
@@ -170,8 +192,8 @@ import Assessments from '@/components/Assessments.vue'
|
|||||||
import Announcements from '@/components/Annoucements.vue'
|
import Announcements from '@/components/Annoucements.vue'
|
||||||
import AnnouncementModal from '@/components/Modals/AnnouncementModal.vue'
|
import AnnouncementModal from '@/components/Modals/AnnouncementModal.vue'
|
||||||
import Discussions from '@/components/Discussions.vue'
|
import Discussions from '@/components/Discussions.vue'
|
||||||
|
import DateRange from '@/components/Common/DateRange.vue'
|
||||||
|
|
||||||
const dayjs = inject('$dayjs')
|
|
||||||
const user = inject('$user')
|
const user = inject('$user')
|
||||||
const showAnnouncementModal = ref(false)
|
const showAnnouncementModal = ref(false)
|
||||||
|
|
||||||
@@ -264,4 +286,13 @@ const redirectToLogin = () => {
|
|||||||
const openAnnouncementModal = () => {
|
const openAnnouncementModal = () => {
|
||||||
showAnnouncementModal.value = true
|
showAnnouncementModal.value = true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const pageMeta = computed(() => {
|
||||||
|
return {
|
||||||
|
title: batch.data?.title,
|
||||||
|
description: batch.data?.description,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
updateDocumentTitle(pageMeta)
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -11,20 +11,23 @@
|
|||||||
<div class="my-3">
|
<div class="my-3">
|
||||||
{{ batch.data.description }}
|
{{ batch.data.description }}
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center justify-between w-1/2">
|
<div
|
||||||
|
class="flex flex-col gap-2 lg:gap-0 lg:flex-row lg:items-center justify-between lg:w-1/2"
|
||||||
|
>
|
||||||
<div class="flex items-center">
|
<div class="flex items-center">
|
||||||
<BookOpen class="h-4 w-4 text-gray-700 mr-2" />
|
<BookOpen class="h-4 w-4 text-gray-700 mr-2" />
|
||||||
<span> {{ batch.data?.courses?.length }} {{ __('Courses') }} </span>
|
<span> {{ batch.data?.courses?.length }} {{ __('Courses') }} </span>
|
||||||
</div>
|
</div>
|
||||||
<span v-if="batch.data.courses">·</span>
|
<span class="hidden lg:block" v-if="batch.data.courses"
|
||||||
<div class="flex items-center">
|
>·</span
|
||||||
<Calendar class="h-4 w-4 text-gray-700 mr-2" />
|
>
|
||||||
<span>
|
<DateRange
|
||||||
{{ dayjs(batch.data.start_date).format('DD MMM YYYY') }} -
|
:startDate="batch.data.start_date"
|
||||||
{{ dayjs(batch.data.end_date).format('DD MMM YYYY') }}
|
:endDate="batch.data.end_date"
|
||||||
</span>
|
/>
|
||||||
</div>
|
<span class="hidden lg:block" v-if="batch.data.start_date"
|
||||||
<span v-if="batch.data.start_date">·</span>
|
>·</span
|
||||||
|
>
|
||||||
<div class="flex items-center">
|
<div class="flex items-center">
|
||||||
<Clock class="h-4 w-4 text-gray-700 mr-2" />
|
<Clock class="h-4 w-4 text-gray-700 mr-2" />
|
||||||
<span>
|
<span>
|
||||||
@@ -33,15 +36,29 @@
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="flex avatar-group overlap mt-3">
|
||||||
|
<div
|
||||||
|
class="h-6 mr-1"
|
||||||
|
:class="{
|
||||||
|
'avatar-group overlap': batch.data.instructors.length > 1,
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
<UserAvatar
|
||||||
|
v-for="instructor in batch.data.instructors"
|
||||||
|
:user="instructor"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<CourseInstructors :instructors="batch.data.instructors" />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="grid grid-cols-[60%,20%] gap-20 mt-10">
|
<div class="grid lg:grid-cols-[60%,20%] gap-4 lg:gap-20 mt-10">
|
||||||
<div class="">
|
<div class="order-2 lg:order-none">
|
||||||
<div
|
<div
|
||||||
class="ProseMirror prose prose-table:table-fixed prose-td:p-2 prose-th:p-2 prose-td:border prose-th:border prose-td:border-gray-300 prose-th:border-gray-300 prose-td:relative prose-th:relative prose-th:bg-gray-100 prose-sm max-w-none !whitespace-normal mt-6"
|
class="ProseMirror prose prose-table:table-fixed prose-td:p-2 prose-th:p-2 prose-td:border prose-th:border prose-td:border-gray-300 prose-th:border-gray-300 prose-td:relative prose-th:relative prose-th:bg-gray-100 prose-sm max-w-none !whitespace-normal mt-6"
|
||||||
v-html="batch.data.batch_details"
|
v-html="batch.data.batch_details"
|
||||||
></div>
|
></div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div class="order-1 lg:order-none">
|
||||||
<BatchOverlay :batch="batch" />
|
<BatchOverlay :batch="batch" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -51,7 +68,7 @@
|
|||||||
{{ __('Courses') }}
|
{{ __('Courses') }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-8 mt-5">
|
<div class="grid grid-cols-1 lg:grid-cols-3 gap-8 mt-5">
|
||||||
<div
|
<div
|
||||||
v-if="batch.data.courses"
|
v-if="batch.data.courses"
|
||||||
v-for="course in courses.data"
|
v-for="course in courses.data"
|
||||||
@@ -80,15 +97,17 @@
|
|||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<script setup>
|
<script setup>
|
||||||
import { Breadcrumbs, createResource, Button } from 'frappe-ui'
|
import { computed, inject } from 'vue'
|
||||||
import { BookOpen, Calendar, Clock } from 'lucide-vue-next'
|
|
||||||
import { formatTime } from '../utils'
|
|
||||||
import { computed, inject, ref } from 'vue'
|
|
||||||
import BatchOverlay from '@/components/BatchOverlay.vue'
|
|
||||||
import CourseCard from '@/components/CourseCard.vue'
|
|
||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
|
import { BookOpen, Clock } from 'lucide-vue-next'
|
||||||
|
import { formatTime, updateDocumentTitle } from '@/utils'
|
||||||
|
import { Breadcrumbs, createResource } from 'frappe-ui'
|
||||||
|
import CourseCard from '@/components/CourseCard.vue'
|
||||||
|
import BatchOverlay from '@/components/BatchOverlay.vue'
|
||||||
|
import DateRange from '../components/Common/DateRange.vue'
|
||||||
|
import CourseInstructors from '@/components/CourseInstructors.vue'
|
||||||
|
import UserAvatar from '@/components/UserAvatar.vue'
|
||||||
|
|
||||||
const dayjs = inject('$dayjs')
|
|
||||||
const user = inject('$user')
|
const user = inject('$user')
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
|
||||||
@@ -106,16 +125,6 @@ const batch = createResource({
|
|||||||
batch: props.batchName,
|
batch: props.batchName,
|
||||||
},
|
},
|
||||||
auto: true,
|
auto: true,
|
||||||
onSuccess(data) {
|
|
||||||
if (data.students?.includes(user.data?.name)) {
|
|
||||||
router.push({
|
|
||||||
name: 'Batch',
|
|
||||||
params: {
|
|
||||||
batchName: props.batchName,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}
|
|
||||||
},
|
|
||||||
})
|
})
|
||||||
|
|
||||||
const courses = createResource({
|
const courses = createResource({
|
||||||
@@ -135,6 +144,15 @@ const breadcrumbs = computed(() => {
|
|||||||
})
|
})
|
||||||
return items
|
return items
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const pageMeta = computed(() => {
|
||||||
|
return {
|
||||||
|
title: batch.data?.title,
|
||||||
|
description: batch.data?.description,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
updateDocumentTitle(pageMeta)
|
||||||
</script>
|
</script>
|
||||||
<style>
|
<style>
|
||||||
.batch-description p {
|
.batch-description p {
|
||||||
|
|||||||
@@ -8,76 +8,86 @@
|
|||||||
{{ __('Save') }}
|
{{ __('Save') }}
|
||||||
</Button>
|
</Button>
|
||||||
</header>
|
</header>
|
||||||
<div class="py-5">
|
<div class="w-1/2 mx-auto py-5">
|
||||||
<div class="container">
|
<div class="">
|
||||||
<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">
|
<div class="grid grid-cols-2 gap-10 mb-4">
|
||||||
<div>
|
<div>
|
||||||
<FormControl
|
<FormControl
|
||||||
v-model="batch.title"
|
v-model="batch.title"
|
||||||
:label="__('Title')"
|
:label="__('Title')"
|
||||||
class="mb-4"
|
class="mb-4"
|
||||||
/>
|
/>
|
||||||
<FormControl
|
|
||||||
v-model="batch.description"
|
|
||||||
:label="__('Description')"
|
|
||||||
type="textarea"
|
|
||||||
class="mb-4"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div class="flex flex-col space-y-2">
|
||||||
<FormControl
|
<FormControl
|
||||||
v-model="batch.published"
|
v-model="batch.published"
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
:label="__('Published')"
|
:label="__('Published')"
|
||||||
/>
|
/>
|
||||||
<FileUploader
|
<FormControl
|
||||||
v-if="!batch.image"
|
v-model="batch.allow_self_enrollment"
|
||||||
class="mt-4"
|
type="checkbox"
|
||||||
:fileTypes="['image/*']"
|
:label="__('Allow self enrollment')"
|
||||||
:validateFile="validateFile"
|
/>
|
||||||
@success="(file) => saveImage(file)"
|
|
||||||
>
|
|
||||||
<template
|
|
||||||
v-slot="{ file, progress, uploading, openFileSelector }"
|
|
||||||
>
|
|
||||||
<div class="mb-4">
|
|
||||||
<Button @click="openFileSelector" :loading="uploading">
|
|
||||||
{{
|
|
||||||
uploading ? `Uploading ${progress}%` : 'Upload an image'
|
|
||||||
}}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
</FileUploader>
|
|
||||||
<div v-else class="mt-4">
|
|
||||||
<div class="text-xs text-gray-600 mb-1">
|
|
||||||
{{ __('Meta Image') }}
|
|
||||||
</div>
|
|
||||||
<div class="flex items-center">
|
|
||||||
<div class="border rounded-md p-2 mr-2">
|
|
||||||
<FileText class="h-5 w-5 stroke-1.5 text-gray-700" />
|
|
||||||
</div>
|
|
||||||
<div class="flex flex-col">
|
|
||||||
<span>
|
|
||||||
{{ batch.image.file_name }}
|
|
||||||
</span>
|
|
||||||
<span class="text-sm text-gray-500 mt-1">
|
|
||||||
{{ getFileSize(batch.image.file_size) }}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<X
|
|
||||||
@click="removeImage()"
|
|
||||||
class="bg-gray-200 rounded-md cursor-pointer stroke-1.5 w-5 h-5 p-1 ml-4"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="container border-b mb-5">
|
<div class="mb-4">
|
||||||
|
<div>
|
||||||
|
<FileUploader
|
||||||
|
v-if="!batch.image"
|
||||||
|
class="mt-4"
|
||||||
|
:fileTypes="['image/*']"
|
||||||
|
:validateFile="validateFile"
|
||||||
|
@success="(file) => saveImage(file)"
|
||||||
|
>
|
||||||
|
<template v-slot="{ file, progress, uploading, openFileSelector }">
|
||||||
|
<div class="mb-4">
|
||||||
|
<Button @click="openFileSelector" :loading="uploading">
|
||||||
|
{{ uploading ? `Uploading ${progress}%` : 'Upload an image' }}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</FileUploader>
|
||||||
|
<div v-else class="mb-4">
|
||||||
|
<div class="text-xs text-gray-600 mb-1">
|
||||||
|
{{ __('Meta Image') }}
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center">
|
||||||
|
<div class="border rounded-md p-2 mr-2">
|
||||||
|
<FileText class="h-5 w-5 stroke-1.5 text-gray-700" />
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-col">
|
||||||
|
<span>
|
||||||
|
{{ batch.image.file_name }}
|
||||||
|
</span>
|
||||||
|
<span class="text-sm text-gray-500 mt-1">
|
||||||
|
{{ getFileSize(batch.image.file_size) }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<X
|
||||||
|
@click="removeImage()"
|
||||||
|
class="bg-gray-200 rounded-md cursor-pointer stroke-1.5 w-5 h-5 p-1 ml-4"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<MultiSelect
|
||||||
|
v-model="instructors"
|
||||||
|
doctype="User"
|
||||||
|
:label="__('Instructors')"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="mb-4">
|
||||||
|
<FormControl
|
||||||
|
v-model="batch.description"
|
||||||
|
:label="__('Description')"
|
||||||
|
type="textarea"
|
||||||
|
class="my-4"
|
||||||
|
/>
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-sm text-gray-600 mb-1">
|
<label class="block text-sm text-gray-600 mb-1">
|
||||||
{{ __('Batch Details') }}
|
{{ __('Batch Details') }}
|
||||||
@@ -91,9 +101,9 @@
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="container border-b mb-5">
|
<div class="mb-4">
|
||||||
<div class="text-lg font-semibold mb-4">
|
<div class="text-lg font-semibold mb-4">
|
||||||
{{ __('Settings') }}
|
{{ __('Date and Time') }}
|
||||||
</div>
|
</div>
|
||||||
<div class="grid grid-cols-2 gap-10">
|
<div class="grid grid-cols-2 gap-10">
|
||||||
<div>
|
<div>
|
||||||
@@ -109,6 +119,8 @@
|
|||||||
type="date"
|
type="date"
|
||||||
class="mb-4"
|
class="mb-4"
|
||||||
/>
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
<FormControl
|
<FormControl
|
||||||
v-model="batch.start_time"
|
v-model="batch.start_time"
|
||||||
:label="__('Start Time')"
|
:label="__('Start Time')"
|
||||||
@@ -121,7 +133,20 @@
|
|||||||
type="time"
|
type="time"
|
||||||
class="mb-4"
|
class="mb-4"
|
||||||
/>
|
/>
|
||||||
|
<FormControl
|
||||||
|
v-model="batch.timezone"
|
||||||
|
:label="__('Timezone')"
|
||||||
|
type="text"
|
||||||
|
class="mb-4"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="mb-4">
|
||||||
|
<div class="text-lg font-semibold mb-4">
|
||||||
|
{{ __('Settings') }}
|
||||||
|
</div>
|
||||||
|
<div class="grid grid-cols-2 gap-10">
|
||||||
<div>
|
<div>
|
||||||
<FormControl
|
<FormControl
|
||||||
v-model="batch.seat_count"
|
v-model="batch.seat_count"
|
||||||
@@ -135,6 +160,8 @@
|
|||||||
type="date"
|
type="date"
|
||||||
class="mb-4"
|
class="mb-4"
|
||||||
/>
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
<FormControl
|
<FormControl
|
||||||
v-model="batch.medium"
|
v-model="batch.medium"
|
||||||
type="select"
|
type="select"
|
||||||
@@ -160,7 +187,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="container">
|
<div class="">
|
||||||
<div class="text-lg font-semibold mb-4">
|
<div class="text-lg font-semibold mb-4">
|
||||||
{{ __('Payment') }}
|
{{ __('Payment') }}
|
||||||
</div>
|
</div>
|
||||||
@@ -188,7 +215,14 @@
|
|||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<script setup>
|
<script setup>
|
||||||
import { computed, onMounted, inject, reactive } from 'vue'
|
import {
|
||||||
|
computed,
|
||||||
|
onMounted,
|
||||||
|
inject,
|
||||||
|
reactive,
|
||||||
|
onBeforeUnmount,
|
||||||
|
ref,
|
||||||
|
} from 'vue'
|
||||||
import {
|
import {
|
||||||
Breadcrumbs,
|
Breadcrumbs,
|
||||||
FormControl,
|
FormControl,
|
||||||
@@ -198,9 +232,11 @@ import {
|
|||||||
createResource,
|
createResource,
|
||||||
} from 'frappe-ui'
|
} from 'frappe-ui'
|
||||||
import Link from '@/components/Controls/Link.vue'
|
import Link from '@/components/Controls/Link.vue'
|
||||||
|
import MultiSelect from '@/components/Controls/MultiSelect.vue'
|
||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
import { getFileSize, showToast } from '../utils'
|
import { getFileSize, showToast } from '../utils'
|
||||||
import { X, FileText } from 'lucide-vue-next'
|
import { X, FileText } from 'lucide-vue-next'
|
||||||
|
import { capture } from '@/telemetry'
|
||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const user = inject('$user')
|
const user = inject('$user')
|
||||||
@@ -221,21 +257,43 @@ const batch = reactive({
|
|||||||
end_date: '',
|
end_date: '',
|
||||||
start_time: '',
|
start_time: '',
|
||||||
end_time: '',
|
end_time: '',
|
||||||
|
timezone: '',
|
||||||
evaluation_end_date: '',
|
evaluation_end_date: '',
|
||||||
seat_count: '',
|
seat_count: '',
|
||||||
medium: '',
|
medium: '',
|
||||||
category: '',
|
category: '',
|
||||||
|
allow_self_enrollment: false,
|
||||||
image: null,
|
image: null,
|
||||||
paid_batch: false,
|
paid_batch: false,
|
||||||
currency: '',
|
currency: '',
|
||||||
amount: 0,
|
amount: 0,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const instructors = ref([])
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
if (!user.data) window.location.href = '/login'
|
if (!user.data) window.location.href = '/login'
|
||||||
if (props.batchName != 'new') {
|
if (props.batchName != 'new') {
|
||||||
batchDetail.reload()
|
batchDetail.reload()
|
||||||
|
} else {
|
||||||
|
capture('batch_form_opened')
|
||||||
}
|
}
|
||||||
|
window.addEventListener('keydown', keyboardShortcut)
|
||||||
|
})
|
||||||
|
|
||||||
|
const keyboardShortcut = (e) => {
|
||||||
|
if (
|
||||||
|
e.key === 's' &&
|
||||||
|
(e.ctrlKey || e.metaKey) &&
|
||||||
|
!e.target.classList.contains('ProseMirror')
|
||||||
|
) {
|
||||||
|
saveBatch()
|
||||||
|
e.preventDefault()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
window.removeEventListener('keydown', keyboardShortcut)
|
||||||
})
|
})
|
||||||
|
|
||||||
const newBatch = createResource({
|
const newBatch = createResource({
|
||||||
@@ -244,7 +302,10 @@ const newBatch = createResource({
|
|||||||
return {
|
return {
|
||||||
doc: {
|
doc: {
|
||||||
doctype: 'LMS Batch',
|
doctype: 'LMS Batch',
|
||||||
meta_image: batch.image.file_url,
|
meta_image: batch.image?.file_url,
|
||||||
|
instructors: instructors.value.map((instructor) => ({
|
||||||
|
instructor: instructor,
|
||||||
|
})),
|
||||||
...batch,
|
...batch,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
@@ -261,9 +322,13 @@ const batchDetail = createResource({
|
|||||||
},
|
},
|
||||||
onSuccess(data) {
|
onSuccess(data) {
|
||||||
Object.keys(data).forEach((key) => {
|
Object.keys(data).forEach((key) => {
|
||||||
if (Object.hasOwn(batch, key)) batch[key] = data[key]
|
if (key == 'instructors') {
|
||||||
|
data.instructors.forEach((instructor) => {
|
||||||
|
instructors.value.push(instructor.instructor)
|
||||||
|
})
|
||||||
|
} else if (Object.hasOwn(batch, key)) batch[key] = data[key]
|
||||||
})
|
})
|
||||||
let checkboxes = ['published', 'paid_batch']
|
let checkboxes = ['published', 'paid_batch', 'allow_self_enrollment']
|
||||||
for (let idx in checkboxes) {
|
for (let idx in checkboxes) {
|
||||||
let key = checkboxes[idx]
|
let key = checkboxes[idx]
|
||||||
batch[key] = batch[key] ? true : false
|
batch[key] = batch[key] ? true : false
|
||||||
@@ -279,7 +344,10 @@ const editBatch = createResource({
|
|||||||
doctype: 'LMS Batch',
|
doctype: 'LMS Batch',
|
||||||
name: props.batchName,
|
name: props.batchName,
|
||||||
fieldname: {
|
fieldname: {
|
||||||
meta_image: batch.image.file_url,
|
meta_image: batch.image?.file_url,
|
||||||
|
instructors: instructors.value.map((instructor) => ({
|
||||||
|
instructor: instructor,
|
||||||
|
})),
|
||||||
...batch,
|
...batch,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
@@ -312,6 +380,7 @@ const createNewBatch = () => {
|
|||||||
{},
|
{},
|
||||||
{
|
{
|
||||||
onSuccess(data) {
|
onSuccess(data) {
|
||||||
|
capture('batch_created')
|
||||||
router.push({
|
router.push({
|
||||||
name: 'BatchDetail',
|
name: 'BatchDetail',
|
||||||
params: {
|
params: {
|
||||||
@@ -382,7 +451,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,19 +5,27 @@
|
|||||||
>
|
>
|
||||||
<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-40">
|
||||||
|
<Select
|
||||||
|
v-if="categories.data?.length"
|
||||||
|
v-model="currentCategory"
|
||||||
|
:options="categories.data"
|
||||||
|
:placeholder="__('Filter')"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
<router-link
|
<router-link
|
||||||
v-if="user.data"
|
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 Batch') }}
|
||||||
</Button>
|
</Button>
|
||||||
@@ -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 }">
|
||||||
@@ -62,7 +70,7 @@
|
|||||||
<template #default="{ tab }">
|
<template #default="{ tab }">
|
||||||
<div
|
<div
|
||||||
v-if="tab.batches && tab.batches.value.length"
|
v-if="tab.batches && tab.batches.value.length"
|
||||||
class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-5 mt-5 mx-5"
|
class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-5 m-5"
|
||||||
>
|
>
|
||||||
<router-link
|
<router-link
|
||||||
v-for="batch in tab.batches.value"
|
v-for="batch in tab.batches.value"
|
||||||
@@ -87,12 +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'
|
||||||
|
|
||||||
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',
|
||||||
@@ -101,32 +126,82 @@ 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(() => {
|
||||||
|
return {
|
||||||
|
title: 'Batches',
|
||||||
|
description: 'All batches divided by categories',
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
updateDocumentTitle(pageMeta)
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -161,7 +161,7 @@
|
|||||||
<div v-else-if="!user.data?.name">
|
<div v-else-if="!user.data?.name">
|
||||||
<NotPermitted
|
<NotPermitted
|
||||||
text="Please login to access this page."
|
text="Please login to access this page."
|
||||||
:buttonLink="`/login?redirect-to=/billing/${type}/${name}`"
|
:buttonLink="`/login?redirect-to=/lms/billing/${type}/${name}`"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -340,6 +340,7 @@ const validateAddress = () => {
|
|||||||
'Assam',
|
'Assam',
|
||||||
'Bihar',
|
'Bihar',
|
||||||
'Chhattisgarh',
|
'Chhattisgarh',
|
||||||
|
'Delhi',
|
||||||
'Goa',
|
'Goa',
|
||||||
'Gujarat',
|
'Gujarat',
|
||||||
'Haryana',
|
'Haryana',
|
||||||
|
|||||||
93
frontend/src/pages/CertifiedParticipants.vue
Normal file
93
frontend/src/pages/CertifiedParticipants.vue
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
<template>
|
||||||
|
<header
|
||||||
|
class="sticky top-0 z-10 flex items-center justify-between border-b bg-white px-3 py-2.5 sm:px-5"
|
||||||
|
>
|
||||||
|
<Breadcrumbs :items="breadcrumbs" />
|
||||||
|
<div>
|
||||||
|
<FormControl
|
||||||
|
type="text"
|
||||||
|
placeholder="Search"
|
||||||
|
v-model="searchQuery"
|
||||||
|
@input="participants.reload()"
|
||||||
|
class="w-40"
|
||||||
|
>
|
||||||
|
<template #prefix>
|
||||||
|
<Search class="w-4 stroke-1.5 text-gray-600" name="search" />
|
||||||
|
</template>
|
||||||
|
</FormControl>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4 m-5">
|
||||||
|
<div
|
||||||
|
v-if="participants.data?.length"
|
||||||
|
v-for="participant in participantsList"
|
||||||
|
>
|
||||||
|
<router-link
|
||||||
|
:to="{
|
||||||
|
name: 'Profile',
|
||||||
|
params: { username: participant.username },
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
<div class="flex shadow rounded-md h-full p-2">
|
||||||
|
<UserAvatar :user="participant" size="3xl" class="mr-2" />
|
||||||
|
<div>
|
||||||
|
<router-link
|
||||||
|
:to="{
|
||||||
|
name: 'Profile',
|
||||||
|
params: { username: participant.username },
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
<div class="text-lg font-semibold mb-2">
|
||||||
|
{{ participant.full_name }}
|
||||||
|
</div>
|
||||||
|
</router-link>
|
||||||
|
<div class="leading-5" v-for="course in participant.courses">
|
||||||
|
{{ course }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</router-link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<script setup>
|
||||||
|
import { Breadcrumbs, FormControl, createResource } from 'frappe-ui'
|
||||||
|
import { ref, computed } from 'vue'
|
||||||
|
import UserAvatar from '@/components/UserAvatar.vue'
|
||||||
|
import { Search } from 'lucide-vue-next'
|
||||||
|
import { updateDocumentTitle } from '@/utils'
|
||||||
|
|
||||||
|
const searchQuery = ref('')
|
||||||
|
|
||||||
|
const participants = createResource({
|
||||||
|
url: 'lms.lms.api.get_certified_participants',
|
||||||
|
method: 'GET',
|
||||||
|
cache: 'certified-participants',
|
||||||
|
auto: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
const breadcrumbs = computed(() => {
|
||||||
|
return [{ label: 'Certified Participants', to: '/certified-participants' }]
|
||||||
|
})
|
||||||
|
|
||||||
|
const pageMeta = computed(() => {
|
||||||
|
return {
|
||||||
|
title: 'Certified Participants',
|
||||||
|
description: 'All participants that have been certified.',
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const participantsList = computed(() => {
|
||||||
|
if (searchQuery.value) {
|
||||||
|
return participants.data.filter((participant) => {
|
||||||
|
return participant.full_name
|
||||||
|
.toLowerCase()
|
||||||
|
.includes(searchQuery.value.toLowerCase())
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return participants.data
|
||||||
|
})
|
||||||
|
|
||||||
|
updateDocumentTitle(pageMeta)
|
||||||
|
</script>
|
||||||
@@ -41,7 +41,7 @@
|
|||||||
>
|
>
|
||||||
<div class="flex items-center">
|
<div class="flex items-center">
|
||||||
<span
|
<span
|
||||||
class="mr-1"
|
class="h-6 mr-1"
|
||||||
:class="{
|
:class="{
|
||||||
'avatar-group overlap': course.data.instructors.length > 1,
|
'avatar-group overlap': course.data.instructors.length > 1,
|
||||||
}"
|
}"
|
||||||
@@ -51,17 +51,7 @@
|
|||||||
:user="instructor"
|
:user="instructor"
|
||||||
/>
|
/>
|
||||||
</span>
|
</span>
|
||||||
<span v-if="course.data.instructors.length == 1">
|
<CourseInstructors :instructors="course.data.instructors" />
|
||||||
{{ course.data.instructors[0].full_name }}
|
|
||||||
</span>
|
|
||||||
<span v-if="course.data.instructors.length == 2">
|
|
||||||
{{ course.data.instructors[0].first_name }} and
|
|
||||||
{{ course.data.instructors[1].first_name }}
|
|
||||||
</span>
|
|
||||||
<span v-if="course.data.instructors.length > 2">
|
|
||||||
{{ course.data.instructors[0].first_name }} and
|
|
||||||
{{ course.data.instructors.length - 1 }} others
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex mt-3 mb-4 w-fit">
|
<div class="flex mt-3 mb-4 w-fit">
|
||||||
@@ -80,14 +70,9 @@
|
|||||||
class="course-description"
|
class="course-description"
|
||||||
></div>
|
></div>
|
||||||
<div class="mt-10">
|
<div class="mt-10">
|
||||||
<CourseOutline
|
<CourseOutline :courseName="course.data.name" :showOutline="true" />
|
||||||
:courseName="course.data.name"
|
|
||||||
:showOutline="true"
|
|
||||||
title="Course Outline"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
<CourseReviews
|
<CourseReviews
|
||||||
v-if="course.data.avg_rating"
|
|
||||||
:courseName="course.data.name"
|
:courseName="course.data.name"
|
||||||
:avg_rating="course.data.avg_rating"
|
:avg_rating="course.data.avg_rating"
|
||||||
:membership="course.data.membership"
|
:membership="course.data.membership"
|
||||||
@@ -109,6 +94,7 @@ import CourseOutline from '@/components/CourseOutline.vue'
|
|||||||
import CourseReviews from '@/components/CourseReviews.vue'
|
import CourseReviews from '@/components/CourseReviews.vue'
|
||||||
import UserAvatar from '@/components/UserAvatar.vue'
|
import UserAvatar from '@/components/UserAvatar.vue'
|
||||||
import { updateDocumentTitle } from '@/utils'
|
import { updateDocumentTitle } from '@/utils'
|
||||||
|
import CourseInstructors from '@/components/CourseInstructors.vue'
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
courseName: {
|
courseName: {
|
||||||
@@ -159,6 +145,12 @@ updateDocumentTitle(pageMeta)
|
|||||||
padding: revert;
|
padding: revert;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.course-description ul {
|
||||||
|
list-style: disc;
|
||||||
|
margin: revert;
|
||||||
|
padding: revert;
|
||||||
|
}
|
||||||
|
|
||||||
.avatar-group {
|
.avatar-group {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|||||||
@@ -7,19 +7,6 @@
|
|||||||
>
|
>
|
||||||
<Breadcrumbs class="h-7" :items="breadcrumbs" />
|
<Breadcrumbs class="h-7" :items="breadcrumbs" />
|
||||||
<div class="flex items-center mt-3 md:mt-0">
|
<div class="flex items-center mt-3 md:mt-0">
|
||||||
<router-link
|
|
||||||
v-if="courseResource.data"
|
|
||||||
:to="{
|
|
||||||
name: 'CourseDetail',
|
|
||||||
params: { courseName: courseResource.data.name },
|
|
||||||
}"
|
|
||||||
>
|
|
||||||
<Button>
|
|
||||||
<span>
|
|
||||||
{{ __('View Course') }}
|
|
||||||
</span>
|
|
||||||
</Button>
|
|
||||||
</router-link>
|
|
||||||
<Button variant="solid" @click="submitCourse()" class="ml-2">
|
<Button variant="solid" @click="submitCourse()" class="ml-2">
|
||||||
<span>
|
<span>
|
||||||
{{ __('Save') }}
|
{{ __('Save') }}
|
||||||
@@ -99,13 +86,14 @@
|
|||||||
:label="__('Preview Video')"
|
:label="__('Preview Video')"
|
||||||
class="mb-4"
|
class="mb-4"
|
||||||
/>
|
/>
|
||||||
<div>
|
<div class="mb-4">
|
||||||
<div class="mb-1.5 text-xs text-gray-600">
|
<div class="mb-1.5 text-xs text-gray-600">
|
||||||
{{ __('Tags') }}
|
{{ __('Tags') }}
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center">
|
<div class="flex items-center">
|
||||||
<div
|
<div
|
||||||
v-for="tag in course.tags.split(', ')"
|
v-if="course.tags"
|
||||||
|
v-for="tag in course.tags?.split(', ')"
|
||||||
class="flex items-center bg-gray-100 p-2 rounded-md mr-2"
|
class="flex items-center bg-gray-100 p-2 rounded-md mr-2"
|
||||||
>
|
>
|
||||||
{{ tag }}
|
{{ tag }}
|
||||||
@@ -114,30 +102,64 @@
|
|||||||
@click="removeTag(tag)"
|
@click="removeTag(tag)"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<FormControl v-model="newTag" @keyup.enter="updateTags()" />
|
<FormControl
|
||||||
|
v-model="newTag"
|
||||||
|
@keyup.enter="updateTags()"
|
||||||
|
id="tags"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<MultiSelect
|
||||||
|
v-model="instructors"
|
||||||
|
doctype="User"
|
||||||
|
:label="__('Instructors')"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div class="container border-t">
|
<div class="container border-t">
|
||||||
<div class="text-lg font-semibold mt-5 mb-4">
|
<div class="text-lg font-semibold mt-5 mb-4">
|
||||||
{{ __('Settings') }}
|
{{ __('Settings') }}
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center justify-between mb-5">
|
<div class="grid grid-cols-3 gap-10 mb-4">
|
||||||
<FormControl
|
<div
|
||||||
type="checkbox"
|
v-if="user.data?.is_moderator"
|
||||||
v-model="course.published"
|
class="flex flex-col space-y-3"
|
||||||
:label="__('Published')"
|
>
|
||||||
/>
|
<FormControl
|
||||||
<FormControl
|
type="checkbox"
|
||||||
type="checkbox"
|
v-model="course.published"
|
||||||
v-model="course.upcoming"
|
:label="__('Published')"
|
||||||
:label="__('Upcoming')"
|
/>
|
||||||
/>
|
<FormControl
|
||||||
<FormControl
|
v-model="course.published_on"
|
||||||
type="checkbox"
|
:label="__('Published On')"
|
||||||
v-model="course.disable_self_learning"
|
type="date"
|
||||||
:label="__('Disable Self Enrollment')"
|
class="mb-5"
|
||||||
/>
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-col space-y-3">
|
||||||
|
<FormControl
|
||||||
|
type="checkbox"
|
||||||
|
v-model="course.upcoming"
|
||||||
|
:label="__('Upcoming')"
|
||||||
|
/>
|
||||||
|
<FormControl
|
||||||
|
type="checkbox"
|
||||||
|
v-model="course.featured"
|
||||||
|
:label="__('Featured')"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-col space-y-3">
|
||||||
|
<FormControl
|
||||||
|
type="checkbox"
|
||||||
|
v-model="course.disable_self_learning"
|
||||||
|
:label="__('Disable Self Enrollment')"
|
||||||
|
/>
|
||||||
|
<FormControl
|
||||||
|
type="checkbox"
|
||||||
|
v-model="course.enable_certification"
|
||||||
|
:label="__('Completion Certificate')"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="container border-t">
|
<div class="container border-t">
|
||||||
@@ -165,8 +187,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="border-l px-5 pt-5">
|
<div class="border-l pt-5">
|
||||||
<!-- <CreateOutline v-if="courseResource.doc" :course="courseResource.doc"/> -->
|
|
||||||
<CourseOutline
|
<CourseOutline
|
||||||
v-if="courseResource.data"
|
v-if="courseResource.data"
|
||||||
:courseName="courseResource.data.name"
|
:courseName="courseResource.data.name"
|
||||||
@@ -183,20 +204,35 @@ import {
|
|||||||
TextEditor,
|
TextEditor,
|
||||||
Button,
|
Button,
|
||||||
createResource,
|
createResource,
|
||||||
createDocumentResource,
|
|
||||||
FormControl,
|
FormControl,
|
||||||
FileUploader,
|
FileUploader,
|
||||||
} from 'frappe-ui'
|
} from 'frappe-ui'
|
||||||
import { inject, onMounted, computed, ref, reactive, watch } from 'vue'
|
import {
|
||||||
import { convertToTitleCase, showToast, getFileSize } from '../utils'
|
inject,
|
||||||
|
onMounted,
|
||||||
|
onBeforeUnmount,
|
||||||
|
computed,
|
||||||
|
ref,
|
||||||
|
reactive,
|
||||||
|
watch,
|
||||||
|
} from 'vue'
|
||||||
|
import {
|
||||||
|
convertToTitleCase,
|
||||||
|
showToast,
|
||||||
|
getFileSize,
|
||||||
|
updateDocumentTitle,
|
||||||
|
} from '../utils'
|
||||||
import Link from '@/components/Controls/Link.vue'
|
import Link from '@/components/Controls/Link.vue'
|
||||||
import { FileText, X } from 'lucide-vue-next'
|
import { FileText, X } from 'lucide-vue-next'
|
||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
import CourseOutline from '@/components/CourseOutline.vue'
|
import CourseOutline from '@/components/CourseOutline.vue'
|
||||||
|
import MultiSelect from '@/components/Controls/MultiSelect.vue'
|
||||||
|
import { capture } from '@/telemetry'
|
||||||
|
|
||||||
const user = inject('$user')
|
const user = inject('$user')
|
||||||
const newTag = ref('')
|
const newTag = ref('')
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
const instructors = ref([])
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
courseName: {
|
courseName: {
|
||||||
@@ -212,20 +248,46 @@ const course = reactive({
|
|||||||
course_image: null,
|
course_image: null,
|
||||||
tags: '',
|
tags: '',
|
||||||
published: false,
|
published: false,
|
||||||
|
published_on: '',
|
||||||
|
featured: false,
|
||||||
upcoming: false,
|
upcoming: false,
|
||||||
disable_self_learning: false,
|
disable_self_learning: false,
|
||||||
|
enable_certification: false,
|
||||||
paid_course: false,
|
paid_course: false,
|
||||||
course_price: '',
|
course_price: '',
|
||||||
currency: '',
|
currency: '',
|
||||||
})
|
})
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
if (!user.data?.is_moderator || !user.data?.is_instructor) {
|
if (
|
||||||
|
props.courseName == 'new' &&
|
||||||
|
!user.data?.is_moderator &&
|
||||||
|
!user.data?.is_instructor
|
||||||
|
) {
|
||||||
router.push({ name: 'Courses' })
|
router.push({ name: 'Courses' })
|
||||||
}
|
}
|
||||||
|
|
||||||
if (props.courseName !== 'new') {
|
if (props.courseName !== 'new') {
|
||||||
courseResource.reload()
|
courseResource.reload()
|
||||||
|
} else {
|
||||||
|
capture('course_form_opened')
|
||||||
}
|
}
|
||||||
|
window.addEventListener('keydown', keyboardShortcut)
|
||||||
|
})
|
||||||
|
|
||||||
|
const keyboardShortcut = (e) => {
|
||||||
|
if (
|
||||||
|
e.key === 's' &&
|
||||||
|
(e.ctrlKey || e.metaKey) &&
|
||||||
|
!e.target.classList.contains('ProseMirror')
|
||||||
|
) {
|
||||||
|
submitCourse()
|
||||||
|
e.preventDefault()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
window.removeEventListener('keydown', keyboardShortcut)
|
||||||
})
|
})
|
||||||
|
|
||||||
const courseCreationResource = createResource({
|
const courseCreationResource = createResource({
|
||||||
@@ -234,7 +296,10 @@ const courseCreationResource = createResource({
|
|||||||
return {
|
return {
|
||||||
doc: {
|
doc: {
|
||||||
doctype: 'LMS Course',
|
doctype: 'LMS Course',
|
||||||
image: course.course_image.file_url,
|
image: course.course_image?.file_url || '',
|
||||||
|
instructors: instructors.value.map((instructor) => ({
|
||||||
|
instructor: instructor,
|
||||||
|
})),
|
||||||
...values,
|
...values,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
@@ -249,7 +314,10 @@ const courseEditResource = createResource({
|
|||||||
doctype: 'LMS Course',
|
doctype: 'LMS Course',
|
||||||
name: values.course,
|
name: values.course,
|
||||||
fieldname: {
|
fieldname: {
|
||||||
image: course.course_image.file_url,
|
image: course.course_image?.file_url || '',
|
||||||
|
instructors: instructors.value.map((instructor) => ({
|
||||||
|
instructor: instructor,
|
||||||
|
})),
|
||||||
...course,
|
...course,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
@@ -267,13 +335,20 @@ const courseResource = createResource({
|
|||||||
auto: false,
|
auto: false,
|
||||||
onSuccess(data) {
|
onSuccess(data) {
|
||||||
Object.keys(data).forEach((key) => {
|
Object.keys(data).forEach((key) => {
|
||||||
if (Object.hasOwn(course, key)) course[key] = data[key]
|
if (key == 'instructors') {
|
||||||
|
instructors.value = []
|
||||||
|
data.instructors.forEach((instructor) => {
|
||||||
|
instructors.value.push(instructor.instructor)
|
||||||
|
})
|
||||||
|
} else if (Object.hasOwn(course, key)) course[key] = data[key]
|
||||||
})
|
})
|
||||||
let checkboxes = [
|
let checkboxes = [
|
||||||
'published',
|
'published',
|
||||||
'upcoming',
|
'upcoming',
|
||||||
'disable_self_learning',
|
'disable_self_learning',
|
||||||
'paid_course',
|
'paid_course',
|
||||||
|
'featured',
|
||||||
|
'enable_certification',
|
||||||
]
|
]
|
||||||
for (let idx in checkboxes) {
|
for (let idx in checkboxes) {
|
||||||
let key = checkboxes[idx]
|
let key = checkboxes[idx]
|
||||||
@@ -281,6 +356,7 @@ const courseResource = createResource({
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (data.image) imageResource.reload({ image: data.image })
|
if (data.image) imageResource.reload({ image: data.image })
|
||||||
|
check_permission()
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -297,12 +373,6 @@ const imageResource = createResource({
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
const getTags = computed(() => {
|
|
||||||
return courseResource.doc?.tags
|
|
||||||
? courseResource.doc.tags.split(', ')
|
|
||||||
: tags.value?.split(', ')
|
|
||||||
})
|
|
||||||
|
|
||||||
const submitCourse = () => {
|
const submitCourse = () => {
|
||||||
if (courseResource.data) {
|
if (courseResource.data) {
|
||||||
courseEditResource.submit(
|
courseEditResource.submit(
|
||||||
@@ -321,14 +391,15 @@ 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 },
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
onError(err) {
|
onError(err) {
|
||||||
showToast(err)
|
showToast('Error', err.messages?.[0] || err, 'x')
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -364,7 +435,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'].includes(extension)) {
|
if (!['jpg', 'jpeg', 'png', 'webp'].includes(extension)) {
|
||||||
return 'Only image file is allowed.'
|
return 'Only image file is allowed.'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -392,6 +463,21 @@ const removeImage = () => {
|
|||||||
course.course_image = null
|
course.course_image = null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const check_permission = () => {
|
||||||
|
let user_is_instructor = false
|
||||||
|
if (user.data?.is_moderator) return
|
||||||
|
|
||||||
|
instructors.value.forEach((instructor) => {
|
||||||
|
if (!user_is_instructor && instructor == user.data?.name) {
|
||||||
|
user_is_instructor = true
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!user_is_instructor) {
|
||||||
|
router.push({ name: 'Courses' })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const breadcrumbs = computed(() => {
|
const breadcrumbs = computed(() => {
|
||||||
let crumbs = [
|
let crumbs = [
|
||||||
{
|
{
|
||||||
@@ -407,8 +493,17 @@ 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
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const pageMeta = computed(() => {
|
||||||
|
return {
|
||||||
|
title: 'Create a Course',
|
||||||
|
description: 'Create or edit a course for your learning system.',
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
updateDocumentTitle(pageMeta)
|
||||||
</script>
|
</script>
|
||||||
@@ -7,10 +7,22 @@
|
|||||||
class="h-7"
|
class="h-7"
|
||||||
:items="[{ label: __('Courses'), route: { name: 'Courses' } }]"
|
:items="[{ label: __('Courses'), route: { name: 'Courses' } }]"
|
||||||
/>
|
/>
|
||||||
<div class="flex">
|
<div class="flex space-x-2 justify-end">
|
||||||
|
<div class="w-36">
|
||||||
|
<FormControl
|
||||||
|
type="text"
|
||||||
|
placeholder="Search"
|
||||||
|
v-model="searchQuery"
|
||||||
|
@input="courses.reload()"
|
||||||
|
>
|
||||||
|
<template #prefix>
|
||||||
|
<Search class="w-4 h-4 stroke-1.5" name="search" />
|
||||||
|
</template>
|
||||||
|
</FormControl>
|
||||||
|
</div>
|
||||||
<router-link
|
<router-link
|
||||||
:to="{
|
:to="{
|
||||||
name: 'CreateCourse',
|
name: 'CourseForm',
|
||||||
params: {
|
params: {
|
||||||
courseName: 'new',
|
courseName: 'new',
|
||||||
},
|
},
|
||||||
@@ -26,17 +38,10 @@
|
|||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
<div class="">
|
<div class="">
|
||||||
<div
|
|
||||||
v-if="courses.data.length == 0 && courses.list.loading"
|
|
||||||
class="p-5 text-base text-gray-700"
|
|
||||||
>
|
|
||||||
{{ __('Loading Courses...') }}
|
|
||||||
</div>
|
|
||||||
<Tabs
|
<Tabs
|
||||||
v-else
|
|
||||||
v-model="tabIndex"
|
v-model="tabIndex"
|
||||||
tablistClass="overflow-x-visible flex-wrap !gap-3 md:flex-nowrap"
|
tablistClass="overflow-x-visible flex-wrap !gap-3 md:flex-nowrap"
|
||||||
:tabs="tabs"
|
:tabs="makeTabs"
|
||||||
>
|
>
|
||||||
<template #tab="{ tab, selected }">
|
<template #tab="{ tab, selected }">
|
||||||
<div>
|
<div>
|
||||||
@@ -65,8 +70,8 @@
|
|||||||
name: 'Lesson',
|
name: 'Lesson',
|
||||||
params: {
|
params: {
|
||||||
courseName: course.name,
|
courseName: course.name,
|
||||||
chapterNumber: course.current_lesson.split('.')[0],
|
chapterNumber: course.current_lesson.split('-')[0],
|
||||||
lessonNumber: course.current_lesson.split('.')[1],
|
lessonNumber: course.current_lesson.split('-')[1],
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
: course.membership
|
: course.membership
|
||||||
@@ -104,61 +109,75 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { createListResource, Breadcrumbs, Tabs, Badge, Button } from 'frappe-ui'
|
import {
|
||||||
|
Breadcrumbs,
|
||||||
|
Tabs,
|
||||||
|
Badge,
|
||||||
|
Button,
|
||||||
|
FormControl,
|
||||||
|
createResource,
|
||||||
|
} from 'frappe-ui'
|
||||||
import CourseCard from '@/components/CourseCard.vue'
|
import CourseCard from '@/components/CourseCard.vue'
|
||||||
import { Plus } from 'lucide-vue-next'
|
import { Plus, Search } from 'lucide-vue-next'
|
||||||
import { ref, computed, inject } from 'vue'
|
import { ref, computed, inject } from 'vue'
|
||||||
import { updateDocumentTitle } from '@/utils'
|
import { updateDocumentTitle } from '@/utils'
|
||||||
|
|
||||||
const user = inject('$user')
|
const user = inject('$user')
|
||||||
const courses = createListResource({
|
const searchQuery = ref('')
|
||||||
type: 'list',
|
|
||||||
doctype: 'LMS Course',
|
const courses = createResource({
|
||||||
cache: ['courses', user?.data?.email],
|
|
||||||
url: 'lms.lms.utils.get_courses',
|
url: 'lms.lms.utils.get_courses',
|
||||||
|
cache: ['courses', user.data?.email],
|
||||||
auto: true,
|
auto: true,
|
||||||
})
|
})
|
||||||
|
|
||||||
const tabIndex = ref(0)
|
const tabIndex = ref(0)
|
||||||
const tabs = [
|
let tabs
|
||||||
{
|
|
||||||
label: 'All',
|
|
||||||
courses: computed(() => courses.data?.live || []),
|
|
||||||
count: computed(() => courses.data?.live?.length),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Upcoming',
|
|
||||||
courses: computed(() => courses.data?.upcoming),
|
|
||||||
count: computed(() => courses.data?.upcoming?.length),
|
|
||||||
},
|
|
||||||
]
|
|
||||||
|
|
||||||
if (user.data) {
|
const makeTabs = computed(() => {
|
||||||
|
tabs = []
|
||||||
|
addToTabs('Live')
|
||||||
|
addToTabs('New')
|
||||||
|
addToTabs('Upcoming')
|
||||||
|
|
||||||
|
if (user.data) {
|
||||||
|
addToTabs('Enrolled')
|
||||||
|
|
||||||
|
if (
|
||||||
|
user.data.is_moderator ||
|
||||||
|
user.data.is_instructor ||
|
||||||
|
courses.data?.created?.length
|
||||||
|
) {
|
||||||
|
addToTabs('Created')
|
||||||
|
}
|
||||||
|
|
||||||
|
if (user.data.is_moderator) {
|
||||||
|
addToTabs('Under Review')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return tabs
|
||||||
|
})
|
||||||
|
|
||||||
|
const addToTabs = (label) => {
|
||||||
|
let courses = getCourses(label.toLowerCase().split(' ').join('_'))
|
||||||
tabs.push({
|
tabs.push({
|
||||||
label: 'Enrolled',
|
label,
|
||||||
courses: computed(() => courses.data?.enrolled),
|
courses: computed(() => courses),
|
||||||
count: computed(() => courses.data?.enrolled?.length),
|
count: computed(() => courses.length),
|
||||||
})
|
})
|
||||||
|
}
|
||||||
|
|
||||||
if (
|
const getCourses = (type) => {
|
||||||
user.data.is_moderator ||
|
if (searchQuery.value) {
|
||||||
user.data.is_instructor ||
|
let query = searchQuery.value.toLowerCase()
|
||||||
courses.data?.created?.length
|
return courses.data[type].filter(
|
||||||
) {
|
(course) =>
|
||||||
tabs.push({
|
course.title.toLowerCase().includes(query) ||
|
||||||
label: 'Created',
|
course.short_introduction.toLowerCase().includes(query) ||
|
||||||
courses: computed(() => courses.data?.created),
|
course.tags.filter((tag) => tag.toLowerCase().includes(query)).length
|
||||||
count: computed(() => courses.data?.created?.length),
|
)
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
if (user.data.is_moderator) {
|
|
||||||
tabs.push({
|
|
||||||
label: 'Under Review',
|
|
||||||
courses: computed(() => courses.data?.under_review),
|
|
||||||
count: computed(() => courses.data?.under_review?.length),
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
return courses.data[type]
|
||||||
}
|
}
|
||||||
|
|
||||||
const pageMeta = computed(() => {
|
const pageMeta = computed(() => {
|
||||||
|
|||||||
@@ -50,38 +50,49 @@
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
<div v-if="job.data">
|
<div v-if="job.data" class="max-w-3xl mx-auto">
|
||||||
<div class="p-5 sm:p-5">
|
<div class="p-4">
|
||||||
<div class="flex mb-4">
|
<div class="flex mb-10">
|
||||||
<img
|
<img
|
||||||
:src="job.data.company_logo"
|
:src="job.data.company_logo"
|
||||||
class="w-16 h-16 rounded-lg object-contain mr-4"
|
class="w-16 h-16 rounded-lg object-contain mr-4"
|
||||||
:alt="job.data.company_name"
|
:alt="job.data.company_name"
|
||||||
/>
|
/>
|
||||||
<div>
|
<div>
|
||||||
<div class="text-2xl font-semibold mb-2">
|
<div class="text-2xl font-semibold mb-4">
|
||||||
{{ job.data.job_title }}
|
{{ job.data.job_title }}
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div
|
||||||
{{ __('posted by') }}
|
class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-x-10 gap-y-2 md:gap-y-4"
|
||||||
<span class="font-medium">{{ job.data.company_name }}</span>
|
>
|
||||||
{{ __('on') }}
|
<div class="flex items-center space-x-2">
|
||||||
<span class="font-medium">{{
|
<Building2 class="h-4 w-4 stroke-1.5" />
|
||||||
dayjs(job.data.creation).format('DD MMM YYYY')
|
<span>{{ job.data.company_name }}</span>
|
||||||
}}</span>
|
</div>
|
||||||
</div>
|
<div class="flex items-center space-x-2">
|
||||||
<div class="flex items-center mt-2">
|
<MapPin class="h-4 w-4 stroke-1.5" />
|
||||||
<Badge :label="job.data.type" theme="green" size="lg" />
|
<span>{{ job.data.location }}</span>
|
||||||
<Badge
|
</div>
|
||||||
:label="job.data.location"
|
<div class="flex items-center space-x-2">
|
||||||
theme="gray"
|
<ClipboardType class="h-4 w-4 stroke-1.5" />
|
||||||
size="lg"
|
<span>{{ job.data.type }}</span>
|
||||||
class="ml-4"
|
</div>
|
||||||
|
<div class="flex items-center space-x-2">
|
||||||
|
<CalendarDays class="h-4 w-4 stroke-1.5" />
|
||||||
|
<span>
|
||||||
|
{{ dayjs(job.data.creation).format('DD MMM YYYY') }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-if="applicationCount.data"
|
||||||
|
class="flex items-center space-x-2"
|
||||||
>
|
>
|
||||||
<template #prefix>
|
<SquareUserRound class="h-4 w-4 stroke-1.5" />
|
||||||
<MapPin class="h-4 w-4 stroke-1.5" />
|
<span
|
||||||
</template>
|
>{{ applicationCount.data }}
|
||||||
</Badge>
|
{{ __('applications received') }}</span
|
||||||
|
>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -99,10 +110,19 @@
|
|||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<script setup>
|
<script setup>
|
||||||
import { Badge, Button, Breadcrumbs, createResource } from 'frappe-ui'
|
import { Button, Breadcrumbs, createResource } from 'frappe-ui'
|
||||||
import { inject, ref, onMounted } from 'vue'
|
import { inject, ref, computed } from 'vue'
|
||||||
import { MapPin, SendHorizonal, Pencil } from 'lucide-vue-next'
|
import { updateDocumentTitle } from '@/utils'
|
||||||
import JobApplicationModal from '@/components/Modals/JobApplicationModal.vue'
|
import JobApplicationModal from '@/components/Modals/JobApplicationModal.vue'
|
||||||
|
import {
|
||||||
|
MapPin,
|
||||||
|
SendHorizonal,
|
||||||
|
Pencil,
|
||||||
|
Building2,
|
||||||
|
CalendarDays,
|
||||||
|
ClipboardType,
|
||||||
|
SquareUserRound,
|
||||||
|
} from 'lucide-vue-next'
|
||||||
|
|
||||||
const user = inject('$user')
|
const user = inject('$user')
|
||||||
const dayjs = inject('$dayjs')
|
const dayjs = inject('$dayjs')
|
||||||
@@ -126,6 +146,7 @@ const job = createResource({
|
|||||||
if (user.data?.name) {
|
if (user.data?.name) {
|
||||||
jobApplication.submit()
|
jobApplication.submit()
|
||||||
}
|
}
|
||||||
|
applicationCount.submit()
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -142,6 +163,18 @@ const jobApplication = createResource({
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const applicationCount = createResource({
|
||||||
|
url: 'frappe.client.get_count',
|
||||||
|
makeParams(values) {
|
||||||
|
return {
|
||||||
|
doctype: 'LMS Job Application',
|
||||||
|
filters: {
|
||||||
|
job: job.data?.name,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
const openApplicationModal = () => {
|
const openApplicationModal = () => {
|
||||||
showApplicationModal.value = true
|
showApplicationModal.value = true
|
||||||
}
|
}
|
||||||
@@ -149,4 +182,13 @@ const openApplicationModal = () => {
|
|||||||
const redirectToLogin = (job) => {
|
const redirectToLogin = (job) => {
|
||||||
window.location.href = `/login?redirect-to=/job-openings/${job}`
|
window.location.href = `/login?redirect-to=/job-openings/${job}`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const pageMeta = computed(() => {
|
||||||
|
return {
|
||||||
|
title: job.data?.job_title,
|
||||||
|
description: job.data?.description,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
updateDocumentTitle(pageMeta)
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -26,9 +26,9 @@
|
|||||||
</router-link>
|
</router-link>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
<div v-if="jobs.data">
|
<div v-if="jobs.data?.length">
|
||||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-5 p-5">
|
<div class="divide-y lg:w-3/4 mx-auto p-5">
|
||||||
<div v-if="jobs.data.length" v-for="job in jobs.data">
|
<div v-for="job in jobs.data">
|
||||||
<router-link
|
<router-link
|
||||||
:to="{
|
:to="{
|
||||||
name: 'JobDetail',
|
name: 'JobDetail',
|
||||||
@@ -41,13 +41,17 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div v-else class="text-gray-700 italic p-5 w-fit mx-auto">
|
||||||
|
{{ __('No jobs posted') }}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<script setup>
|
<script setup>
|
||||||
import { Button, Breadcrumbs, createResource } from 'frappe-ui'
|
import { Button, Breadcrumbs, createResource } from 'frappe-ui'
|
||||||
import { Plus } from 'lucide-vue-next'
|
import { Plus } from 'lucide-vue-next'
|
||||||
import { inject } from 'vue'
|
import { inject, computed } from 'vue'
|
||||||
import JobCard from '@/components/JobCard.vue'
|
import JobCard from '@/components/JobCard.vue'
|
||||||
|
import { updateDocumentTitle } from '@/utils'
|
||||||
|
|
||||||
const user = inject('$user')
|
const user = inject('$user')
|
||||||
|
|
||||||
@@ -56,4 +60,13 @@ const jobs = createResource({
|
|||||||
cache: ['jobs'],
|
cache: ['jobs'],
|
||||||
auto: true,
|
auto: true,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const pageMeta = computed(() => {
|
||||||
|
return {
|
||||||
|
title: 'Jobs',
|
||||||
|
description: 'An open job board for the community',
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
updateDocumentTitle(pageMeta)
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -5,10 +5,10 @@
|
|||||||
>
|
>
|
||||||
<Breadcrumbs class="h-7" :items="breadcrumbs" />
|
<Breadcrumbs class="h-7" :items="breadcrumbs" />
|
||||||
</header>
|
</header>
|
||||||
<div class="grid md:grid-cols-[70%,30%] h-full">
|
<div class="grid md:grid-cols-[70%,30%] h-screen">
|
||||||
<div
|
<div
|
||||||
v-if="lesson.data.no_preview"
|
v-if="lesson.data.no_preview"
|
||||||
class="border-r-2 text-center pt-10 px-5 md:px-0 pb-10"
|
class="border-r text-center pt-10 px-5 md:px-0 pb-10"
|
||||||
>
|
>
|
||||||
<p class="mb-4">
|
<p class="mb-4">
|
||||||
{{
|
{{
|
||||||
@@ -18,14 +18,18 @@
|
|||||||
}}
|
}}
|
||||||
</p>
|
</p>
|
||||||
<router-link
|
<router-link
|
||||||
|
v-if="user.data"
|
||||||
:to="{ name: 'CourseDetail', params: { courseName: courseName } }"
|
:to="{ name: 'CourseDetail', params: { courseName: courseName } }"
|
||||||
>
|
>
|
||||||
<Button variant="solid">
|
<Button variant="solid">
|
||||||
{{ __('Start Learning') }}
|
{{ __('Start Learning') }}
|
||||||
</Button>
|
</Button>
|
||||||
</router-link>
|
</router-link>
|
||||||
|
<Button v-else @click="redirectToLogin()">
|
||||||
|
{{ __('Login') }}
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<div v-else class="border-r-2 container pt-5 pb-10 px-5">
|
<div v-else class="border-r container pt-5 pb-10 px-5">
|
||||||
<div class="flex flex-col md:flex-row md:items-center justify-between">
|
<div class="flex flex-col md:flex-row md:items-center justify-between">
|
||||||
<div class="text-3xl font-semibold">
|
<div class="text-3xl font-semibold">
|
||||||
{{ lesson.data.title }}
|
{{ lesson.data.title }}
|
||||||
@@ -54,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,
|
||||||
@@ -86,12 +90,23 @@
|
|||||||
</span>
|
</span>
|
||||||
</Button>
|
</Button>
|
||||||
</router-link>
|
</router-link>
|
||||||
|
<router-link
|
||||||
|
v-else
|
||||||
|
:to="{
|
||||||
|
name: 'CourseDetail',
|
||||||
|
params: { courseName: courseName },
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
<Button>
|
||||||
|
{{ __('Back to Course') }}
|
||||||
|
</Button>
|
||||||
|
</router-link>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex items-center mt-2">
|
<div class="flex items-center mt-2">
|
||||||
<span
|
<span
|
||||||
class="mr-1"
|
class="h-6 mr-1"
|
||||||
:class="{
|
:class="{
|
||||||
'avatar-group overlap': lesson.data.instructors.length > 1,
|
'avatar-group overlap': lesson.data.instructors.length > 1,
|
||||||
}"
|
}"
|
||||||
@@ -101,17 +116,7 @@
|
|||||||
:user="instructor"
|
:user="instructor"
|
||||||
/>
|
/>
|
||||||
</span>
|
</span>
|
||||||
<span v-if="lesson.data.instructors.length == 1">
|
<CourseInstructors :instructors="lesson.data.instructors" />
|
||||||
{{ lesson.data.instructors[0].full_name }}
|
|
||||||
</span>
|
|
||||||
<span v-if="lesson.data.instructors.length == 2">
|
|
||||||
{{ lesson.data.instructors[0].first_name }} and
|
|
||||||
{{ lesson.data.instructors[1].first_name }}
|
|
||||||
</span>
|
|
||||||
<span v-if="lesson.data.instructors.length > 2">
|
|
||||||
{{ lesson.data.instructors[0].first_name }} and
|
|
||||||
{{ lesson.data.instructors.length - 1 }} others
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
v-if="
|
v-if="
|
||||||
@@ -152,7 +157,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="mt-20">
|
<div class="mt-20">
|
||||||
<Discussions
|
<Discussions
|
||||||
v-if="allowDiscussions()"
|
v-if="allowDiscussions"
|
||||||
:title="'Questions'"
|
:title="'Questions'"
|
||||||
:doctype="'Course Lesson'"
|
:doctype="'Course Lesson'"
|
||||||
:docname="lesson.data.name"
|
:docname="lesson.data.name"
|
||||||
@@ -161,45 +166,50 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="sticky top-10">
|
<div class="sticky top-10">
|
||||||
<div class="bg-gray-50 p-5 border-b-2">
|
<div class="bg-gray-50 py-5 px-2 border-b">
|
||||||
<div class="text-lg font-semibold">
|
<div class="text-lg font-semibold">
|
||||||
{{ lesson.data.course_title }}
|
{{ lesson.data.course_title }}
|
||||||
</div>
|
</div>
|
||||||
<div v-if="user && lesson.data.membership" class="text-sm mt-3">
|
<div v-if="user && lesson.data.membership" class="text-sm mt-3">
|
||||||
{{ Math.ceil(lesson.data.membership.progress) }}% completed
|
{{ Math.ceil(lessonProgress) }}% {{ __('completed') }}
|
||||||
</div>
|
</div>
|
||||||
<div
|
|
||||||
|
<ProgressBar
|
||||||
v-if="user && lesson.data.membership"
|
v-if="user && lesson.data.membership"
|
||||||
class="w-full bg-gray-200 rounded-full h-1 my-2"
|
:progress="lessonProgress"
|
||||||
>
|
/>
|
||||||
<div
|
|
||||||
class="bg-gray-900 h-1 rounded-full"
|
|
||||||
:style="{
|
|
||||||
width: Math.ceil(lesson.data.membership.progress) + '%',
|
|
||||||
}"
|
|
||||||
></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<CourseOutline :courseName="courseName" :key="chapterNumber" />
|
<CourseOutline
|
||||||
|
:courseName="courseName"
|
||||||
|
:key="chapterNumber"
|
||||||
|
:getProgress="lesson.data.membership ? true : false"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<script setup>
|
<script setup>
|
||||||
import { createResource, Breadcrumbs, Button } from 'frappe-ui'
|
import { createResource, Breadcrumbs, Button } from 'frappe-ui'
|
||||||
import { computed, watch, ref, inject, createApp } 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'
|
||||||
import { ChevronLeft, ChevronRight } from 'lucide-vue-next'
|
import { ChevronLeft, ChevronRight } from 'lucide-vue-next'
|
||||||
import Discussions from '@/components/Discussions.vue'
|
import Discussions from '@/components/Discussions.vue'
|
||||||
import { getEditorTools } from '../utils'
|
import { getEditorTools, updateDocumentTitle } from '../utils'
|
||||||
import EditorJS from '@editorjs/editorjs'
|
import EditorJS from '@editorjs/editorjs'
|
||||||
import LessonContent from '@/components/LessonContent.vue'
|
import LessonContent from '@/components/LessonContent.vue'
|
||||||
|
import CourseInstructors from '@/components/CourseInstructors.vue'
|
||||||
|
import ProgressBar from '@/components/ProgressBar.vue'
|
||||||
|
|
||||||
const user = inject('$user')
|
const user = inject('$user')
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
let editor, instructorEditor
|
const allowDiscussions = ref(false)
|
||||||
|
const editor = ref(null)
|
||||||
|
const instructorEditor = ref(null)
|
||||||
|
const lessonProgress = ref(0)
|
||||||
|
const timer = ref(0)
|
||||||
|
let timerInterval
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
courseName: {
|
courseName: {
|
||||||
@@ -216,6 +226,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],
|
||||||
@@ -228,25 +242,29 @@ const lesson = createResource({
|
|||||||
},
|
},
|
||||||
auto: true,
|
auto: true,
|
||||||
onSuccess(data) {
|
onSuccess(data) {
|
||||||
if (data.membership)
|
lessonProgress.value = data.membership?.progress
|
||||||
current_lesson.submit({
|
if (data.content) editor.value = renderEditor('editor', data.content)
|
||||||
name: data.membership.name,
|
|
||||||
lesson_name: data.name,
|
|
||||||
})
|
|
||||||
markProgress(data)
|
|
||||||
|
|
||||||
if (data.content) editor = renderEditor('editor', data.content)
|
|
||||||
if (data.instructor_content?.blocks?.length)
|
if (data.instructor_content?.blocks?.length)
|
||||||
instructorEditor = renderEditor(
|
instructorEditor.value = renderEditor(
|
||||||
'instructor-content',
|
'instructor-content',
|
||||||
data.instructor_content
|
data.instructor_content
|
||||||
)
|
)
|
||||||
|
editor.value?.isReady.then(() => {
|
||||||
|
checkIfDiscussionsAllowed()
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!editor.value && data.body) {
|
||||||
|
const quizRegex = /\{\{ Quiz\(".*"\) \}\}/
|
||||||
|
const hasQuiz = quizRegex.test(data.body)
|
||||||
|
if (!hasQuiz) allowDiscussions.value = true
|
||||||
|
}
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
const renderEditor = (holder, content) => {
|
const renderEditor = (holder, content) => {
|
||||||
// empty the holder
|
// empty the holder
|
||||||
document.getElementById(holder).innerHTML = ''
|
if (document.getElementById(holder))
|
||||||
|
document.getElementById(holder).innerHTML = ''
|
||||||
return new EditorJS({
|
return new EditorJS({
|
||||||
holder: holder,
|
holder: holder,
|
||||||
tools: getEditorTools(),
|
tools: getEditorTools(),
|
||||||
@@ -256,22 +274,12 @@ const renderEditor = (holder, content) => {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const markProgress = (data) => {
|
const markProgress = () => {
|
||||||
if (user.data && !data.progress) progress.submit()
|
if (user.data && !lesson.data?.progress) {
|
||||||
|
progress.submit()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const current_lesson = createResource({
|
|
||||||
url: 'frappe.client.set_value',
|
|
||||||
makeParams(values) {
|
|
||||||
return {
|
|
||||||
doctype: 'LMS Enrollment',
|
|
||||||
name: values.name,
|
|
||||||
fieldname: 'current_lesson',
|
|
||||||
value: values.lesson_name,
|
|
||||||
}
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
const progress = createResource({
|
const progress = createResource({
|
||||||
url: 'lms.lms.doctype.course_lesson.course_lesson.save_progress',
|
url: 'lms.lms.doctype.course_lesson.course_lesson.save_progress',
|
||||||
makeParams() {
|
makeParams() {
|
||||||
@@ -280,6 +288,9 @@ const progress = createResource({
|
|||||||
course: props.courseName,
|
course: props.courseName,
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
onSuccess(data) {
|
||||||
|
lessonProgress.value = data
|
||||||
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
const breadcrumbs = computed(() => {
|
const breadcrumbs = computed(() => {
|
||||||
@@ -308,21 +319,48 @@ watch(
|
|||||||
[newChapterNumber, newLessonNumber],
|
[newChapterNumber, newLessonNumber],
|
||||||
[oldChapterNumber, oldLessonNumber]
|
[oldChapterNumber, oldLessonNumber]
|
||||||
) => {
|
) => {
|
||||||
if (newChapterNumber && newLessonNumber) {
|
if (newChapterNumber || newLessonNumber) {
|
||||||
|
editor.value = null
|
||||||
|
instructorEditor.value = null
|
||||||
|
allowDiscussions.value = false
|
||||||
lesson.submit({
|
lesson.submit({
|
||||||
chapter: newChapterNumber,
|
chapter: newChapterNumber,
|
||||||
lesson: newLessonNumber,
|
lesson: newLessonNumber,
|
||||||
})
|
})
|
||||||
|
clearInterval(timerInterval)
|
||||||
|
timer.value = 0
|
||||||
|
startTimer()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
const allowDiscussions = () => {
|
const startTimer = () => {
|
||||||
return (
|
timerInterval = setInterval(() => {
|
||||||
lesson.data?.membership ||
|
timer.value++
|
||||||
user.data?.is_moderator ||
|
if (timer.value == 30) {
|
||||||
user.data?.is_instructor
|
clearInterval(timerInterval)
|
||||||
|
markProgress()
|
||||||
|
}
|
||||||
|
}, 1000)
|
||||||
|
}
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
clearInterval(timerInterval)
|
||||||
|
})
|
||||||
|
|
||||||
|
const checkIfDiscussionsAllowed = () => {
|
||||||
|
let quizPresent = false
|
||||||
|
JSON.parse(lesson.data?.content)?.blocks?.forEach((block) => {
|
||||||
|
if (block.type === 'quiz') quizPresent = true
|
||||||
|
})
|
||||||
|
|
||||||
|
if (
|
||||||
|
!quizPresent &&
|
||||||
|
(lesson.data?.membership ||
|
||||||
|
user.data?.is_moderator ||
|
||||||
|
user.data?.is_instructor)
|
||||||
)
|
)
|
||||||
|
allowDiscussions.value = true
|
||||||
}
|
}
|
||||||
|
|
||||||
const allowEdit = () => {
|
const allowEdit = () => {
|
||||||
@@ -336,6 +374,19 @@ const allowInstructorContent = () => {
|
|||||||
if (lesson.data?.instructors.includes(user.data?.name)) return true
|
if (lesson.data?.instructors.includes(user.data?.name)) return true
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const redirectToLogin = () => {
|
||||||
|
window.location.href = `/login?redirect-to=/lms/courses/${props.courseName}`
|
||||||
|
}
|
||||||
|
|
||||||
|
const pageMeta = computed(() => {
|
||||||
|
return {
|
||||||
|
title: lesson.data?.title,
|
||||||
|
description: lesson.data?.course,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
updateDocumentTitle(pageMeta)
|
||||||
</script>
|
</script>
|
||||||
<style>
|
<style>
|
||||||
.avatar-group {
|
.avatar-group {
|
||||||
@@ -389,11 +440,101 @@ const allowInstructorContent = () => {
|
|||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
}
|
}
|
||||||
|
|
||||||
.codex-editor__redactor {
|
|
||||||
padding-bottom: 0 !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.embed-tool__caption {
|
.embed-tool__caption {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.ce-block__content {
|
||||||
|
max-width: unset;
|
||||||
|
}
|
||||||
|
|
||||||
|
.codeBoxHolder {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: flex-start;
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.codeBoxTextArea {
|
||||||
|
width: 100%;
|
||||||
|
min-height: 30px;
|
||||||
|
padding: 10px;
|
||||||
|
border-radius: 2px 2px 2px 0;
|
||||||
|
border: none !important;
|
||||||
|
outline: none !important;
|
||||||
|
font: 14px monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
.codeBoxSelectDiv {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: flex-start;
|
||||||
|
align-items: flex-start;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.codeBoxSelectInput {
|
||||||
|
border-radius: 0 0 20px 2px;
|
||||||
|
padding: 2px 26px;
|
||||||
|
padding-top: 0;
|
||||||
|
padding-right: 0;
|
||||||
|
text-align: left;
|
||||||
|
cursor: pointer;
|
||||||
|
border: none !important;
|
||||||
|
outline: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.codeBoxSelectDropIcon {
|
||||||
|
position: absolute !important;
|
||||||
|
left: 10px !important;
|
||||||
|
bottom: 0 !important;
|
||||||
|
width: unset !important;
|
||||||
|
height: unset !important;
|
||||||
|
font-size: 16px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.codeBoxSelectPreview {
|
||||||
|
display: none;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: flex-start;
|
||||||
|
align-items: flex-start;
|
||||||
|
border-radius: 2px;
|
||||||
|
box-shadow: 0 3px 15px -3px rgba(13, 20, 33, 0.13);
|
||||||
|
position: absolute;
|
||||||
|
top: 100%;
|
||||||
|
margin: 5px 0;
|
||||||
|
max-height: 30vh;
|
||||||
|
overflow-x: hidden;
|
||||||
|
overflow-y: auto;
|
||||||
|
z-index: 10000;
|
||||||
|
}
|
||||||
|
|
||||||
|
.codeBoxSelectItem {
|
||||||
|
width: 100%;
|
||||||
|
padding: 5px 20px;
|
||||||
|
margin: 0;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.codeBoxSelectItem:hover {
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.codeBoxSelectedItem {
|
||||||
|
background-color: lightblue !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.codeBoxShow {
|
||||||
|
display: flex !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark {
|
||||||
|
color: #abb2bf;
|
||||||
|
background-color: #282c34;
|
||||||
|
}
|
||||||
|
|
||||||
|
.light {
|
||||||
|
color: #383a42;
|
||||||
|
background-color: #fafafa;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="">
|
<div class="">
|
||||||
<div class="grid md:grid-cols-[75%,25%] h-full">
|
<div class="grid md:grid-cols-[75%,25%] h-screen">
|
||||||
<div class="border-r">
|
<div class="border-r">
|
||||||
<header
|
<header
|
||||||
class="sticky top-0 z-10 flex flex-col md:flex-row md:items-center justify-between border-b overflow-hidden bg-white px-3 py-2.5 sm:px-5"
|
class="sticky top-0 z-10 flex flex-col md:flex-row md:items-center justify-between border-b overflow-hidden bg-white px-3 py-2.5 sm:px-5"
|
||||||
@@ -43,7 +43,7 @@
|
|||||||
<div
|
<div
|
||||||
v-show="openInstructorEditor"
|
v-show="openInstructorEditor"
|
||||||
id="instructor-notes"
|
id="instructor-notes"
|
||||||
class="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>
|
||||||
@@ -52,7 +52,10 @@
|
|||||||
<label class="block font-medium text-gray-600 mb-1">
|
<label class="block font-medium text-gray-600 mb-1">
|
||||||
{{ __('Content') }}
|
{{ __('Content') }}
|
||||||
</label>
|
</label>
|
||||||
<div id="content" class="py-3"></div>
|
<div
|
||||||
|
id="content"
|
||||||
|
class="ProseMirror prose prose-table:table-fixed prose-td:p-2 prose-th:p-2 prose-td:border prose-th:border prose-td:border-gray-300 prose-th:border-gray-300 prose-td:relative prose-th:relative prose-th:bg-gray-100 prose-sm max-w-none !whitespace-normal py-3"
|
||||||
|
></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -66,26 +69,26 @@
|
|||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<script setup>
|
<script setup>
|
||||||
|
import { Breadcrumbs, FormControl, createResource, Button } from 'frappe-ui'
|
||||||
import {
|
import {
|
||||||
Breadcrumbs,
|
computed,
|
||||||
FormControl,
|
reactive,
|
||||||
createResource,
|
onMounted,
|
||||||
Button,
|
inject,
|
||||||
createDocumentResource,
|
ref,
|
||||||
} from 'frappe-ui'
|
onBeforeUnmount,
|
||||||
import { computed, reactive, onMounted, inject, ref, watch } from 'vue'
|
} from 'vue'
|
||||||
import EditorJS from '@editorjs/editorjs'
|
import EditorJS from '@editorjs/editorjs'
|
||||||
import { createToast } from '../utils'
|
|
||||||
import LessonPlugins from '@/components/LessonPlugins.vue'
|
import LessonPlugins from '@/components/LessonPlugins.vue'
|
||||||
import { getEditorTools } from '../utils'
|
|
||||||
import { ChevronRight } from 'lucide-vue-next'
|
import { ChevronRight } from 'lucide-vue-next'
|
||||||
import { useRouter } from 'vue-router'
|
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)
|
||||||
const router = useRouter()
|
let autoSaveInterval
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
courseName: {
|
courseName: {
|
||||||
@@ -103,9 +106,10 @@ const props = defineProps({
|
|||||||
})
|
})
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
if (!user.data?.is_moderator || !user.data?.is_instructor) {
|
if (!user.data?.is_moderator && !user.data?.is_instructor) {
|
||||||
window.location.href = '/login'
|
window.location.href = '/login'
|
||||||
}
|
}
|
||||||
|
capture('lesson_form_opened')
|
||||||
editor.value = renderEditor('content')
|
editor.value = renderEditor('content')
|
||||||
instructorEditor.value = renderEditor('instructor-notes')
|
instructorEditor.value = renderEditor('instructor-notes')
|
||||||
})
|
})
|
||||||
@@ -114,6 +118,7 @@ const renderEditor = (holder) => {
|
|||||||
return new EditorJS({
|
return new EditorJS({
|
||||||
holder: holder,
|
holder: holder,
|
||||||
tools: getEditorTools(),
|
tools: getEditorTools(),
|
||||||
|
autofocus: true,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -139,32 +144,49 @@ const lessonDetails = createResource({
|
|||||||
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.include_in_preview ? true : false
|
||||||
editor.value.isReady.then(() => {
|
addLessonContent(data)
|
||||||
if (data.lesson.content) {
|
addInstructorNotes(data)
|
||||||
editor.value.render(JSON.parse(data.lesson.content))
|
enableAutoSave()
|
||||||
} else if (data.lesson.body) {
|
|
||||||
let blocks = convertToJSON(data.lesson)
|
|
||||||
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()
|
||||||
|
}, 5000)
|
||||||
|
}
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
clearInterval(autoSaveInterval)
|
||||||
|
})
|
||||||
|
|
||||||
const newLessonResource = createResource({
|
const newLessonResource = createResource({
|
||||||
url: 'frappe.client.insert',
|
url: 'frappe.client.insert',
|
||||||
makeParams(values) {
|
makeParams(values) {
|
||||||
@@ -340,6 +362,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()
|
||||||
},
|
},
|
||||||
@@ -362,9 +385,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')
|
||||||
},
|
},
|
||||||
@@ -423,7 +443,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,
|
||||||
@@ -433,17 +453,117 @@ const breadcrumbs = computed(() => {
|
|||||||
})
|
})
|
||||||
return crumbs
|
return crumbs
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const pageMeta = computed(() => {
|
||||||
|
return {
|
||||||
|
title: 'Lesson Editor',
|
||||||
|
description: 'Create and edit lessons for your course',
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
updateDocumentTitle(pageMeta)
|
||||||
</script>
|
</script>
|
||||||
<style>
|
<style>
|
||||||
.embed-tool__caption {
|
.embed-tool__caption,
|
||||||
|
.cdx-simple-image__caption {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.ce-toolbar__actions {
|
|
||||||
right: 108%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.ce-block__content {
|
.ce-block__content {
|
||||||
max-width: none;
|
max-width: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.ce-toolbar__content {
|
||||||
|
max-width: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.codeBoxHolder {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: flex-start;
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.codeBoxTextArea {
|
||||||
|
width: 100%;
|
||||||
|
min-height: 30px;
|
||||||
|
padding: 10px;
|
||||||
|
border-radius: 2px 2px 2px 0;
|
||||||
|
border: none !important;
|
||||||
|
outline: none !important;
|
||||||
|
font: 14px monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
.codeBoxSelectDiv {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: flex-start;
|
||||||
|
align-items: flex-start;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.codeBoxSelectInput {
|
||||||
|
border-radius: 0 0 20px 2px;
|
||||||
|
padding: 2px 26px;
|
||||||
|
padding-top: 0;
|
||||||
|
padding-right: 0;
|
||||||
|
text-align: left;
|
||||||
|
cursor: pointer;
|
||||||
|
border: none !important;
|
||||||
|
outline: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.codeBoxSelectDropIcon {
|
||||||
|
position: absolute !important;
|
||||||
|
left: 10px !important;
|
||||||
|
bottom: 0 !important;
|
||||||
|
width: unset !important;
|
||||||
|
height: unset !important;
|
||||||
|
font-size: 16px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.codeBoxSelectPreview {
|
||||||
|
display: none;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: flex-start;
|
||||||
|
align-items: flex-start;
|
||||||
|
border-radius: 2px;
|
||||||
|
box-shadow: 0 3px 15px -3px rgba(13, 20, 33, 0.13);
|
||||||
|
position: absolute;
|
||||||
|
top: 100%;
|
||||||
|
margin: 5px 0;
|
||||||
|
max-height: 30vh;
|
||||||
|
overflow-x: hidden;
|
||||||
|
overflow-y: auto;
|
||||||
|
z-index: 10000;
|
||||||
|
}
|
||||||
|
|
||||||
|
.codeBoxSelectItem {
|
||||||
|
width: 100%;
|
||||||
|
padding: 5px 20px;
|
||||||
|
margin: 0;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.codeBoxSelectItem:hover {
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.codeBoxSelectedItem {
|
||||||
|
background-color: lightblue !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.codeBoxShow {
|
||||||
|
display: flex !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark {
|
||||||
|
color: #abb2bf;
|
||||||
|
background-color: #282c34;
|
||||||
|
}
|
||||||
|
|
||||||
|
.light {
|
||||||
|
color: #383a42;
|
||||||
|
background-color: #fafafa;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
167
frontend/src/pages/Notifications.vue
Normal file
167
frontend/src/pages/Notifications.vue
Normal file
@@ -0,0 +1,167 @@
|
|||||||
|
<template>
|
||||||
|
<header
|
||||||
|
class="sticky top-0 z-10 flex flex-col md:flex-row md:items-center justify-between border-b bg-white px-3 py-2.5 sm:px-5"
|
||||||
|
>
|
||||||
|
<Breadcrumbs :items="breadcrumbs" />
|
||||||
|
<div class="flex items-center space-x-2">
|
||||||
|
<Button
|
||||||
|
@click="markAllAsRead.submit"
|
||||||
|
:loading="markAllAsRead.loading"
|
||||||
|
v-if="activeTab === 'Unread' && unReadNotifications.data?.length > 0"
|
||||||
|
>
|
||||||
|
{{ __('Mark all as read') }}
|
||||||
|
</Button>
|
||||||
|
<TabButtons
|
||||||
|
class="inline-block"
|
||||||
|
:buttons="[{ label: 'Unread', active: true }, { label: 'Read' }]"
|
||||||
|
v-model="activeTab"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
<div class="w-3/4 mx-auto px-5 pt-6 divide-y">
|
||||||
|
<div
|
||||||
|
v-if="notifications?.length"
|
||||||
|
v-for="log in notifications"
|
||||||
|
class="flex items-center py-2 justify-between"
|
||||||
|
>
|
||||||
|
<div class="flex items-center">
|
||||||
|
<UserAvatar :user="allUsers.data[log.from_user]" class="mr-2" />
|
||||||
|
<div class="notification" v-html="log.subject"></div>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center space-x-2">
|
||||||
|
<Link
|
||||||
|
v-if="log.link"
|
||||||
|
:to="log.link"
|
||||||
|
@click="markAsRead.submit({ name: log.name })"
|
||||||
|
class="text-gray-600 font-medium text-sm hover:text-gray-700"
|
||||||
|
>
|
||||||
|
{{ __('View') }}
|
||||||
|
</Link>
|
||||||
|
<Tooltip :text="__('Mark as read')">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
v-if="!log.read"
|
||||||
|
@click="markAsRead.submit({ name: log.name })"
|
||||||
|
>
|
||||||
|
<template #icon>
|
||||||
|
<X class="h-4 w-4 text-gray-700 stroke-1.5" />
|
||||||
|
</template>
|
||||||
|
</Button>
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-else class="text-gray-600">
|
||||||
|
{{ __('Nothing to see here.') }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<script setup>
|
||||||
|
import {
|
||||||
|
createListResource,
|
||||||
|
createResource,
|
||||||
|
Breadcrumbs,
|
||||||
|
Link,
|
||||||
|
TabButtons,
|
||||||
|
Button,
|
||||||
|
Tooltip,
|
||||||
|
} from 'frappe-ui'
|
||||||
|
import { computed, inject, ref, onMounted } from 'vue'
|
||||||
|
import UserAvatar from '@/components/UserAvatar.vue'
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
|
import { X } from 'lucide-vue-next'
|
||||||
|
import { updateDocumentTitle } from '@/utils'
|
||||||
|
|
||||||
|
const user = inject('$user')
|
||||||
|
const socket = inject('$socket')
|
||||||
|
const allUsers = inject('$allUsers')
|
||||||
|
const activeTab = ref('Unread')
|
||||||
|
const router = useRouter()
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
if (!user.data) router.push({ name: 'Courses' })
|
||||||
|
|
||||||
|
socket.on('publish_lms_notifications', (data) => {
|
||||||
|
unReadNotifications.reload()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
const notifications = computed(() => {
|
||||||
|
return activeTab.value === 'Unread'
|
||||||
|
? unReadNotifications.data
|
||||||
|
: readNotifications.data
|
||||||
|
})
|
||||||
|
|
||||||
|
const unReadNotifications = createListResource({
|
||||||
|
doctype: 'Notification Log',
|
||||||
|
fields: ['subject', 'from_user', 'link', 'read', 'name'],
|
||||||
|
filters: {
|
||||||
|
for_user: user.data?.name,
|
||||||
|
read: 0,
|
||||||
|
},
|
||||||
|
orderBy: 'creation desc',
|
||||||
|
auto: true,
|
||||||
|
cache: 'Unread Notifications',
|
||||||
|
})
|
||||||
|
|
||||||
|
const readNotifications = createListResource({
|
||||||
|
doctype: 'Notification Log',
|
||||||
|
fields: ['subject', 'from_user', 'link', 'read', 'name'],
|
||||||
|
filters: {
|
||||||
|
for_user: user.data?.name,
|
||||||
|
read: 1,
|
||||||
|
},
|
||||||
|
orderBy: 'creation desc',
|
||||||
|
auto: true,
|
||||||
|
cache: 'Read Notifications',
|
||||||
|
})
|
||||||
|
|
||||||
|
const markAsRead = createResource({
|
||||||
|
url: 'lms.lms.api.mark_as_read',
|
||||||
|
makeParams(values) {
|
||||||
|
return {
|
||||||
|
name: values.name,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onSuccess(data) {
|
||||||
|
unReadNotifications.reload()
|
||||||
|
readNotifications.reload()
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const markAllAsRead = createResource({
|
||||||
|
url: 'lms.lms.api.mark_all_as_read',
|
||||||
|
onSuccess(data) {
|
||||||
|
unReadNotifications.reload()
|
||||||
|
readNotifications.reload()
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const breadcrumbs = computed(() => {
|
||||||
|
let crumbs = [
|
||||||
|
{
|
||||||
|
label: 'Notifications',
|
||||||
|
route: {
|
||||||
|
name: 'Notifications',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]
|
||||||
|
return crumbs
|
||||||
|
})
|
||||||
|
|
||||||
|
const pageMeta = computed(() => {
|
||||||
|
return {
|
||||||
|
title: 'Notifications',
|
||||||
|
description: 'All your notifications in one place.',
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
updateDocumentTitle(pageMeta)
|
||||||
|
</script>
|
||||||
|
<style>
|
||||||
|
.notification strong {
|
||||||
|
font-weight: 400;
|
||||||
|
}
|
||||||
|
.notification b {
|
||||||
|
font-weight: 400;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -1 +1,220 @@
|
|||||||
<template></template>
|
<template>
|
||||||
|
<NoPermission v-if="!$user.data" />
|
||||||
|
<div v-else-if="profile.data">
|
||||||
|
<header
|
||||||
|
class="sticky top-0 z-10 flex flex-col md:flex-row md:items-center justify-between border-b bg-white px-3 py-2.5 sm:px-5"
|
||||||
|
>
|
||||||
|
<Breadcrumbs class="h-7" :items="breadcrumbs" />
|
||||||
|
</header>
|
||||||
|
<div class="group relative h-[130px] w-full">
|
||||||
|
<img
|
||||||
|
v-if="profile.data.cover_image"
|
||||||
|
:src="profile.data.cover_image"
|
||||||
|
class="h-[130px] w-full object-cover object-center"
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
v-else
|
||||||
|
:class="{ 'bg-gray-100': !profile.data.cover_image }"
|
||||||
|
class="h-[130px] w-full"
|
||||||
|
></div>
|
||||||
|
<div
|
||||||
|
class="absolute bottom-0 left-1/2 mb-4 flex -translate-x-1/2 space-x-2 opacity-0 transition-opacity focus-within:opacity-100 group-hover:opacity-100"
|
||||||
|
v-if="isSessionUser()"
|
||||||
|
>
|
||||||
|
<EditCoverImage
|
||||||
|
@select="(imageUrl) => coverImage.submit({ url: imageUrl })"
|
||||||
|
>
|
||||||
|
<template v-slot="{ togglePopover }">
|
||||||
|
<Button variant="outline" @click="togglePopover()">
|
||||||
|
<template #prefix>
|
||||||
|
<Edit class="w-4 h-4 stroke-1.5 text-gray-700" />
|
||||||
|
</template>
|
||||||
|
{{ __('Edit') }}
|
||||||
|
</Button>
|
||||||
|
</template>
|
||||||
|
</EditCoverImage>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="mx-auto -mt-10 md:-mt-4 max-w-4xl translate-x-0 px-5">
|
||||||
|
<div class="flex flex-col md:flex-row items-center">
|
||||||
|
<div>
|
||||||
|
<img
|
||||||
|
v-if="profile.data.user_image"
|
||||||
|
:src="profile.data.user_image"
|
||||||
|
class="object-cover h-[100px] w-[100px] rounded-full border-4 border-white object-cover"
|
||||||
|
/>
|
||||||
|
<UserAvatar
|
||||||
|
v-else
|
||||||
|
:user="profile.data"
|
||||||
|
class="object-cover h-[100px] w-[100px] rounded-full border-4 border-white object-cover"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="ml-6">
|
||||||
|
<h2 class="mt-2 text-3xl font-semibold text-gray-900">
|
||||||
|
{{ profile.data.full_name }}
|
||||||
|
</h2>
|
||||||
|
<div class="mt-2 text-base text-gray-700">
|
||||||
|
{{ profile.data.headline }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
v-if="isSessionUser()"
|
||||||
|
class="mt-3 sm:mt-0 md:ml-auto"
|
||||||
|
@click="editProfile()"
|
||||||
|
>
|
||||||
|
<template #prefix>
|
||||||
|
<Edit class="w-4 h-4 stroke-1.5 text-gray-700" />
|
||||||
|
</template>
|
||||||
|
{{ __('Edit Profile') }}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-4 mt-6">
|
||||||
|
<TabButtons
|
||||||
|
class="inline-block"
|
||||||
|
:buttons="getTabButtons()"
|
||||||
|
v-model="activeTab"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<router-view :profile="profile" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<EditProfile
|
||||||
|
v-model="showProfileModal"
|
||||||
|
v-model:reloadProfile="profile"
|
||||||
|
:profile="profile"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
<script setup>
|
||||||
|
import { Breadcrumbs, createResource, Button, TabButtons } from 'frappe-ui'
|
||||||
|
import { computed, inject, watch, ref, onMounted, watchEffect } from 'vue'
|
||||||
|
import { sessionStore } from '@/stores/session'
|
||||||
|
import { Edit } from 'lucide-vue-next'
|
||||||
|
import UserAvatar from '@/components/UserAvatar.vue'
|
||||||
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
|
import NoPermission from '@/components/NoPermission.vue'
|
||||||
|
import { convertToTitleCase, updateDocumentTitle } from '@/utils'
|
||||||
|
import EditProfile from '@/components/Modals/EditProfile.vue'
|
||||||
|
import EditCoverImage from '@/components/Modals/EditCoverImage.vue'
|
||||||
|
|
||||||
|
const { user } = sessionStore()
|
||||||
|
const $user = inject('$user')
|
||||||
|
const route = useRoute()
|
||||||
|
const router = useRouter()
|
||||||
|
const activeTab = ref('')
|
||||||
|
const showProfileModal = ref(false)
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
username: {
|
||||||
|
type: String,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
if ($user.data) profile.reload()
|
||||||
|
|
||||||
|
setActiveTab()
|
||||||
|
})
|
||||||
|
|
||||||
|
const profile = createResource({
|
||||||
|
url: 'frappe.client.get',
|
||||||
|
makeParams(values) {
|
||||||
|
return {
|
||||||
|
doctype: 'User',
|
||||||
|
filters: {
|
||||||
|
username: props.username,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const coverImage = createResource({
|
||||||
|
url: 'frappe.client.set_value',
|
||||||
|
makeParams(values) {
|
||||||
|
return {
|
||||||
|
doctype: 'User',
|
||||||
|
name: profile.data?.name,
|
||||||
|
fieldname: 'cover_image',
|
||||||
|
value: values.url,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onSuccess() {
|
||||||
|
profile.reload()
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const setActiveTab = () => {
|
||||||
|
let fragments = route.path.split('/')
|
||||||
|
let sections = ['certificates', 'roles', 'evaluations']
|
||||||
|
sections.forEach((section) => {
|
||||||
|
if (fragments.includes(section)) {
|
||||||
|
activeTab.value = convertToTitleCase(section)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
if (!activeTab.value) activeTab.value = 'About'
|
||||||
|
}
|
||||||
|
|
||||||
|
watchEffect(() => {
|
||||||
|
if (activeTab.value) {
|
||||||
|
let route = {
|
||||||
|
About: { name: 'ProfileAbout' },
|
||||||
|
Certificates: { name: 'ProfileCertificates' },
|
||||||
|
Roles: { name: 'ProfileRoles' },
|
||||||
|
Evaluations: { name: 'ProfileEvaluator' },
|
||||||
|
}[activeTab.value]
|
||||||
|
router.push(route)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.username,
|
||||||
|
() => {
|
||||||
|
profile.reload()
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
const editProfile = () => {
|
||||||
|
showProfileModal.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
const isSessionUser = () => {
|
||||||
|
return $user.data?.email === profile.data?.email
|
||||||
|
}
|
||||||
|
|
||||||
|
const getTabButtons = () => {
|
||||||
|
let buttons = [{ label: 'About' }, { label: 'Certificates' }]
|
||||||
|
if ($user.data?.is_moderator) buttons.push({ label: 'Roles' })
|
||||||
|
if (isSessionUser() && $user.data?.is_evaluator)
|
||||||
|
buttons.push({ label: 'Evaluations' })
|
||||||
|
|
||||||
|
return buttons
|
||||||
|
}
|
||||||
|
|
||||||
|
const breadcrumbs = computed(() => {
|
||||||
|
let crumbs = [
|
||||||
|
{
|
||||||
|
label: 'People',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: profile.data?.full_name,
|
||||||
|
route: {
|
||||||
|
name: 'Profile',
|
||||||
|
params: {
|
||||||
|
username: user.doc?.username,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]
|
||||||
|
return crumbs
|
||||||
|
})
|
||||||
|
|
||||||
|
const pageMeta = computed(() => {
|
||||||
|
return {
|
||||||
|
title: profile.data?.full_name,
|
||||||
|
description: profile.data?.headline,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
updateDocumentTitle(pageMeta)
|
||||||
|
</script>
|
||||||
|
|||||||
155
frontend/src/pages/ProfileAbout.vue
Normal file
155
frontend/src/pages/ProfileAbout.vue
Normal file
@@ -0,0 +1,155 @@
|
|||||||
|
<template>
|
||||||
|
<div class="mt-7 mb-10">
|
||||||
|
<h2 class="mb-3 text-lg font-semibold text-gray-900">
|
||||||
|
{{ __('About') }}
|
||||||
|
</h2>
|
||||||
|
<div
|
||||||
|
v-if="profile.data.bio"
|
||||||
|
v-html="profile.data.bio"
|
||||||
|
class="ProseMirror prose prose-table:table-fixed prose-td:p-2 prose-th:p-2 prose-td:border prose-th:border prose-td:border-gray-300 prose-th:border-gray-300 prose-td:relative prose-th:relative prose-th:bg-gray-100 prose-sm max-w-none !whitespace-normal"
|
||||||
|
></div>
|
||||||
|
<div v-else class="text-gray-700 text-sm italic">
|
||||||
|
{{ __('No introduction') }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="mt-7 mb-10" v-if="badges.data?.length">
|
||||||
|
<h2 class="mb-3 text-lg font-semibold text-gray-900">
|
||||||
|
{{ __('Achievements') }}
|
||||||
|
</h2>
|
||||||
|
<div class="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-5 gap-4">
|
||||||
|
<div v-for="badge in badges.data">
|
||||||
|
<Popover trigger="hover" :leaveDelay="Number(0.01)">
|
||||||
|
<template #target>
|
||||||
|
<div class="relative">
|
||||||
|
<img
|
||||||
|
:src="badge.badge_image"
|
||||||
|
:alt="badge.badge"
|
||||||
|
class="h-[80px]"
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
v-if="badge.count > 1"
|
||||||
|
class="flex items-end bg-gray-100 p-2 text-xs font-semibold rounded-full absolute right-0 bottom-0"
|
||||||
|
>
|
||||||
|
<span>
|
||||||
|
<X class="w-3 h-3" />
|
||||||
|
</span>
|
||||||
|
{{ badge.count }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<template #body-main>
|
||||||
|
<div class="w-[250px] text-base">
|
||||||
|
<img
|
||||||
|
:src="badge.badge_image"
|
||||||
|
:alt="badge.badge"
|
||||||
|
class="bg-gray-100 rounded-t-md"
|
||||||
|
/>
|
||||||
|
<div class="p-5">
|
||||||
|
<div class="text-2xl font-semibold mb-2">
|
||||||
|
{{ badge.badge }}
|
||||||
|
</div>
|
||||||
|
<div class="leading-5 mb-4">
|
||||||
|
{{ badge.badge_description }}
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-col mb-4">
|
||||||
|
<span class="text-xs text-gray-700 font-medium mb-1">
|
||||||
|
{{ __('Issued on') }}:
|
||||||
|
</span>
|
||||||
|
{{ dayjs(badge.issued_on).format('DD MMM YYYY') }}
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-col">
|
||||||
|
<span class="text-xs text-gray-700 font-medium mb-1">
|
||||||
|
{{ __('Share on') }}:
|
||||||
|
</span>
|
||||||
|
<div class="flex items-center space-x-2">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
@click="shareOnSocial(badge, 'LinkedIn')"
|
||||||
|
>
|
||||||
|
<template #prefix>
|
||||||
|
<LinkedinIcon class="h-3 w-3 text-gray-700" />
|
||||||
|
</template>
|
||||||
|
<span class="text-xs">
|
||||||
|
{{ __('LinkedIn') }}
|
||||||
|
</span>
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
@click="shareOnSocial(badge, 'Twitter')"
|
||||||
|
>
|
||||||
|
<template #prefix>
|
||||||
|
<Twitter class="h-3 w-3 text-gray-700" />
|
||||||
|
</template>
|
||||||
|
<span class="text-xs">
|
||||||
|
{{ __('Twitter') }}
|
||||||
|
</span>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</Popover>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<script setup>
|
||||||
|
import { inject } from 'vue'
|
||||||
|
import { createResource, Popover, Button } from 'frappe-ui'
|
||||||
|
import { X, LinkedinIcon, Twitter } from 'lucide-vue-next'
|
||||||
|
import { sessionStore } from '@/stores/session'
|
||||||
|
|
||||||
|
const dayjs = inject('$dayjs')
|
||||||
|
const { branding } = sessionStore()
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
profile: {
|
||||||
|
type: Object,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const badges = createResource({
|
||||||
|
url: 'frappe.client.get_list',
|
||||||
|
params: {
|
||||||
|
doctype: 'LMS Badge Assignment',
|
||||||
|
fields: ['name', 'badge', 'badge_image', 'badge_description', 'issued_on'],
|
||||||
|
filters: {
|
||||||
|
member: props.profile.data.name,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
auto: true,
|
||||||
|
transform(data) {
|
||||||
|
let finalBadges = []
|
||||||
|
let groupedBadges = Object.groupBy(data, ({ badge }) => badge)
|
||||||
|
for (let badge in groupedBadges) {
|
||||||
|
let badgeData = groupedBadges[badge][0]
|
||||||
|
badgeData.count = groupedBadges[badge].length
|
||||||
|
finalBadges.push(badgeData)
|
||||||
|
}
|
||||||
|
return finalBadges
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const shareOnSocial = (badge, medium) => {
|
||||||
|
let shareUrl
|
||||||
|
const url = encodeURIComponent(
|
||||||
|
`${window.location.origin}/lms/badges/${badge.badge}/${props.profile.data?.email}`
|
||||||
|
)
|
||||||
|
const summary = `I am happy to announce that I earned the ${
|
||||||
|
badge.badge
|
||||||
|
} badge on ${dayjs(badge.issued_on).format('DD MMM YYYY')} at ${
|
||||||
|
branding.data?.brand_name
|
||||||
|
}.`
|
||||||
|
|
||||||
|
if (medium == 'LinkedIn')
|
||||||
|
shareUrl = `https://www.linkedin.com/shareArticle?mini=true&url=${url}&text=${summary}`
|
||||||
|
else if (medium == 'Twitter')
|
||||||
|
shareUrl = `https://twitter.com/intent/tweet?text=${summary}&url=${url}`
|
||||||
|
|
||||||
|
window.open(shareUrl, '_blank')
|
||||||
|
}
|
||||||
|
</script>
|
||||||
51
frontend/src/pages/ProfileCertificates.vue
Normal file
51
frontend/src/pages/ProfileCertificates.vue
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
<template>
|
||||||
|
<div class="mt-7 mb-10">
|
||||||
|
<h2 class="mb-3 text-lg font-semibold text-gray-900">
|
||||||
|
{{ __('Certificates') }}
|
||||||
|
</h2>
|
||||||
|
<div class="grid grod-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||||
|
<div
|
||||||
|
v-for="certificate in certificates.data"
|
||||||
|
:key="certificate.name"
|
||||||
|
class="bg-white shadow rounded-lg p-3 cursor-pointer"
|
||||||
|
@click="openCertificate(certificate)"
|
||||||
|
>
|
||||||
|
<div class="font-medium leading-5">
|
||||||
|
{{ certificate.course_title }}
|
||||||
|
</div>
|
||||||
|
<div class="mt-2">
|
||||||
|
<span class="text-xs text-gray-700"> {{ __('issued on') }}: </span>
|
||||||
|
{{ dayjs(certificate.issue_date).format('DD MMM YYYY') }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<script setup>
|
||||||
|
import { createResource } from 'frappe-ui'
|
||||||
|
import { inject } from 'vue'
|
||||||
|
|
||||||
|
const dayjs = inject('$dayjs')
|
||||||
|
const props = defineProps({
|
||||||
|
profile: {
|
||||||
|
type: Object,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const certificates = createResource({
|
||||||
|
url: 'lms.lms.api.get_certificates',
|
||||||
|
params: {
|
||||||
|
member: props.profile.data.name,
|
||||||
|
},
|
||||||
|
auto: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
const openCertificate = (certificate) => {
|
||||||
|
window.open(
|
||||||
|
`/api/method/frappe.utils.print_format.download_pdf?doctype=LMS+Certificate&name=${
|
||||||
|
certificate.name
|
||||||
|
}&format=${encodeURIComponent(certificate.template)}`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
</script>
|
||||||
323
frontend/src/pages/ProfileEvaluator.vue
Normal file
323
frontend/src/pages/ProfileEvaluator.vue
Normal file
@@ -0,0 +1,323 @@
|
|||||||
|
<template>
|
||||||
|
<div class="mt-7 mb-20">
|
||||||
|
<h2 class="mb-4 text-lg font-semibold text-gray-900">
|
||||||
|
{{ __('My availability') }}
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<div class="">
|
||||||
|
<div
|
||||||
|
class="grid grid-cols-3 md:grid-cols-4 gap-4 text-sm text-gray-700 mb-4"
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
{{ __('Day') }}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
{{ __('Start Time') }}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
{{ __('End Time') }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-if="evaluator.data"
|
||||||
|
v-for="slot in evaluator.data.slots.schedule"
|
||||||
|
class="grid grid-cols-3 md:grid-cols-4 gap-4 mb-4 group"
|
||||||
|
>
|
||||||
|
<FormControl
|
||||||
|
type="select"
|
||||||
|
:options="days"
|
||||||
|
v-model="slot.day"
|
||||||
|
@focusout.stop="update(slot.name, 'day', slot.day)"
|
||||||
|
/>
|
||||||
|
<FormControl
|
||||||
|
type="time"
|
||||||
|
v-model="slot.start_time"
|
||||||
|
@focusout.stop="update(slot.name, 'start_time', slot.start_time)"
|
||||||
|
/>
|
||||||
|
<FormControl
|
||||||
|
type="time"
|
||||||
|
v-model="slot.end_time"
|
||||||
|
@focusout.stop="update(slot.name, 'end_time', slot.end_time)"
|
||||||
|
/>
|
||||||
|
<X
|
||||||
|
@click="deleteRow(slot.name)"
|
||||||
|
class="w-6 h-auto stroke-1.5 text-red-900 rounded-md cursor-pointer p-1 bg-red-100 hidden group-hover:block"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="grid grid-cols-3 md:grid-cols-4 gap-4 mb-4"
|
||||||
|
v-show="showSlotsTemplate"
|
||||||
|
>
|
||||||
|
<FormControl
|
||||||
|
type="select"
|
||||||
|
:options="days"
|
||||||
|
v-model="newSlot.day"
|
||||||
|
@focusout.stop="add()"
|
||||||
|
/>
|
||||||
|
<FormControl
|
||||||
|
type="time"
|
||||||
|
v-model="newSlot.start_time"
|
||||||
|
@focusout.stop="add()"
|
||||||
|
/>
|
||||||
|
<FormControl
|
||||||
|
type="time"
|
||||||
|
v-model="newSlot.end_time"
|
||||||
|
@focusout.stop="add()"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button @click="showSlotsTemplate = 1">
|
||||||
|
<template #prefix>
|
||||||
|
<Plus class="w-4 h-4 stroke-1.5 text-gray-700" />
|
||||||
|
</template>
|
||||||
|
{{ __('Add Slot') }}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<div class="my-10">
|
||||||
|
<h2 class="mb-4 text-lg font-semibold text-gray-900">
|
||||||
|
{{ __('I am unavailable') }}
|
||||||
|
</h2>
|
||||||
|
<div class="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||||
|
<FormControl
|
||||||
|
type="date"
|
||||||
|
:label="__('From')"
|
||||||
|
v-model="from"
|
||||||
|
@blur="
|
||||||
|
() => {
|
||||||
|
updateUnavailability.submit({
|
||||||
|
field: 'unavailable_from',
|
||||||
|
value: from,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
"
|
||||||
|
/>
|
||||||
|
<FormControl
|
||||||
|
type="date"
|
||||||
|
:label="__('To')"
|
||||||
|
v-model="to"
|
||||||
|
@blur="
|
||||||
|
() => {
|
||||||
|
updateUnavailability.submit({
|
||||||
|
field: 'unavailable_to',
|
||||||
|
value: to,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h2 class="mb-4 text-lg font-semibold text-gray-900">
|
||||||
|
{{ __('My calendar') }}
|
||||||
|
</h2>
|
||||||
|
<div
|
||||||
|
v-if="evaluator.data?.calendar && evaluator.data?.is_authorized"
|
||||||
|
class="flex items-center bg-green-100 text-green-900 text-sm p-1 rounded-md mb-4 w-fit"
|
||||||
|
>
|
||||||
|
<Check class="h-4 w-4 stroke-1.5 mr-2" />
|
||||||
|
{{ __('Your calendar is set.') }}
|
||||||
|
</div>
|
||||||
|
<Button @click="() => authorizeCalendar.submit()">
|
||||||
|
{{ __('Authorize Google Calendar Access') }}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<script setup>
|
||||||
|
import { createResource, FormControl, Button } from 'frappe-ui'
|
||||||
|
import { computed, reactive, ref, onMounted, inject } from 'vue'
|
||||||
|
import { showToast, convertToTitleCase } from '@/utils'
|
||||||
|
import { Plus, X, Check } from 'lucide-vue-next'
|
||||||
|
|
||||||
|
const user = inject('$user')
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
profile: {
|
||||||
|
type: Object,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
if (user.data?.name !== props.profile.data?.name) {
|
||||||
|
window.location.href = `/user/${props.profile.data?.username}`
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const showSlotsTemplate = ref(0)
|
||||||
|
const from = ref(null)
|
||||||
|
const to = ref(null)
|
||||||
|
|
||||||
|
const newSlot = reactive({
|
||||||
|
day: '',
|
||||||
|
start_time: '',
|
||||||
|
end_time: '',
|
||||||
|
})
|
||||||
|
|
||||||
|
const evaluator = createResource({
|
||||||
|
url: 'lms.lms.api.get_evaluator_details',
|
||||||
|
params: {
|
||||||
|
evaluator: props.profile.data?.name,
|
||||||
|
},
|
||||||
|
auto: true,
|
||||||
|
onSuccess(data) {
|
||||||
|
if (data.slots.unavailable_from) from.value = data.slots.unavailable_from
|
||||||
|
if (data.slots.unavailable_to) to.value = data.slots.unavailable_to
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const createSlot = createResource({
|
||||||
|
url: 'frappe.client.insert',
|
||||||
|
makeParams(values) {
|
||||||
|
return {
|
||||||
|
doc: {
|
||||||
|
doctype: 'Evaluator Schedule',
|
||||||
|
parent: evaluator.data?.slots.name,
|
||||||
|
parentfield: 'schedule',
|
||||||
|
parenttype: 'Course Evaluator',
|
||||||
|
...newSlot,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onSuccess() {
|
||||||
|
showToast('Success', 'Slot added successfully', 'check')
|
||||||
|
evaluator.reload()
|
||||||
|
showSlotsTemplate.value = 0
|
||||||
|
newSlot.day = ''
|
||||||
|
newSlot.start_time = ''
|
||||||
|
newSlot.end_time = ''
|
||||||
|
},
|
||||||
|
onError(err) {
|
||||||
|
showToast('Error', err.messages?.[0] || err, 'x')
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const updateSlot = createResource({
|
||||||
|
url: 'frappe.client.set_value',
|
||||||
|
makeParams(values) {
|
||||||
|
return {
|
||||||
|
doctype: 'Evaluator Schedule',
|
||||||
|
name: values.name,
|
||||||
|
fieldname: values.field,
|
||||||
|
value: values.value,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onSuccess() {
|
||||||
|
showToast('Success', 'Availability updated successfully', 'check')
|
||||||
|
},
|
||||||
|
onError(err) {
|
||||||
|
showToast('Error', err.messages?.[0] || err, 'x')
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const deleteSlot = createResource({
|
||||||
|
url: 'frappe.client.delete',
|
||||||
|
makeParams(values) {
|
||||||
|
return {
|
||||||
|
doctype: 'Evaluator Schedule',
|
||||||
|
name: values.name,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onSuccess() {
|
||||||
|
showToast('Success', 'Slot deleted successfully', 'check')
|
||||||
|
evaluator.reload()
|
||||||
|
},
|
||||||
|
onError(err) {
|
||||||
|
showToast('Error', err.messages?.[0] || err, 'x')
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const updateUnavailability = createResource({
|
||||||
|
url: 'frappe.client.set_value',
|
||||||
|
makeParams(values) {
|
||||||
|
return {
|
||||||
|
doctype: 'Course Evaluator',
|
||||||
|
name: evaluator.data?.slots.name,
|
||||||
|
fieldname: values.field,
|
||||||
|
value: values.value,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onSuccess() {
|
||||||
|
showToast('Success', 'Unavailability updated successfully', 'check')
|
||||||
|
},
|
||||||
|
onError(err) {
|
||||||
|
showToast('Error', err.messages?.[0] || err, 'x')
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const update = (name, field, value) => {
|
||||||
|
updateSlot.submit(
|
||||||
|
{
|
||||||
|
name,
|
||||||
|
field,
|
||||||
|
value,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
validate() {
|
||||||
|
if (!value) {
|
||||||
|
return `Please enter a value for ${convertToTitleCase(field)}`
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const add = () => {
|
||||||
|
if (!newSlot.day || !newSlot.start_time || !newSlot.end_time) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
createSlot.submit()
|
||||||
|
}
|
||||||
|
|
||||||
|
const deleteRow = (name) => {
|
||||||
|
deleteSlot.submit({ name })
|
||||||
|
}
|
||||||
|
|
||||||
|
const authorizeCalendar = createResource({
|
||||||
|
url: 'frappe.integrations.doctype.google_calendar.google_calendar.authorize_access',
|
||||||
|
makeParams() {
|
||||||
|
return {
|
||||||
|
g_calendar: evaluator.data?.calendar,
|
||||||
|
reauthorize: 1,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onSuccess(data) {
|
||||||
|
window.open(data.url)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const days = computed(() => {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
label: 'Monday',
|
||||||
|
value: 'Monday',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Tuesday',
|
||||||
|
value: 'Tuesday',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Wednesday',
|
||||||
|
value: 'Wednesday',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Thursday',
|
||||||
|
value: 'Thursday',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Friday',
|
||||||
|
value: 'Friday',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Saturday',
|
||||||
|
value: 'Saturday',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Sunday',
|
||||||
|
value: 'Sunday',
|
||||||
|
},
|
||||||
|
]
|
||||||
|
})
|
||||||
|
</script>
|
||||||
98
frontend/src/pages/ProfileRoles.vue
Normal file
98
frontend/src/pages/ProfileRoles.vue
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
<template>
|
||||||
|
<div class="mt-7">
|
||||||
|
<h2 class="mb-3 text-lg font-semibold text-gray-900">
|
||||||
|
{{ __('Settings') }}
|
||||||
|
</h2>
|
||||||
|
<div
|
||||||
|
class="flex flex-col md:flex-row gap-4 md:gap-0 justify-between w-3/4 mt-5"
|
||||||
|
>
|
||||||
|
<FormControl
|
||||||
|
:label="__('Moderator')"
|
||||||
|
v-model="moderator"
|
||||||
|
type="checkbox"
|
||||||
|
@change.stop="changeRole('moderator')"
|
||||||
|
/>
|
||||||
|
<FormControl
|
||||||
|
:label="__('Course Creator')"
|
||||||
|
v-model="course_creator"
|
||||||
|
type="checkbox"
|
||||||
|
@change.stop="changeRole('course_creator')"
|
||||||
|
/>
|
||||||
|
<FormControl
|
||||||
|
:label="__('Evaluator')"
|
||||||
|
v-model="batch_evaluator"
|
||||||
|
type="checkbox"
|
||||||
|
@change.stop="changeRole('batch_evaluator')"
|
||||||
|
/>
|
||||||
|
<FormControl
|
||||||
|
:label="__('Student')"
|
||||||
|
v-model="lms_student"
|
||||||
|
type="checkbox"
|
||||||
|
@change.stop="changeRole('lms_student')"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<script setup>
|
||||||
|
import { FormControl, createResource } from 'frappe-ui'
|
||||||
|
import { ref } from 'vue'
|
||||||
|
import { showToast, convertToTitleCase } from '@/utils'
|
||||||
|
|
||||||
|
const moderator = ref(false)
|
||||||
|
const course_creator = ref(false)
|
||||||
|
const batch_evaluator = ref(false)
|
||||||
|
const lms_student = ref(false)
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
profile: {
|
||||||
|
type: Object,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const roles = createResource({
|
||||||
|
url: 'lms.lms.utils.get_roles',
|
||||||
|
makeParams(values) {
|
||||||
|
return {
|
||||||
|
name: props.profile.data?.name,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
auto: true,
|
||||||
|
onSuccess(data) {
|
||||||
|
let roles = [
|
||||||
|
'moderator',
|
||||||
|
'course_creator',
|
||||||
|
'batch_evaluator',
|
||||||
|
'lms_student',
|
||||||
|
]
|
||||||
|
for (let role of roles) {
|
||||||
|
if (data[role]) eval(role).value = true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const updateRole = createResource({
|
||||||
|
url: 'lms.overrides.user.save_role',
|
||||||
|
makeParams(values) {
|
||||||
|
return {
|
||||||
|
user: props.profile.data?.name,
|
||||||
|
role: values.role,
|
||||||
|
value: values.value,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const changeRole = (role) => {
|
||||||
|
updateRole.submit(
|
||||||
|
{
|
||||||
|
role: convertToTitleCase(role.split('_').join(' ')),
|
||||||
|
value: eval(role).value,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
onSuccess(data) {
|
||||||
|
showToast('Success', 'Role updated successfully', 'check')
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
</script>
|
||||||
418
frontend/src/pages/QuizCreation.vue
Normal file
418
frontend/src/pages/QuizCreation.vue
Normal file
@@ -0,0 +1,418 @@
|
|||||||
|
<template>
|
||||||
|
<header
|
||||||
|
class="sticky top-0 z-10 flex items-center justify-between border-b bg-white px-3 py-2.5 sm:px-5"
|
||||||
|
>
|
||||||
|
<Breadcrumbs :items="breadcrumbs" />
|
||||||
|
<Button variant="solid" @click="submitQuiz()">
|
||||||
|
{{ __('Save') }}
|
||||||
|
</Button>
|
||||||
|
</header>
|
||||||
|
<div class="w-3/4 mx-auto py-5">
|
||||||
|
<!-- Details -->
|
||||||
|
<div class="mb-8">
|
||||||
|
<div class="text-sm font-semibold mb-4">
|
||||||
|
{{ __('Details') }}
|
||||||
|
</div>
|
||||||
|
<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-3 gap-5 mt-2 mb-8">
|
||||||
|
<FormControl
|
||||||
|
v-model="quiz.max_attempts"
|
||||||
|
:label="__('Maximun Attempts')"
|
||||||
|
/>
|
||||||
|
<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="text-sm font-semibold mb-4">
|
||||||
|
{{ __('Settings') }}
|
||||||
|
</div>
|
||||||
|
<div class="grid grid-cols-3 gap-5 my-4">
|
||||||
|
<FormControl
|
||||||
|
v-model="quiz.show_answers"
|
||||||
|
type="checkbox"
|
||||||
|
:label="__('Show Answers')"
|
||||||
|
/>
|
||||||
|
<FormControl
|
||||||
|
v-model="quiz.show_submission_history"
|
||||||
|
type="checkbox"
|
||||||
|
:label="__('Show Submission History')"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-8">
|
||||||
|
<div class="text-sm 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="text-sm font-semibold">
|
||||||
|
{{ __('Questions') }}
|
||||||
|
</div>
|
||||||
|
<Button @click="openQuestionModal()">
|
||||||
|
<template #prefix>
|
||||||
|
<Plus class="w-4 h-4" />
|
||||||
|
</template>
|
||||||
|
{{ __('New Question') }}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<ListView
|
||||||
|
:columns="questionColumns"
|
||||||
|
:rows="quiz.questions"
|
||||||
|
row-key="name"
|
||||||
|
:options="{
|
||||||
|
showTooltip: false,
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
<ListHeader
|
||||||
|
class="mb-2 grid items-center space-x-4 rounded bg-gray-100 p-2"
|
||||||
|
>
|
||||||
|
<ListHeaderItem :item="item" v-for="item in questionColumns" />
|
||||||
|
</ListHeader>
|
||||||
|
<ListRows>
|
||||||
|
<ListRow
|
||||||
|
:row="row"
|
||||||
|
v-slot="{ idx, column, item }"
|
||||||
|
v-for="row in quiz.questions"
|
||||||
|
@click="openQuestionModal(row)"
|
||||||
|
>
|
||||||
|
<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="deleteQuizzes(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 } 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,
|
||||||
|
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: 'QuizCreation',
|
||||||
|
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: '25%',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: __('Question'),
|
||||||
|
key: __('question_detail'),
|
||||||
|
width: '60%',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: __('Marks'),
|
||||||
|
key: 'marks',
|
||||||
|
width: '10%',
|
||||||
|
},
|
||||||
|
]
|
||||||
|
})
|
||||||
|
|
||||||
|
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 deleteQuiz = createResource({
|
||||||
|
url: 'frappe.client.delete',
|
||||||
|
makeParams(values) {
|
||||||
|
return {
|
||||||
|
doctype: 'LMS Quiz Question',
|
||||||
|
name: values.quiz,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const deleteQuizzes = (selections, unselectAll) => {
|
||||||
|
selections.forEach(async (quiz) => {
|
||||||
|
deleteQuiz.submit({ quiz })
|
||||||
|
})
|
||||||
|
setTimeout(() => {
|
||||||
|
quizDetails.reload()
|
||||||
|
unselectAll()
|
||||||
|
}, 500)
|
||||||
|
}
|
||||||
|
|
||||||
|
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: 'QuizCreation', params: { quizID: props.quizID } },
|
||||||
|
})
|
||||||
|
return crumbs
|
||||||
|
})
|
||||||
|
</script>
|
||||||
126
frontend/src/pages/Quizzes.vue
Normal file
126
frontend/src/pages/Quizzes.vue
Normal file
@@ -0,0 +1,126 @@
|
|||||||
|
<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: 'QuizCreation',
|
||||||
|
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="w-3/4 mx-auto py-5">
|
||||||
|
<ListView
|
||||||
|
:columns="quizColumns"
|
||||||
|
:rows="quizzes.data"
|
||||||
|
row-key="name"
|
||||||
|
:options="{ showTooltip: false, selectable: false }"
|
||||||
|
>
|
||||||
|
<ListHeader
|
||||||
|
class="mb-2 grid items-center space-x-4 rounded bg-gray-100 p-2"
|
||||||
|
>
|
||||||
|
<ListHeaderItem :item="item" v-for="item in quizColumns">
|
||||||
|
</ListHeaderItem>
|
||||||
|
</ListHeader>
|
||||||
|
<ListRows>
|
||||||
|
<router-link
|
||||||
|
v-for="row in quizzes.data"
|
||||||
|
:to="{
|
||||||
|
name: 'QuizCreation',
|
||||||
|
params: {
|
||||||
|
quizID: row.name,
|
||||||
|
},
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
<ListRow :row="row" />
|
||||||
|
</router-link>
|
||||||
|
</ListRows>
|
||||||
|
</ListView>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<script setup>
|
||||||
|
import {
|
||||||
|
Breadcrumbs,
|
||||||
|
createListResource,
|
||||||
|
ListView,
|
||||||
|
ListRows,
|
||||||
|
ListRow,
|
||||||
|
ListHeader,
|
||||||
|
ListHeaderItem,
|
||||||
|
Button,
|
||||||
|
} from 'frappe-ui'
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
|
import { computed, inject, onMounted } from 'vue'
|
||||||
|
import { Plus } from 'lucide-vue-next'
|
||||||
|
|
||||||
|
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',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]
|
||||||
|
})
|
||||||
|
</script>
|
||||||
@@ -6,17 +6,17 @@
|
|||||||
<Breadcrumbs class="h-7" :items="breadcrumbs" />
|
<Breadcrumbs class="h-7" :items="breadcrumbs" />
|
||||||
</header>
|
</header>
|
||||||
<div v-if="chartDetails.data" class="p-5">
|
<div v-if="chartDetails.data" class="p-5">
|
||||||
<div class="grid grid-cols-2 lg:grid-cols-5 gap-4">
|
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-5 gap-4">
|
||||||
<div class="flex items-center shadow py-2 px-3 rounded-md">
|
<div class="flex items-center shadow py-2 px-3 rounded-md">
|
||||||
<div class="p-2 rounded-md bg-gray-100 mr-3">
|
<div class="p-2 rounded-md bg-gray-100 mr-3">
|
||||||
<BookOpen class="w-18 h-18 stroke-1.5 text-gray-700" />
|
<BookOpen class="w-18 h-18 stroke-1.5 text-gray-700" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<div class="text-xl font-semibold mb-1">
|
<div class="text-xl font-semibold mb-1">
|
||||||
{{ chartDetails.data.courses }}
|
{{ formatNumber(chartDetails.data.courses) }}
|
||||||
</div>
|
</div>
|
||||||
<div class="text-gray-700">
|
<div class="text-gray-700">
|
||||||
{{ __('Published Courses') }}
|
{{ __('Courses') }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -26,10 +26,10 @@
|
|||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<div class="text-xl font-semibold mb-1">
|
<div class="text-xl font-semibold mb-1">
|
||||||
{{ chartDetails.data.users }}
|
{{ formatNumber(chartDetails.data.users) }}
|
||||||
</div>
|
</div>
|
||||||
<div class="text-gray-700">
|
<div class="text-gray-700">
|
||||||
{{ __('Total Signups') }}
|
{{ __('Signups') }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -39,10 +39,10 @@
|
|||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<div class="text-xl font-semibold mb-1">
|
<div class="text-xl font-semibold mb-1">
|
||||||
{{ chartDetails.data.enrollments }}
|
{{ formatNumber(chartDetails.data.enrollments) }}
|
||||||
</div>
|
</div>
|
||||||
<div class="text-gray-700">
|
<div class="text-gray-700">
|
||||||
{{ __('Enrolled Users') }}
|
{{ __('Enrollments') }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -52,10 +52,10 @@
|
|||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<div class="text-xl font-semibold mb-1">
|
<div class="text-xl font-semibold mb-1">
|
||||||
{{ chartDetails.data.completions }}
|
{{ formatNumber(chartDetails.data.completions) }}
|
||||||
</div>
|
</div>
|
||||||
<div class="text-gray-700">
|
<div class="text-gray-700">
|
||||||
{{ __('Courses Completed') }}
|
{{ __('Completions') }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -65,10 +65,10 @@
|
|||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<div class="text-xl font-semibold mb-1">
|
<div class="text-xl font-semibold mb-1">
|
||||||
{{ chartDetails.data.lesson_completions }}
|
{{ formatNumber(chartDetails.data.lesson_completions) }}
|
||||||
</div>
|
</div>
|
||||||
<div class="text-gray-700">
|
<div class="text-gray-700">
|
||||||
{{ __('Lessons Completed') }}
|
{{ __('Milestones') }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -109,6 +109,8 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { createResource, Breadcrumbs } from 'frappe-ui'
|
import { createResource, Breadcrumbs } from 'frappe-ui'
|
||||||
import { computed, inject } from 'vue'
|
import { computed, inject } from 'vue'
|
||||||
|
import { updateDocumentTitle } from '@/utils'
|
||||||
|
import { formatNumber } from '@/utils'
|
||||||
import { Line, Pie } from 'vue-chartjs'
|
import { Line, Pie } from 'vue-chartjs'
|
||||||
import {
|
import {
|
||||||
Chart as ChartJS,
|
Chart as ChartJS,
|
||||||
@@ -196,7 +198,7 @@ const courseCompletion = createResource({
|
|||||||
|
|
||||||
const signupChartOptions = () => {
|
const signupChartOptions = () => {
|
||||||
let options = chartOptions(false)
|
let options = chartOptions(false)
|
||||||
options.plugins.title.text = 'New Signups'
|
options.plugins.title.text = 'Signups'
|
||||||
options.borderColor = '#4563f0'
|
options.borderColor = '#4563f0'
|
||||||
options.backgroundColor = (ctx) => {
|
options.backgroundColor = (ctx) => {
|
||||||
const canvas = ctx.chart.ctx
|
const canvas = ctx.chart.ctx
|
||||||
@@ -212,7 +214,7 @@ const signupChartOptions = () => {
|
|||||||
|
|
||||||
const enrollmentChartOptions = () => {
|
const enrollmentChartOptions = () => {
|
||||||
let options = chartOptions(false)
|
let options = chartOptions(false)
|
||||||
options.plugins.title.text = 'Course Enrollments'
|
options.plugins.title.text = 'Enrollments'
|
||||||
options.borderColor = '#4563f0'
|
options.borderColor = '#4563f0'
|
||||||
options.backgroundColor = (ctx) => {
|
options.backgroundColor = (ctx) => {
|
||||||
const canvas = ctx.chart.ctx
|
const canvas = ctx.chart.ctx
|
||||||
@@ -228,7 +230,7 @@ const enrollmentChartOptions = () => {
|
|||||||
|
|
||||||
const lessonChartOptions = () => {
|
const lessonChartOptions = () => {
|
||||||
let options = chartOptions(false)
|
let options = chartOptions(false)
|
||||||
options.plugins.title.text = 'Lesson Completion'
|
options.plugins.title.text = 'Milestones'
|
||||||
options.borderColor = '#4563f0'
|
options.borderColor = '#4563f0'
|
||||||
options.backgroundColor = (ctx) => {
|
options.backgroundColor = (ctx) => {
|
||||||
const canvas = ctx.chart.ctx
|
const canvas = ctx.chart.ctx
|
||||||
@@ -244,7 +246,7 @@ const lessonChartOptions = () => {
|
|||||||
|
|
||||||
const courseChartOptions = () => {
|
const courseChartOptions = () => {
|
||||||
let options = chartOptions(true)
|
let options = chartOptions(true)
|
||||||
options.plugins.title.text = 'Course Completion'
|
options.plugins.title.text = 'Completions'
|
||||||
options.backgroundColor = ['#4563f0', '#f683ae']
|
options.backgroundColor = ['#4563f0', '#f683ae']
|
||||||
return options
|
return options
|
||||||
}
|
}
|
||||||
@@ -304,4 +306,13 @@ const chartOptions = (isPie) => {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const pageMeta = computed(() => {
|
||||||
|
return {
|
||||||
|
title: 'Statistics',
|
||||||
|
description: 'Statistics of the platform',
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
updateDocumentTitle(pageMeta)
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -56,10 +56,33 @@ const routes = [
|
|||||||
component: () => import('@/pages/Statistics.vue'),
|
component: () => import('@/pages/Statistics.vue'),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: '/user/:userName',
|
path: '/user/:username',
|
||||||
name: 'Profile',
|
name: 'Profile',
|
||||||
component: () => import('@/pages/Profile.vue'),
|
component: () => import('@/pages/Profile.vue'),
|
||||||
props: true,
|
props: true,
|
||||||
|
redirect: { name: 'ProfileAbout' },
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
name: 'ProfileAbout',
|
||||||
|
path: '',
|
||||||
|
component: () => import('@/pages/ProfileAbout.vue'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'ProfileCertificates',
|
||||||
|
path: 'certificates',
|
||||||
|
component: () => import('@/pages/ProfileCertificates.vue'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'ProfileRoles',
|
||||||
|
path: 'roles',
|
||||||
|
component: () => import('@/pages/ProfileRoles.vue'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'ProfileEvaluator',
|
||||||
|
path: 'evaluations',
|
||||||
|
component: () => import('@/pages/ProfileEvaluator.vue'),
|
||||||
|
},
|
||||||
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: '/job-openings',
|
path: '/job-openings',
|
||||||
@@ -74,20 +97,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,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -102,6 +125,33 @@ const routes = [
|
|||||||
component: () => import('@/pages/AssignmentSubmission.vue'),
|
component: () => import('@/pages/AssignmentSubmission.vue'),
|
||||||
props: true,
|
props: true,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: '/certified-participants',
|
||||||
|
name: 'CertifiedParticipants',
|
||||||
|
component: () => import('@/pages/CertifiedParticipants.vue'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/notifications',
|
||||||
|
name: 'Notifications',
|
||||||
|
component: () => import('@/pages/Notifications.vue'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/badges/:badgeName/:email',
|
||||||
|
name: 'Badge',
|
||||||
|
component: () => import('@/pages/Badge.vue'),
|
||||||
|
props: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/quizzes',
|
||||||
|
name: 'Quizzes',
|
||||||
|
component: () => import('@/pages/Quizzes.vue'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/quizzes/:quizID',
|
||||||
|
name: 'QuizCreation',
|
||||||
|
component: () => import('@/pages/QuizCreation.vue'),
|
||||||
|
props: true,
|
||||||
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
let router = createRouter({
|
let router = createRouter({
|
||||||
@@ -110,12 +160,21 @@ let router = createRouter({
|
|||||||
})
|
})
|
||||||
|
|
||||||
router.beforeEach(async (to, from, next) => {
|
router.beforeEach(async (to, from, next) => {
|
||||||
const { userResource } = usersStore()
|
const { userResource, allUsers } = usersStore()
|
||||||
let { isLoggedIn } = sessionStore()
|
let { isLoggedIn } = sessionStore()
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (isLoggedIn) {
|
if (isLoggedIn) {
|
||||||
await userResource.reload()
|
await userResource.promise
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
isLoggedIn &&
|
||||||
|
(to.name == 'Lesson' ||
|
||||||
|
to.name == 'Batch' ||
|
||||||
|
to.name == 'Notifications' ||
|
||||||
|
to.name == 'Badge')
|
||||||
|
) {
|
||||||
|
await allUsers.promise
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
isLoggedIn = false
|
isLoggedIn = false
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import router from '@/router'
|
|||||||
import { ref, computed } from 'vue'
|
import { ref, computed } from 'vue'
|
||||||
|
|
||||||
export const sessionStore = defineStore('lms-session', () => {
|
export const sessionStore = defineStore('lms-session', () => {
|
||||||
let { userResource } = 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) {
|
||||||
|
allUsers.reload()
|
||||||
|
}
|
||||||
const isLoggedIn = computed(() => !!user.value)
|
const isLoggedIn = computed(() => !!user.value)
|
||||||
|
|
||||||
const login = createResource({
|
const login = createResource({
|
||||||
@@ -41,10 +44,27 @@ export const sessionStore = defineStore('lms-session', () => {
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const branding = createResource({
|
||||||
|
url: 'lms.lms.api.get_branding',
|
||||||
|
cache: 'brand',
|
||||||
|
auto: true,
|
||||||
|
onSuccess(data) {
|
||||||
|
document.querySelector("link[rel='icon']").href = data.favicon
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
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,
|
||||||
|
sidebarSettings,
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -9,9 +9,16 @@ export const usersStore = defineStore('lms-users', () => {
|
|||||||
router.push('/login')
|
router.push('/login')
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
auto: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
const allUsers = createResource({
|
||||||
|
url: 'lms.lms.api.get_all_users',
|
||||||
|
cache: ['allUsers'],
|
||||||
})
|
})
|
||||||
|
|
||||||
return {
|
return {
|
||||||
userResource,
|
userResource,
|
||||||
|
allUsers,
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
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()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
218
frontend/src/utils/code.ts
Normal file
218
frontend/src/utils/code.ts
Normal file
@@ -0,0 +1,218 @@
|
|||||||
|
import { Code } from "lucide-vue-next"
|
||||||
|
import { h, createApp } from "vue"
|
||||||
|
|
||||||
|
const DEFAULT_THEMES = ['light', 'dark'];
|
||||||
|
const COMMON_LANGUAGES = {
|
||||||
|
none: 'Auto-detect', apache: 'Apache', bash: 'Bash', cs: 'C#', cpp: 'C++', css: 'CSS', coffeescript: 'CoffeeScript', diff: 'Diff',
|
||||||
|
go: 'Go', html: 'HTML, XML', http: 'HTTP', json: 'JSON', java: 'Java', javascript: 'JavaScript', kotlin: 'Kotlin',
|
||||||
|
less: 'Less', lua: 'Lua', makefile: 'Makefile', markdown: 'Markdown', nginx: 'Nginx', objectivec: 'Objective-C',
|
||||||
|
php: 'PHP', perl: 'Perl', properties: 'Properties', python: 'Python', ruby: 'Ruby', rust: 'Rust', scss: 'SCSS',
|
||||||
|
sql: 'SQL', shell: 'Shell Session', swift: 'Swift', toml: 'TOML, also INI', typescript: 'TypeScript', yaml: 'YAML',
|
||||||
|
plaintext: 'Plaintext'
|
||||||
|
};
|
||||||
|
|
||||||
|
export class CodeBox {
|
||||||
|
api: any;
|
||||||
|
config: { themeName: any; themeURL: any; useDefaultTheme: any; };
|
||||||
|
readOnly: boolean;
|
||||||
|
data: { code: any; language: any; theme: any; };
|
||||||
|
highlightScriptID: string;
|
||||||
|
highlightCSSID: string;
|
||||||
|
codeArea: HTMLDivElement;
|
||||||
|
selectInput: HTMLInputElement;
|
||||||
|
selectDropIcon: HTMLElement;
|
||||||
|
|
||||||
|
constructor({ data, api, config, readOnly }) {
|
||||||
|
this.api = api;
|
||||||
|
this.readOnly = readOnly;
|
||||||
|
this.config = {
|
||||||
|
themeName: config.themeName && typeof config.themeName === 'string' ? config.themeName : '',
|
||||||
|
themeURL: config.themeURL && typeof config.themeURL === 'string' ? config.themeURL : '',
|
||||||
|
useDefaultTheme: (config.useDefaultTheme && typeof config.useDefaultTheme === 'string'
|
||||||
|
&& DEFAULT_THEMES.includes(config.useDefaultTheme.toLowerCase())) ? config.useDefaultTheme : 'dark',
|
||||||
|
};
|
||||||
|
this.data = {
|
||||||
|
code: data.code && typeof data.code === 'string' ? data.code : '',
|
||||||
|
language: data.language && typeof data.language === 'string' ? data.language : 'Auto-detect',
|
||||||
|
theme: data.theme && typeof data.theme === 'string' ? data.theme : this._getThemeURLFromConfig(),
|
||||||
|
};
|
||||||
|
this.highlightScriptID = 'highlightJSScriptElement';
|
||||||
|
this.highlightCSSID = 'highlightJSCSSElement';
|
||||||
|
this.codeArea = document.createElement('div');
|
||||||
|
this.selectInput = document.createElement('input');
|
||||||
|
this.selectDropIcon = document.createElement('i');
|
||||||
|
|
||||||
|
this._injectHighlightJSScriptElement();
|
||||||
|
this._injectHighlightJSCSSElement();
|
||||||
|
|
||||||
|
this.api.listeners.on(window, 'click', this._closeAllLanguageSelects, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
static get isReadOnlySupported() {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
static get sanitize() {
|
||||||
|
return {
|
||||||
|
code: true,
|
||||||
|
language: false,
|
||||||
|
theme: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static get toolbox() {
|
||||||
|
const app = createApp({
|
||||||
|
render: () => h(Code, { size: 24, strokeWidth: 2, color: 'black' }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const div = document.createElement('div');
|
||||||
|
app.mount(div);
|
||||||
|
|
||||||
|
return {
|
||||||
|
title: 'CodeBox',
|
||||||
|
icon: div.innerHTML
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
static get displayInToolbox() {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
static get enableLineBreaks() {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
const codeAreaHolder = document.createElement('pre');
|
||||||
|
const languageSelect = this._createLanguageSelectElement();
|
||||||
|
|
||||||
|
codeAreaHolder.setAttribute('class', 'codeBoxHolder');
|
||||||
|
this.codeArea.setAttribute('class', `codeBoxTextArea ${this.config.useDefaultTheme} ${this.data.language}`);
|
||||||
|
this.codeArea.setAttribute('contenteditable', 'true');
|
||||||
|
this.codeArea.innerHTML = this.data.code;
|
||||||
|
this.api.listeners.on(this.codeArea, 'blur', event => this._highlightCodeArea(event), false);
|
||||||
|
this.api.listeners.on(this.codeArea, 'paste', event => this._handleCodeAreaPaste(event), false);
|
||||||
|
|
||||||
|
codeAreaHolder.appendChild(this.codeArea);
|
||||||
|
!this.readOnly && codeAreaHolder.appendChild(languageSelect);
|
||||||
|
|
||||||
|
return codeAreaHolder;
|
||||||
|
}
|
||||||
|
|
||||||
|
save(blockContent) {
|
||||||
|
return Object.assign(this.data, { code: this.codeArea.innerHTML, theme: this._getThemeURLFromConfig() });
|
||||||
|
}
|
||||||
|
|
||||||
|
validate(savedData) {
|
||||||
|
if (!savedData.code.trim()) return false;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
destroy() {
|
||||||
|
this.api.listeners.off(window, 'click', this._closeAllLanguageSelects, true);
|
||||||
|
this.api.listeners.off(this.codeArea, 'blur', event => this._highlightCodeArea(event), false);
|
||||||
|
this.api.listeners.off(this.codeArea, 'paste', event => this._handleCodeAreaPaste(event), false);
|
||||||
|
this.api.listeners.off(this.selectInput, 'click', event => this._handleSelectInputClick(event), false);
|
||||||
|
}
|
||||||
|
|
||||||
|
_createLanguageSelectElement() {
|
||||||
|
const selectHolder = document.createElement('div');
|
||||||
|
const selectPreview = document.createElement('div');
|
||||||
|
const languages = Object.entries(COMMON_LANGUAGES);
|
||||||
|
|
||||||
|
selectHolder.setAttribute('class', 'codeBoxSelectDiv');
|
||||||
|
|
||||||
|
this.selectDropIcon.setAttribute('class', `codeBoxSelectDropIcon ${this.config.useDefaultTheme}`);
|
||||||
|
this.selectDropIcon.innerHTML = '↓';
|
||||||
|
this.selectInput.setAttribute('class', `codeBoxSelectInput ${this.config.useDefaultTheme}`);
|
||||||
|
this.selectInput.setAttribute('type', 'text');
|
||||||
|
this.selectInput.setAttribute('readonly', 'true');
|
||||||
|
this.selectInput.value = this.data.language;
|
||||||
|
this.api.listeners.on(this.selectInput, 'click', event => this._handleSelectInputClick(event), false);
|
||||||
|
|
||||||
|
selectPreview.setAttribute('class', 'codeBoxSelectPreview');
|
||||||
|
|
||||||
|
languages.forEach(language => {
|
||||||
|
const selectItem = document.createElement('p');
|
||||||
|
selectItem.setAttribute('class', `codeBoxSelectItem ${this.config.useDefaultTheme}`);
|
||||||
|
selectItem.setAttribute('data-key', language[0]);
|
||||||
|
selectItem.textContent = language[1];
|
||||||
|
this.api.listeners.on(selectItem, 'click', event => this._handleSelectItemClick(event, language), false);
|
||||||
|
|
||||||
|
selectPreview.appendChild(selectItem);
|
||||||
|
});
|
||||||
|
|
||||||
|
selectHolder.appendChild(this.selectDropIcon);
|
||||||
|
selectHolder.appendChild(this.selectInput);
|
||||||
|
selectHolder.appendChild(selectPreview);
|
||||||
|
|
||||||
|
return selectHolder;
|
||||||
|
}
|
||||||
|
|
||||||
|
_highlightCodeArea(event) {
|
||||||
|
window.hljs.highlightBlock(this.codeArea);
|
||||||
|
}
|
||||||
|
|
||||||
|
_handleCodeAreaPaste(event) {
|
||||||
|
event.stopPropagation();
|
||||||
|
}
|
||||||
|
|
||||||
|
_handleSelectInputClick(event) {
|
||||||
|
event.target.nextSibling.classList.toggle('codeBoxShow');
|
||||||
|
}
|
||||||
|
|
||||||
|
_handleSelectItemClick(event, language) {
|
||||||
|
event.target.parentNode.parentNode.querySelector('.codeBoxSelectInput').value = language[1];
|
||||||
|
event.target.parentNode.classList.remove('codeBoxShow');
|
||||||
|
this.codeArea.removeAttribute('class');
|
||||||
|
this.data.language = language[0];
|
||||||
|
this.codeArea.setAttribute('class', `codeBoxTextArea ${this.config.useDefaultTheme} ${this.data.language}`);
|
||||||
|
window.hljs.highlightBlock(this.codeArea);
|
||||||
|
}
|
||||||
|
|
||||||
|
_closeAllLanguageSelects() {
|
||||||
|
const selectPreviews = document.querySelectorAll('.codeBoxSelectPreview');
|
||||||
|
for (let i = 0, len = selectPreviews.length; i < len; i++) selectPreviews[i].classList.remove('codeBoxShow');
|
||||||
|
}
|
||||||
|
|
||||||
|
_injectHighlightJSScriptElement() {
|
||||||
|
const highlightJSScriptElement = document.querySelector(`#${this.highlightScriptID}`);
|
||||||
|
const highlightJSScriptURL = 'https://cdn.jsdelivr.net/gh/highlightjs/cdn-release@9.18.1/build/highlight.min.js';
|
||||||
|
if (!highlightJSScriptElement) {
|
||||||
|
const script = document.createElement('script');
|
||||||
|
const head = document.querySelector('head');
|
||||||
|
script.setAttribute('src', highlightJSScriptURL);
|
||||||
|
script.setAttribute('id', this.highlightScriptID);
|
||||||
|
|
||||||
|
if (head) head.appendChild(script);
|
||||||
|
}
|
||||||
|
else highlightJSScriptElement.setAttribute('src', highlightJSScriptURL);
|
||||||
|
}
|
||||||
|
|
||||||
|
_injectHighlightJSCSSElement() {
|
||||||
|
const highlightJSCSSElement = document.querySelector(`#${this.highlightCSSID}`);
|
||||||
|
let highlightJSCSSURL = this._getThemeURLFromConfig();
|
||||||
|
if (!highlightJSCSSElement) {
|
||||||
|
const link = document.createElement('link');
|
||||||
|
const head = document.querySelector('head');
|
||||||
|
link.setAttribute('rel', 'stylesheet');
|
||||||
|
link.setAttribute('href', highlightJSCSSURL);
|
||||||
|
link.setAttribute('id', this.highlightCSSID);
|
||||||
|
|
||||||
|
if (head) head.appendChild(link);
|
||||||
|
}
|
||||||
|
else highlightJSCSSElement.setAttribute('href', highlightJSCSSURL);
|
||||||
|
}
|
||||||
|
|
||||||
|
_getThemeURLFromConfig() {
|
||||||
|
let themeURL = `https://cdn.jsdelivr.net/gh/highlightjs/cdn-release@9.18.1/build/styles/atom-one-${this.config.useDefaultTheme}.min.css`;
|
||||||
|
|
||||||
|
if (this.config.themeName) themeURL = `https://cdn.jsdelivr.net/gh/highlightjs/cdn-release@9.18.1/build/styles/${this.config.themeName}.min.css`;
|
||||||
|
if (this.config.themeURL) themeURL = this.config.themeURL;
|
||||||
|
|
||||||
|
return themeURL;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
export default CodeBox;
|
||||||
32
frontend/src/utils/customEmbed.js
Normal file
32
frontend/src/utils/customEmbed.js
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
import Embed from '@editorjs/embed'
|
||||||
|
import VideoBlock from '@/components/VideoBlock.vue'
|
||||||
|
import { createApp } from 'vue'
|
||||||
|
|
||||||
|
export class CustomEmbed extends Embed {
|
||||||
|
render() {
|
||||||
|
const container = super.render()
|
||||||
|
const { service, source, embed } = this.data
|
||||||
|
|
||||||
|
if (service === 'youtube' || service === 'vimeo') {
|
||||||
|
// Remove the iframe or existing embed content
|
||||||
|
container.innerHTML = ''
|
||||||
|
|
||||||
|
// Create a placeholder element for Vue component
|
||||||
|
const vueContainer = document.createElement('div')
|
||||||
|
vueContainer.setAttribute('data-service', service)
|
||||||
|
vueContainer.setAttribute('data-video-id', this.data.source)
|
||||||
|
|
||||||
|
// Append the Vue placeholder
|
||||||
|
container.appendChild(vueContainer)
|
||||||
|
console.log(source)
|
||||||
|
// Mount the Vue component (using a global Vue instance)
|
||||||
|
const app = createApp(VideoBlock, {
|
||||||
|
file: source,
|
||||||
|
type: 'video/youtube',
|
||||||
|
})
|
||||||
|
app.mount(vueContainer)
|
||||||
|
}
|
||||||
|
|
||||||
|
return container
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,13 +1,16 @@
|
|||||||
import { toast } from 'frappe-ui'
|
import { toast } from 'frappe-ui'
|
||||||
import { useTimeAgo } from '@vueuse/core'
|
import { useTimeAgo } from '@vueuse/core'
|
||||||
import { BookOpen, Users, TrendingUp, Briefcase } from 'lucide-vue-next'
|
|
||||||
import { Quiz } from '@/utils/quiz'
|
import { Quiz } from '@/utils/quiz'
|
||||||
import { Upload } from '@/utils/upload'
|
import { Upload } from '@/utils/upload'
|
||||||
import Header from '@editorjs/header'
|
import Header from '@editorjs/header'
|
||||||
import Paragraph from '@editorjs/paragraph'
|
import Paragraph from '@editorjs/paragraph'
|
||||||
import Embed from '@editorjs/embed'
|
import { CodeBox } from '@/utils/code'
|
||||||
import NestedList from '@editorjs/nested-list'
|
import NestedList from '@editorjs/nested-list'
|
||||||
|
import InlineCode from '@editorjs/inline-code'
|
||||||
import { watch } from 'vue'
|
import { watch } from 'vue'
|
||||||
|
import dayjs from '@/utils/dayjs'
|
||||||
|
import Embed from '@editorjs/embed'
|
||||||
|
import SimpleImage from '@editorjs/simple-image'
|
||||||
|
|
||||||
export function createToast(options) {
|
export function createToast(options) {
|
||||||
toast({
|
toast({
|
||||||
@@ -37,6 +40,12 @@ export function formatTime(timeString) {
|
|||||||
return formattedTime
|
return formattedTime
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function formatNumber(number) {
|
||||||
|
return number.toLocaleString('en-IN', {
|
||||||
|
maximumFractionDigits: 0,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
export function formatNumberIntoCurrency(number, currency) {
|
export function formatNumberIntoCurrency(number, currency) {
|
||||||
if (number) {
|
if (number) {
|
||||||
return number.toLocaleString('en-IN', {
|
return number.toLocaleString('en-IN', {
|
||||||
@@ -71,17 +80,31 @@ 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) {
|
||||||
|
iconClasses =
|
||||||
|
icon == 'check'
|
||||||
|
? 'bg-green-600 text-white rounded-md p-px'
|
||||||
|
: 'bg-red-600 text-white rounded-md p-px'
|
||||||
|
}
|
||||||
createToast({
|
createToast({
|
||||||
title: title,
|
title: title,
|
||||||
text: htmlToText(text),
|
text: htmlToText(text),
|
||||||
icon: icon,
|
icon: icon,
|
||||||
iconClasses:
|
iconClasses: iconClasses,
|
||||||
icon == 'check'
|
|
||||||
? 'bg-green-600 text-white rounded-md p-px'
|
|
||||||
: 'bg-red-600 text-white rounded-md p-px',
|
|
||||||
position: icon == 'check' ? 'bottom-right' : 'top-center',
|
position: icon == 'check' ? 'bottom-right' : 'top-center',
|
||||||
timeout: icon == 'check' ? 5 : 10,
|
timeout: 5,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getImgDimensions(imgSrc) {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
let img = new Image()
|
||||||
|
img.onload = function () {
|
||||||
|
let { width, height } = img
|
||||||
|
resolve({ width, height, ratio: width / height })
|
||||||
|
}
|
||||||
|
img.src = imgSrc
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -114,9 +137,22 @@ export function getEditorTools() {
|
|||||||
header: Header,
|
header: Header,
|
||||||
quiz: Quiz,
|
quiz: Quiz,
|
||||||
upload: Upload,
|
upload: Upload,
|
||||||
|
image: SimpleImage,
|
||||||
paragraph: {
|
paragraph: {
|
||||||
class: Paragraph,
|
class: Paragraph,
|
||||||
inlineToolbar: true,
|
inlineToolbar: true,
|
||||||
|
config: {
|
||||||
|
preserveBlank: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
codeBox: {
|
||||||
|
class: CodeBox,
|
||||||
|
config: {
|
||||||
|
themeURL:
|
||||||
|
'https://cdn.jsdelivr.net/gh/highlightjs/cdn-release@9.18.1/build/styles/dracula.min.css', // Optional
|
||||||
|
themeName: 'atom-one-dark', // Optional
|
||||||
|
useDefaultTheme: 'dark', // Optional. This also determines the background color of the language select drop-down
|
||||||
|
},
|
||||||
},
|
},
|
||||||
list: {
|
list: {
|
||||||
class: NestedList,
|
class: NestedList,
|
||||||
@@ -124,19 +160,113 @@ export function getEditorTools() {
|
|||||||
defaultStyle: 'ordered',
|
defaultStyle: 'ordered',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
inlineCode: {
|
||||||
|
class: InlineCode,
|
||||||
|
shortcut: 'CMD+SHIFT+M',
|
||||||
|
},
|
||||||
embed: {
|
embed: {
|
||||||
class: Embed,
|
class: Embed,
|
||||||
inlineToolbar: false,
|
inlineToolbar: false,
|
||||||
config: {
|
config: {
|
||||||
services: {
|
services: {
|
||||||
youtube: 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: {
|
||||||
|
regex: /(?:http[s]?:\/\/)?(?:www.)?aparat\.com\/v\/([^\/\?\&]+)\/?/,
|
||||||
|
embedUrl:
|
||||||
|
'https://www.aparat.com/video/video/embed/videohash/<%= remote_id %>/vt/frame',
|
||||||
|
html: '<iframe style="margin: 0 auto; width: 100%; height: 25rem;" frameborder="0" scrolling="no" allowtransparency="true"></iframe>',
|
||||||
|
height: 300,
|
||||||
|
width: 600,
|
||||||
|
},
|
||||||
|
github: true,
|
||||||
slides: {
|
slides: {
|
||||||
regex: /https:\/\/docs\.google\.com\/presentation\/d\/e\/([A-Za-z0-9_-]+)\/pub/,
|
regex: /https:\/\/docs\.google\.com\/presentation\/d\/e\/([A-Za-z0-9_-]+)\/pub/,
|
||||||
embedUrl:
|
embedUrl:
|
||||||
'https://docs.google.com/presentation/d/e/<%= remote_id %>/embed',
|
'https://docs.google.com/presentation/d/e/<%= remote_id %>/embed',
|
||||||
html: "<iframe width='100%' height='300' 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: {
|
||||||
|
regex: /https:\/\/drive\.google\.com\/file\/d\/([A-Za-z0-9_-]+)\/view(\?.+)?/,
|
||||||
|
embedUrl:
|
||||||
|
'https://drive.google.com/file/d/<%= remote_id %>/preview',
|
||||||
|
html: "<iframe style='width: 100%; height: 25rem; border: 1px solid #D3D3D3; border-radius: 12px;' frameborder='0' allowfullscreen='true'></iframe>",
|
||||||
|
},
|
||||||
|
docsPublic: {
|
||||||
|
regex: /https:\/\/docs\.google\.com\/document\/d\/([A-Za-z0-9_-]+)\/edit(\?.+)?/,
|
||||||
|
embedUrl:
|
||||||
|
'https://docs.google.com/document/d/<%= remote_id %>/preview',
|
||||||
|
html: "<iframe style='width: 100%; height: 40rem; border: 1px solid #D3D3D3; border-radius: 12px;' frameborder='0' allowfullscreen='true'></iframe>",
|
||||||
|
},
|
||||||
|
sheetsPublic: {
|
||||||
|
regex: /https:\/\/docs\.google\.com\/spreadsheets\/d\/([A-Za-z0-9_-]+)\/edit(\?.+)?/,
|
||||||
|
embedUrl:
|
||||||
|
'https://docs.google.com/spreadsheets/d/<%= remote_id %>/preview',
|
||||||
|
html: "<iframe style='width: 100%; height: 40rem; border: 1px solid #D3D3D3; border-radius: 12px;' frameborder='0' allowfullscreen='true'></iframe>",
|
||||||
|
},
|
||||||
|
slidesPublic: {
|
||||||
|
regex: /https:\/\/docs\.google\.com\/presentation\/d\/([A-Za-z0-9_-]+)\/edit(\?.+)?/,
|
||||||
|
embedUrl:
|
||||||
|
'https://docs.google.com/presentation/d/<%= remote_id %>/embed',
|
||||||
|
html: "<iframe style='width: 100%; height: 30rem; border: 1px solid #D3D3D3; border-radius: 12px; margin: 1rem 0;' frameborder='0' allowfullscreen='true'></iframe>",
|
||||||
|
},
|
||||||
|
codesandbox: {
|
||||||
|
regex: /^https:\/\/codesandbox\.io\/(?:embed\/)?([A-Za-z0-9_-]+)(?:\?[^\/]*)?$/,
|
||||||
|
embedUrl:
|
||||||
|
'https://codesandbox.io/embed/<%= remote_id %>?view=editor+%2B+preview&module=%2Findex.html',
|
||||||
|
html: "<iframe style='width: 100%; height: 500px; border: 0; border-radius: 4px; overflow: hidden;' sandbox='allow-mods allow-forms allow-popups allow-scripts allow-same-origin' frameborder='0' allowfullscreen='true'></iframe>",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -288,26 +418,68 @@ export function getSidebarLinks() {
|
|||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
label: 'Courses',
|
label: 'Courses',
|
||||||
icon: BookOpen,
|
icon: 'BookOpen',
|
||||||
to: 'Courses',
|
to: 'Courses',
|
||||||
activeFor: ['Courses', 'CourseDetail', 'Lesson'],
|
activeFor: [
|
||||||
|
'Courses',
|
||||||
|
'CourseDetail',
|
||||||
|
'Lesson',
|
||||||
|
'CourseForm',
|
||||||
|
'LessonForm',
|
||||||
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'Batches',
|
label: 'Batches',
|
||||||
icon: Users,
|
icon: 'Users',
|
||||||
to: 'Batches',
|
to: 'Batches',
|
||||||
activeFor: ['Batches', 'BatchDetail', 'Batch'],
|
activeFor: ['Batches', 'BatchDetail', 'Batch', 'BatchForm'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Certified Participants',
|
||||||
|
icon: 'GraduationCap',
|
||||||
|
to: 'CertifiedParticipants',
|
||||||
|
activeFor: ['CertifiedParticipants'],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'Jobs',
|
label: 'Jobs',
|
||||||
icon: Briefcase,
|
icon: 'Briefcase',
|
||||||
to: 'Jobs',
|
to: 'Jobs',
|
||||||
activeFor: ['Jobs', 'JobDetail'],
|
activeFor: ['Jobs', 'JobDetail'],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'Statistics',
|
label: 'Statistics',
|
||||||
icon: TrendingUp,
|
icon: 'TrendingUp',
|
||||||
to: 'Statistics',
|
to: 'Statistics',
|
||||||
|
activeFor: ['Statistics'],
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function getFormattedDateRange(
|
||||||
|
startDate,
|
||||||
|
endDate,
|
||||||
|
format = 'DD MMM YYYY'
|
||||||
|
) {
|
||||||
|
if (startDate === endDate) {
|
||||||
|
return dayjs(startDate).format(format)
|
||||||
|
}
|
||||||
|
return `${dayjs(startDate).format(format)} - ${dayjs(endDate).format(
|
||||||
|
format
|
||||||
|
)}`
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getLineStartPosition(string, position) {
|
||||||
|
const charLength = 1
|
||||||
|
let char = ''
|
||||||
|
|
||||||
|
while (char !== '\n' && position > 0) {
|
||||||
|
position = position - charLength
|
||||||
|
char = string.substr(position, charLength)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (char === '\n') {
|
||||||
|
position += 1
|
||||||
|
}
|
||||||
|
|
||||||
|
return position
|
||||||
|
}
|
||||||
|
|||||||
@@ -43,7 +43,6 @@ export class Quiz {
|
|||||||
}
|
}
|
||||||
|
|
||||||
save(blockContent) {
|
save(blockContent) {
|
||||||
console.log(blockContent)
|
|
||||||
return {
|
return {
|
||||||
quiz: this.data.quiz,
|
quiz: this.data.quiz,
|
||||||
}
|
}
|
||||||
|
|||||||
11
frontend/src/utils/scrollContainer.js
Normal file
11
frontend/src/utils/scrollContainer.js
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
export function scrollTo(...options) {
|
||||||
|
if (!options || options.length === 0) return
|
||||||
|
const container = getScrollContainer()
|
||||||
|
if (!container) return
|
||||||
|
container.scrollTo(...options)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getScrollContainer() {
|
||||||
|
// window.scrollContainer is reference to the scroll container in DesktopLayout.vue and MobileLayout.vue
|
||||||
|
return window.scrollContainer
|
||||||
|
}
|
||||||
@@ -1,3 +1,7 @@
|
|||||||
|
import AudioBlock from '@/components/AudioBlock.vue'
|
||||||
|
import VideoBlock from '@/components/VideoBlock.vue'
|
||||||
|
import { createApp } from 'vue'
|
||||||
|
|
||||||
export class Upload {
|
export class Upload {
|
||||||
constructor({ data, api, readOnly }) {
|
constructor({ data, api, readOnly }) {
|
||||||
this.data = data
|
this.data = data
|
||||||
@@ -10,27 +14,33 @@ export class Upload {
|
|||||||
|
|
||||||
render() {
|
render() {
|
||||||
this.wrapper = document.createElement('div')
|
this.wrapper = document.createElement('div')
|
||||||
this.wrapper.innerHTML = this.renderUpload(this.data)
|
this.renderUpload(this.data)
|
||||||
return this.wrapper
|
return this.wrapper
|
||||||
}
|
}
|
||||||
|
|
||||||
renderUpload(file) {
|
renderUpload(file) {
|
||||||
if (this.isVideo(file.file_type)) {
|
if (this.isVideo(file.file_type)) {
|
||||||
return `<video controls width='100%' controls controlsList='nodownload' class="mb-4">
|
const app = createApp(VideoBlock, {
|
||||||
<source src=${encodeURI(file.file_url)} type='video/mp4'>
|
file: file.file_url,
|
||||||
</video>`
|
})
|
||||||
|
app.mount(this.wrapper)
|
||||||
|
return
|
||||||
} else if (this.isAudio(file.file_type)) {
|
} else if (this.isAudio(file.file_type)) {
|
||||||
return `<audio controls width='100%' controls controlsList='nodownload' class="mb-4">
|
const app = createApp(AudioBlock, {
|
||||||
<source src=${encodeURI(file.file_url)} type='audio/mp3'>
|
file: file.file_url,
|
||||||
</audio>`
|
})
|
||||||
} else if (file.file_type == 'pdf') {
|
app.mount(this.wrapper)
|
||||||
return `<iframe src="${encodeURI(
|
return
|
||||||
|
} else if (file.file_type == 'PDF') {
|
||||||
|
this.wrapper.innerHTML = `<iframe src="${encodeURI(
|
||||||
file.file_url
|
file.file_url
|
||||||
)}#toolbar=0" width='100%' height='700px' class="mb-4"></iframe>`
|
)}#toolbar=0" width='100%' height='700px' class="mb-4"></iframe>`
|
||||||
|
return
|
||||||
} else {
|
} else {
|
||||||
return `<img class="mb-4" src=${encodeURI(
|
this.wrapper.innerHTML = `<img class="mb-4" src=${encodeURI(
|
||||||
file.file_url
|
file.file_url
|
||||||
)} width='100%'>`
|
)} width='100%'>`
|
||||||
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
1054
frontend/yarn.lock
1054
frontend/yarn.lock
File diff suppressed because it is too large
Load Diff
@@ -1 +1 @@
|
|||||||
__version__ = "1.0.0"
|
__version__ = "2.1.0"
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user