Compare commits
332 Commits
copy-minor
...
revert-101
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7cdd155f75 | ||
|
|
de8c907c51 | ||
|
|
237ff8db07 | ||
|
|
b5e67a25d2 | ||
|
|
9d2ef4929c | ||
|
|
050084e552 | ||
|
|
86e9739218 | ||
|
|
bd94890da7 | ||
|
|
965f6adb90 | ||
|
|
4979569cf3 | ||
|
|
0fd1cabd60 | ||
|
|
5c21a0532a | ||
|
|
e07aae3fb0 | ||
|
|
65d628ffc0 | ||
|
|
8dd480735c | ||
|
|
bf290bbf0a | ||
|
|
3c9059025b | ||
|
|
4b0413720b | ||
|
|
f8b4ff4bd3 | ||
|
|
3b8ff171f4 | ||
|
|
dec270a10b | ||
|
|
152a339c4e | ||
|
|
395fe700e0 | ||
|
|
ec25e895dc | ||
|
|
e02e4c7ab4 | ||
|
|
676f1a1f0e | ||
|
|
e69cc9af1a | ||
|
|
98b8464e1a | ||
|
|
0170fcc111 | ||
|
|
0be5439e81 | ||
|
|
63f857b8fc | ||
|
|
a3b8ed8f91 | ||
|
|
cdd46667f3 | ||
|
|
2f8acea988 | ||
|
|
75f0e5b9f1 | ||
|
|
ce51129e84 | ||
|
|
86aa8b0a2a | ||
|
|
aeae62a45c | ||
|
|
6b12df44a0 | ||
|
|
a710183bc7 | ||
|
|
ce75422126 | ||
|
|
669316ba14 | ||
|
|
6c18f9a02f | ||
|
|
363edb9a50 | ||
|
|
afbf64170a | ||
|
|
14f36d0c64 | ||
|
|
ceecab395b | ||
|
|
b8eb9fd717 | ||
|
|
230a52f06b | ||
|
|
3e82608d5f | ||
|
|
cf2c2345c3 | ||
|
|
05ebe4b787 | ||
|
|
a744a43d14 | ||
|
|
5abdbfec1f | ||
|
|
0335b3b4d0 | ||
|
|
703fafd6c3 | ||
|
|
b956c4e383 | ||
|
|
d0d1fb2c8c | ||
|
|
d18a6f6e73 | ||
|
|
2994144718 | ||
|
|
62ab853605 | ||
|
|
7f7986d77a | ||
|
|
61f01cc51b | ||
|
|
86af8c6301 | ||
|
|
f1b0fcfbfc | ||
|
|
ab5ce39645 | ||
|
|
685e09ce4b | ||
|
|
8ed4f775e5 | ||
|
|
a3a3085b1f | ||
|
|
ed97640107 | ||
|
|
a9e93a679b | ||
|
|
418c36c09f | ||
|
|
935f7f1f7b | ||
|
|
9a0056b6ca | ||
|
|
cd56da5d85 | ||
|
|
3a097d6b15 | ||
|
|
97d5d853fc | ||
|
|
8adfe247b2 | ||
|
|
afe7df2989 | ||
|
|
cdb028c69c | ||
|
|
9de1bf1020 | ||
|
|
eed330662b | ||
|
|
26db10bbe0 | ||
|
|
93e5cf1c25 | ||
|
|
14230bd588 | ||
|
|
699c821edd | ||
|
|
27ca13ece6 | ||
|
|
6e2376570b | ||
|
|
6820dfc820 | ||
|
|
471e7d9229 | ||
|
|
e0855a2c1b | ||
|
|
b20c4bf197 | ||
|
|
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 | ||
|
|
6ae1d92033 | ||
|
|
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 }}
|
||||||
27
.github/workflows/make_release_pr.yml
vendored
Normal file
27
.github/workflows/make_release_pr.yml
vendored
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
name: Create weekly release
|
||||||
|
on:
|
||||||
|
schedule:
|
||||||
|
# 13:00 UTC -> 7pm IST on every Wednesday
|
||||||
|
- cron: '30 4 * * 3'
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
release:
|
||||||
|
name: Release
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
strategy:
|
||||||
|
fail-fast: false
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- uses: octokit/request-action@v2.x
|
||||||
|
with:
|
||||||
|
route: POST /repos/{owner}/{repo}/pulls
|
||||||
|
owner: frappe
|
||||||
|
repo: lms
|
||||||
|
title: |-
|
||||||
|
"chore: merge 'develop' into 'main'"
|
||||||
|
body: "Automated weekly release"
|
||||||
|
base: main
|
||||||
|
head: develop
|
||||||
|
env:
|
||||||
|
GITHUB_TOKEN: ${{ secrets.RELEASE_TOKEN }}
|
||||||
32
.github/workflows/on_release.yml
vendored
Normal file
32
.github/workflows/on_release.yml
vendored
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
name: Generate Semantic Release
|
||||||
|
on:
|
||||||
|
workflow_dispatch:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
jobs:
|
||||||
|
release:
|
||||||
|
name: Release
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout Entire Repository
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
persist-credentials: false
|
||||||
|
- name: Setup Node.js
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: 20
|
||||||
|
- name: Setup dependencies
|
||||||
|
run: |
|
||||||
|
npm install @semantic-release/git @semantic-release/exec --no-save
|
||||||
|
- name: Create Release
|
||||||
|
env:
|
||||||
|
GH_TOKEN: ${{ secrets.RELEASE_TOKEN }}
|
||||||
|
GITHUB_TOKEN: ${{ secrets.RELEASE_TOKEN }}
|
||||||
|
GIT_AUTHOR_NAME: "Frappe PR Bot"
|
||||||
|
GIT_AUTHOR_EMAIL: "developers@frappe.io"
|
||||||
|
GIT_COMMITTER_NAME: "Frappe PR Bot"
|
||||||
|
GIT_COMMITTER_EMAIL: "developers@frappe.io"
|
||||||
|
run: npx semantic-release
|
||||||
39
.github/workflows/release_notes.yml
vendored
Normal file
39
.github/workflows/release_notes.yml
vendored
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
# This action:
|
||||||
|
#
|
||||||
|
# 1. Generates release notes using github API.
|
||||||
|
# 2. Strips unnecessary info like chore/style etc from notes.
|
||||||
|
# 3. Updates release info.
|
||||||
|
|
||||||
|
name: 'Release Notes'
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_dispatch:
|
||||||
|
inputs:
|
||||||
|
tag_name:
|
||||||
|
description: 'Tag of release like v2.0.0'
|
||||||
|
required: true
|
||||||
|
type: string
|
||||||
|
release:
|
||||||
|
types: [released]
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
regen-notes:
|
||||||
|
name: 'Regenerate release notes'
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Update notes
|
||||||
|
run: |
|
||||||
|
NEW_NOTES=$(gh api --method POST -H "Accept: application/vnd.github+json" /repos/frappe/lms/releases/generate-notes -f tag_name=$RELEASE_TAG \
|
||||||
|
| jq -r '.body' \
|
||||||
|
| sed -E '/^\* (chore|ci|test|docs|style)/d' \
|
||||||
|
| sed -E 's/by @mergify //'
|
||||||
|
)
|
||||||
|
RELEASE_ID=$(gh api -H "Accept: application/vnd.github+json" /repos/frappe/lms/releases/tags/$RELEASE_TAG | jq -r '.id')
|
||||||
|
gh api --method PATCH -H "Accept: application/vnd.github+json" /repos/frappe/lms/releases/$RELEASE_ID -f body="$NEW_NOTES"
|
||||||
|
env:
|
||||||
|
GH_TOKEN: ${{ secrets.RELEASE_TOKEN }}
|
||||||
|
RELEASE_TAG: ${{ github.event.inputs.tag_name || github.event.release.tag_name }}
|
||||||
1
.github/workflows/ui-tests.yml
vendored
1
.github/workflows/ui-tests.yml
vendored
@@ -99,6 +99,7 @@ jobs:
|
|||||||
cd ~/frappe-bench/
|
cd ~/frappe-bench/
|
||||||
bench --site lms.test execute frappe.utils.install.complete_setup_wizard
|
bench --site lms.test execute frappe.utils.install.complete_setup_wizard
|
||||||
bench --site lms.test execute frappe.tests.ui_test_helpers.create_test_user
|
bench --site lms.test execute frappe.tests.ui_test_helpers.create_test_user
|
||||||
|
bench --site lms.test set-password frappe@example.com admin
|
||||||
|
|
||||||
- name: cypress pre-requisites
|
- name: cypress pre-requisites
|
||||||
run: |
|
run: |
|
||||||
|
|||||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -12,3 +12,4 @@ node_modules
|
|||||||
package-lock.json
|
package-lock.json
|
||||||
lms/public/frontend
|
lms/public/frontend
|
||||||
lms/www/lms.html
|
lms/www/lms.html
|
||||||
|
frappe-ui
|
||||||
3
.gitmodules
vendored
Normal file
3
.gitmodules
vendored
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
[submodule "frappe-ui"]
|
||||||
|
path = frappe-ui
|
||||||
|
url = https://github.com/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()
|
||||||
|
.paste("https://www.youtube.com/watch?v=GoDtyItReto"); */
|
||||||
|
|
||||||
|
cy.fixture("Youtube.mov", "base64").then((fileContent) => {
|
||||||
|
cy.get('input[type="file"]').attachFile({
|
||||||
|
fileContent,
|
||||||
|
fileName: "Youtube.mov",
|
||||||
|
mimeType: "image/png",
|
||||||
|
encoding: "base64",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
cy.get("#content .ce-block").type(
|
||||||
|
"This is an extremely big paragraph that is meant to test the UI. This is a very long paragraph. It contains more than once sentence. Its meant to be this long as this is a UI test. Its unbearably long and I'm not sure why I'm typing this much. I'm just going to keep typing until I feel like its long enough. I think its long enough now. I'm going to stop typing now."
|
||||||
);
|
);
|
||||||
cy.get("#lesson-content .ce-toolbar__plus").click();
|
|
||||||
cy.get('#lesson-content [data-item-name="youtube"]').click();
|
|
||||||
cy.get('input[data-fieldname="youtube"]').type("GoDtyItReto");
|
|
||||||
cy.button("Insert").click();
|
|
||||||
cy.wait(1000);
|
|
||||||
cy.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");
|
||||||
|
cy.get("div[contenteditable=true]").invoke(
|
||||||
|
"text",
|
||||||
|
"This is a test discussion. This will check if the UI is working properly."
|
||||||
|
);
|
||||||
|
cy.button("Post").click();
|
||||||
|
});
|
||||||
|
|
||||||
// Enter title
|
// View Discussion
|
||||||
cy.get(".modal .topic-title")
|
cy.wait(500);
|
||||||
.type("Discussion from tests")
|
cy.get("div").contains("Test Discussion").click();
|
||||||
.should("have.value", "Discussion from tests");
|
cy.get("div[contenteditable=true").invoke(
|
||||||
|
"text",
|
||||||
// Enter comment
|
"This is a test comment. This will check if the UI is working properly."
|
||||||
cy.get(".modal .discussions-comment").type(
|
|
||||||
"This is a discussion from the cypress ui tests."
|
|
||||||
);
|
);
|
||||||
|
|
||||||
// Submit
|
cy.get("div").contains(
|
||||||
cy.get(".modal .submit-discussion").click();
|
"This is a test comment. This will check if the UI is working properly."
|
||||||
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"
|
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -35,7 +35,6 @@ bench new-site lms.localhost \
|
|||||||
bench --site lms.localhost install-app lms
|
bench --site lms.localhost install-app lms
|
||||||
bench --site lms.localhost set-config developer_mode 1
|
bench --site lms.localhost set-config developer_mode 1
|
||||||
bench --site lms.localhost clear-cache
|
bench --site lms.localhost clear-cache
|
||||||
bench --site lms.localhost set-config mute_emails 1
|
|
||||||
bench use lms.localhost
|
bench use lms.localhost
|
||||||
|
|
||||||
bench start
|
bench start
|
||||||
|
|||||||
Submodule frappe-ui 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>
|
||||||
|
|||||||
@@ -7,15 +7,60 @@
|
|||||||
class="flex flex-col overflow-hidden"
|
class="flex flex-col overflow-hidden"
|
||||||
:class="isSidebarCollapsed ? 'items-center' : ''"
|
:class="isSidebarCollapsed ? 'items-center' : ''"
|
||||||
>
|
>
|
||||||
<UserDropdown class="p-2" :isCollapsed="isSidebarCollapsed" />
|
<UserDropdown :isCollapsed="isSidebarCollapsed" />
|
||||||
<div class="flex flex-col 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>
|
||||||
|
|||||||
67
frontend/src/components/Apps.vue
Normal file
67
frontend/src/components/Apps.vue
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
<template>
|
||||||
|
<Popover placement="right-start" class="flex w-full">
|
||||||
|
<template #target="{ togglePopover }">
|
||||||
|
<button
|
||||||
|
:class="[
|
||||||
|
'group w-full flex h-7 items-center justify-between rounded px-2 text-base text-gray-800 hover:bg-gray-100',
|
||||||
|
]"
|
||||||
|
@click.prevent="togglePopover()"
|
||||||
|
>
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<LayoutGrid class="size-4 stroke-1.5" />
|
||||||
|
<span class="whitespace-nowrap">
|
||||||
|
{{ __('Apps') }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<ChevronRight class="h-4 w-4 stroke-1.5" />
|
||||||
|
</button>
|
||||||
|
</template>
|
||||||
|
<template #body>
|
||||||
|
<div
|
||||||
|
class="grid grid-cols-3 justify-between mx-3 p-2 rounded-lg border border-gray-100 bg-white shadow-xl"
|
||||||
|
>
|
||||||
|
<div v-for="app in apps.data" key="name">
|
||||||
|
<a
|
||||||
|
:href="app.route"
|
||||||
|
class="flex flex-col gap-1.5 rounded justify-center items-center py-2 px-3 hover:bg-gray-100"
|
||||||
|
>
|
||||||
|
<img class="size-8" :src="app.logo" />
|
||||||
|
<div class="text-sm" @click="app.onClick">
|
||||||
|
{{ app.title }}
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</Popover>
|
||||||
|
</template>
|
||||||
|
<script setup>
|
||||||
|
import { Popover, createResource } from 'frappe-ui'
|
||||||
|
import { LayoutGrid, ChevronRight } from 'lucide-vue-next'
|
||||||
|
|
||||||
|
const apps = createResource({
|
||||||
|
url: 'frappe.apps.get_apps',
|
||||||
|
cache: 'apps',
|
||||||
|
auto: true,
|
||||||
|
transform: (data) => {
|
||||||
|
let _apps = [
|
||||||
|
{
|
||||||
|
name: 'frappe',
|
||||||
|
logo: '/assets/lms/images/desk.png',
|
||||||
|
title: __('Desk'),
|
||||||
|
route: '/app',
|
||||||
|
},
|
||||||
|
]
|
||||||
|
data.map((app) => {
|
||||||
|
if (app.name === 'lms') return
|
||||||
|
_apps.push({
|
||||||
|
name: app.name,
|
||||||
|
logo: app.logo,
|
||||||
|
title: __(app.title),
|
||||||
|
route: app.route,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
return _apps
|
||||||
|
},
|
||||||
|
})
|
||||||
|
</script>
|
||||||
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 items-center mb-3">
|
<div class="flex flex-col space-y-2 mt-auto">
|
||||||
<BookOpen class="h-4 w-4 stroke-1.5 mr-2 text-gray-700" />
|
<DateRange
|
||||||
<span> {{ batch.courses.length }} {{ __('Courses') }} </span>
|
:startDate="batch.start_date"
|
||||||
</div>
|
:endDate="batch.end_date"
|
||||||
<div class="flex items-center mb-3">
|
class="text-sm text-gray-700"
|
||||||
<Calendar class="h-4 w-4 stroke-1.5 mr-2 text-gray-700" />
|
/>
|
||||||
<span>
|
<div class="flex items-center text-sm text-gray-700">
|
||||||
{{ 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)
|
|
||||||
})
|
})
|
||||||
|
setTimeout(() => {
|
||||||
courses.reload()
|
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)
|
|
||||||
})
|
})
|
||||||
|
setTimeout(() => {
|
||||||
students.reload()
|
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 }"
|
||||||
>
|
>
|
||||||
|
<div class="flex flex-col space-y-1">
|
||||||
|
<div>
|
||||||
{{ option.label }}
|
{{ 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>
|
||||||
@@ -13,6 +13,7 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { Star } from 'lucide-vue-next'
|
import { Star } from 'lucide-vue-next'
|
||||||
import { ref } from 'vue'
|
import { ref } from 'vue'
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
id: {
|
id: {
|
||||||
type: String,
|
type: String,
|
||||||
|
|||||||
@@ -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 items-center flex-wrap space-y-1 space-x-1 relative top-4 px-2 w-fit"
|
||||||
|
>
|
||||||
|
<Badge v-if="course.featured" variant="subtle" theme="green" size="md">
|
||||||
|
{{ __('Featured') }}
|
||||||
|
</Badge>
|
||||||
|
<Badge
|
||||||
|
variant="outline"
|
||||||
|
theme="gray"
|
||||||
|
size="md"
|
||||||
|
v-for="tag in course.tags"
|
||||||
>
|
>
|
||||||
<div class="flex relative top-4 left-4 w-fit flex-wrap">
|
|
||||||
<Badge theme="gray" size="md" class="mr-2" v-for="tag in course.tags">
|
|
||||||
{{ tag }}
|
{{ 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,12 +38,20 @@
|
|||||||
{{ 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"
|
||||||
|
:disabled="!allowEdit"
|
||||||
|
item-key="name"
|
||||||
|
group="items"
|
||||||
|
@end="updateOutline"
|
||||||
|
:data-chapter="chapter.name"
|
||||||
|
>
|
||||||
|
<template #item="{ element: lesson }">
|
||||||
|
<div class="outline-lesson pl-8 py-2 pr-4">
|
||||||
<router-link
|
<router-link
|
||||||
:to="{
|
:to="{
|
||||||
name: allowEdit ? 'CreateLesson' : 'Lesson',
|
name: allowEdit ? 'LessonForm' : 'Lesson',
|
||||||
params: {
|
params: {
|
||||||
courseName: courseName,
|
courseName: courseName,
|
||||||
chapterNumber: lesson.number.split('.')[0],
|
chapterNumber: lesson.number.split('.')[0],
|
||||||
@@ -51,7 +59,7 @@
|
|||||||
},
|
},
|
||||||
}"
|
}"
|
||||||
>
|
>
|
||||||
<div class="flex items-center text-sm leading-5">
|
<div class="flex items-center text-sm leading-5 group">
|
||||||
<MonitorPlay
|
<MonitorPlay
|
||||||
v-if="lesson.icon === 'icon-youtube'"
|
v-if="lesson.icon === 'icon-youtube'"
|
||||||
class="h-4 w-4 text-gray-900 stroke-1 mr-2"
|
class="h-4 w-4 text-gray-900 stroke-1 mr-2"
|
||||||
@@ -65,18 +73,24 @@
|
|||||||
class="h-4 w-4 text-gray-900 stroke-1 mr-2"
|
class="h-4 w-4 text-gray-900 stroke-1 mr-2"
|
||||||
/>
|
/>
|
||||||
{{ lesson.title }}
|
{{ lesson.title }}
|
||||||
|
<Trash2
|
||||||
|
v-if="allowEdit"
|
||||||
|
@click.prevent="trashLesson(lesson.name, chapter.name)"
|
||||||
|
class="h-4 w-4 stroke-1.5 text-gray-700 ml-auto invisible group-hover:visible"
|
||||||
|
/>
|
||||||
<Check
|
<Check
|
||||||
v-if="lesson.is_complete"
|
v-if="lesson.is_complete"
|
||||||
class="h-4 w-4 text-green-500 stroke-1.5 ml-2"
|
class="h-4 w-4 text-green-700 ml-2"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</router-link>
|
</router-link>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</template>
|
||||||
<div v-if="allowEdit" class="flex mt-2 pl-8">
|
</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 +120,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 +128,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 +156,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 +167,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 +220,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">
|
||||||
|
<router-link
|
||||||
|
:to="{
|
||||||
|
name: 'Profile',
|
||||||
|
params: { username: review.owner_details.username },
|
||||||
|
}"
|
||||||
|
>
|
||||||
<UserAvatar :user="review.owner_details" :size="'2xl'" />
|
<UserAvatar :user="review.owner_details" :size="'2xl'" />
|
||||||
|
</router-link>
|
||||||
<div class="mx-4">
|
<div class="mx-4">
|
||||||
|
<router-link
|
||||||
|
:to="{
|
||||||
|
name: 'Profile',
|
||||||
|
params: { username: review.owner_details.username },
|
||||||
|
}"
|
||||||
|
>
|
||||||
<span class="text-lg font-medium mr-4">
|
<span class="text-lg font-medium mr-4">
|
||||||
{{ review.owner_details.full_name }}
|
{{ review.owner_details.full_name }}
|
||||||
</span>
|
</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(
|
||||||
{},
|
{},
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<template>
|
<template>
|
||||||
<div>
|
<div>
|
||||||
<Button v-if="!singleThread" class="float-right" @click="openTopicModal()">
|
<Button v-if="!singleThread" class="float-right" @click="openTopicModal()">
|
||||||
{{ __('New {0}').format(title) }}
|
{{ __('New {0}').format(singularize(title)) }}
|
||||||
</Button>
|
</Button>
|
||||||
<div class="text-xl font-semibold">
|
<div class="text-xl font-semibold">
|
||||||
{{ __(title) }}
|
{{ __(title) }}
|
||||||
@@ -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 { singularize, timeAgo } from '../utils'
|
||||||
import { ref, onMounted, inject } from 'vue'
|
import { ref, onMounted, inject } from 'vue'
|
||||||
import DiscussionReplies from '@/components/DiscussionReplies.vue'
|
import DiscussionReplies from '@/components/DiscussionReplies.vue'
|
||||||
import DiscussionModal from '@/components/Modals/DiscussionModal.vue'
|
import DiscussionModal from '@/components/Modals/DiscussionModal.vue'
|
||||||
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,34 +1,30 @@
|
|||||||
<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">
|
||||||
|
<div class="flex w-3/5 md:w-2/5">
|
||||||
<img
|
<img
|
||||||
:src="job.company_logo"
|
:src="job.company_logo"
|
||||||
class="w-12 h-12 rounded-lg object-contain mr-4"
|
class="w-12 h-12 rounded-lg object-contain mr-4"
|
||||||
:alt="job.company_name"
|
:alt="job.company_name"
|
||||||
/>
|
/>
|
||||||
<div>
|
<div>
|
||||||
<div class="text-xl font-semibold mb-2">
|
<div class="font-medium mb-1">
|
||||||
{{ job.job_title }}
|
{{ job.job_title }}
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div class="text-gray-700">
|
||||||
{{ __('posted by') }}
|
|
||||||
<span class="font-medium">
|
|
||||||
{{ 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>
|
||||||
<div>
|
</div>
|
||||||
{{ __('posted on') }}
|
<div class="flex justify-end w-1/5 text-gray-700">
|
||||||
<span class="font-medium">
|
{{ 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') }}
|
{{ dayjs(job.creation).format('DD MMM YYYY') }}
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<!-- <div class="flex flex-col shadow rounded-md p-4 h-full">
|
<!-- <div class="flex flex-col shadow rounded-md p-4 h-full">
|
||||||
|
|||||||
@@ -24,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
|
||||||
|
:to="{
|
||||||
|
name: 'QuizCreation',
|
||||||
|
params: {
|
||||||
|
quizID: 'new',
|
||||||
|
},
|
||||||
|
}"
|
||||||
|
class="self-end ml-2"
|
||||||
|
>
|
||||||
|
<Button>
|
||||||
<template #icon>
|
<template #icon>
|
||||||
<Plus class="h-4 w-4 stroke-1.5" />
|
<Plus class="h-4 w-4 stroke-1.5" />
|
||||||
</template>
|
</template>
|
||||||
</Button>
|
</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,15 +12,20 @@
|
|||||||
{{ __('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"
|
||||||
|
class="flex flex-col border rounded-md h-full p-3"
|
||||||
|
>
|
||||||
<div class="font-semibold text-lg mb-4">
|
<div class="font-semibold text-lg mb-4">
|
||||||
{{ cls.title }}
|
{{ cls.title }}
|
||||||
</div>
|
</div>
|
||||||
|
<div class="mb-4">
|
||||||
|
{{ cls.description }}
|
||||||
|
</div>
|
||||||
<div class="flex items-center mb-2">
|
<div class="flex items-center mb-2">
|
||||||
<Calendar class="w-4 h-4 stroke-1.5" />
|
<Calendar class="w-4 h-4 stroke-1.5" />
|
||||||
<span class="ml-2">
|
<span class="ml-2">
|
||||||
@@ -33,10 +38,7 @@
|
|||||||
{{ formatTime(cls.time) }}
|
{{ formatTime(cls.time) }}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="mb-5">
|
<div class="flex items-center space-x-2 mt-auto">
|
||||||
{{ cls.description }}
|
|
||||||
</div>
|
|
||||||
<div class="flex items-center gap-2">
|
|
||||||
<a
|
<a
|
||||||
:href="cls.start_url"
|
:href="cls.start_url"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
@@ -56,7 +58,6 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
<div v-else class="text-sm italic text-gray-600">
|
<div v-else class="text-sm italic text-gray-600">
|
||||||
{{ __('No live classes scheduled') }}
|
{{ __('No live classes scheduled') }}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
183
frontend/src/components/Members.vue
Normal file
183
frontend/src/components/Members.vue
Normal file
@@ -0,0 +1,183 @@
|
|||||||
|
<template>
|
||||||
|
<div class="text-base p-4">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<div class="font-semibold mb-1">
|
||||||
|
{{ __(label) }}
|
||||||
|
</div>
|
||||||
|
<div class="text-xs text-gray-600">
|
||||||
|
{{ __(description) }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex item-center space-x-2">
|
||||||
|
<FormControl
|
||||||
|
v-model="search"
|
||||||
|
:placeholder="__('Search')"
|
||||||
|
type="text"
|
||||||
|
:debounce="300"
|
||||||
|
/>
|
||||||
|
<Button @click="() => (showForm = true)">
|
||||||
|
<template #icon>
|
||||||
|
<Plus class="h-3 w-3 stroke-1.5" />
|
||||||
|
</template>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="my-4">
|
||||||
|
<!-- Form to add new member -->
|
||||||
|
<div v-if="showForm" class="flex items-center space-x-2 mb-4">
|
||||||
|
<FormControl
|
||||||
|
v-model="member.email"
|
||||||
|
:placeholder="__('Email')"
|
||||||
|
type="email"
|
||||||
|
class="w-full"
|
||||||
|
/>
|
||||||
|
<FormControl
|
||||||
|
v-model="member.first_name"
|
||||||
|
:placeholder="__('First Name')"
|
||||||
|
type="test"
|
||||||
|
class="w-full"
|
||||||
|
/>
|
||||||
|
<Button @click="addMember()" variant="subtle">
|
||||||
|
{{ __('Add') }}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Member list -->
|
||||||
|
<div
|
||||||
|
v-for="member in memberList"
|
||||||
|
class="grid grid-cols-5 grid-flow-row py-2 cursor-pointer"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
@click="openProfile(member.username)"
|
||||||
|
class="flex items-center space-x-2 col-span-2"
|
||||||
|
>
|
||||||
|
<Avatar
|
||||||
|
:image="member.user_image"
|
||||||
|
:label="member.full_name"
|
||||||
|
size="sm"
|
||||||
|
/>
|
||||||
|
<div>
|
||||||
|
{{ member.full_name }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="text-sm text-gray-700 col-span-2">
|
||||||
|
{{ member.name }}
|
||||||
|
</div>
|
||||||
|
<div class="text-sm text-gray-700 justify-self-end">
|
||||||
|
{{ getRole(member.role) }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-if="hasNextPage" class="flex justify-center">
|
||||||
|
<Button variant="solid" @click="members.reload()">
|
||||||
|
<template #prefix>
|
||||||
|
<RefreshCw class="h-3 w-3 stroke-1.5" />
|
||||||
|
</template>
|
||||||
|
{{ __('Load More') }}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { createResource, Avatar, Button, FormControl } from 'frappe-ui'
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
|
import { ref, watch, reactive } from 'vue'
|
||||||
|
import { RefreshCw, Plus } from 'lucide-vue-next'
|
||||||
|
|
||||||
|
const router = useRouter()
|
||||||
|
const show = defineModel('show')
|
||||||
|
const search = ref('')
|
||||||
|
const start = ref(0)
|
||||||
|
const memberList = ref([])
|
||||||
|
const hasNextPage = ref(false)
|
||||||
|
const showForm = ref(false)
|
||||||
|
|
||||||
|
const member = reactive({
|
||||||
|
email: '',
|
||||||
|
first_name: '',
|
||||||
|
})
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
label: {
|
||||||
|
type: String,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
description: {
|
||||||
|
type: String,
|
||||||
|
default: '',
|
||||||
|
},
|
||||||
|
show: {
|
||||||
|
type: Boolean,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const members = createResource({
|
||||||
|
url: 'lms.lms.api.get_members',
|
||||||
|
makeParams: () => {
|
||||||
|
return {
|
||||||
|
search: search.value,
|
||||||
|
start: start.value,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onSuccess(data) {
|
||||||
|
memberList.value = memberList.value.concat(data)
|
||||||
|
start.value = start.value + 20
|
||||||
|
hasNextPage.value = data.length === 20
|
||||||
|
},
|
||||||
|
auto: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
const openProfile = (username) => {
|
||||||
|
show.value = false
|
||||||
|
router.push({
|
||||||
|
name: 'Profile',
|
||||||
|
params: {
|
||||||
|
username: username,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const newMember = createResource({
|
||||||
|
url: 'frappe.client.insert',
|
||||||
|
makeParams(values) {
|
||||||
|
return {
|
||||||
|
doc: {
|
||||||
|
doctype: 'User',
|
||||||
|
first_name: member.first_name,
|
||||||
|
email: member.email,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
},
|
||||||
|
auto: false,
|
||||||
|
onSuccess(data) {
|
||||||
|
show.value = false
|
||||||
|
router.push({
|
||||||
|
name: 'Profile',
|
||||||
|
params: {
|
||||||
|
username: data.username,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const addMember = () => {
|
||||||
|
newMember.reload()
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(search, () => {
|
||||||
|
memberList.value = []
|
||||||
|
start.value = 0
|
||||||
|
members.reload()
|
||||||
|
})
|
||||||
|
|
||||||
|
const getRole = (role) => {
|
||||||
|
const map = {
|
||||||
|
'LMS Student': 'Student',
|
||||||
|
'Course Creator': 'Instructor',
|
||||||
|
Moderator: 'Moderator',
|
||||||
|
'Batch Evaluator': 'Evaluator',
|
||||||
|
}
|
||||||
|
return map[role]
|
||||||
|
}
|
||||||
|
</script>
|
||||||
@@ -4,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,16 +29,59 @@
|
|||||||
<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 },
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
<template>
|
<template>
|
||||||
<Dialog
|
<Dialog
|
||||||
:options="{
|
:options="{
|
||||||
title: props.title,
|
title: singularize(props.title),
|
||||||
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 } from 'vue'
|
||||||
|
import { showToast, singularize } 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">
|
|
||||||
<div class="mb-1.5 text-sm text-gray-600">
|
|
||||||
<Tooltip
|
<Tooltip
|
||||||
class="flex items-center"
|
|
||||||
:text="
|
:text="
|
||||||
__(
|
__(
|
||||||
'Time must be in 24 hour format (HH:mm). Example 11:30 or 22:00'
|
'Time must be in 24 hour format (HH:mm). Example 11:30 or 22:00'
|
||||||
)
|
)
|
||||||
"
|
"
|
||||||
>
|
>
|
||||||
<span>
|
<FormControl
|
||||||
{{ __('Time') }}
|
v-model="liveClass.time"
|
||||||
</span>
|
type="time"
|
||||||
<Info class="stroke-2 w-3 h-3 ml-1" />
|
:label="__('Time')"
|
||||||
|
class="mb-4"
|
||||||
|
/>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</div>
|
<FormControl
|
||||||
<Input v-model="liveClass.time" />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<div class="mb-1.5 text-sm text-gray-600">
|
|
||||||
{{ __('Timezone') }}
|
|
||||||
</div>
|
|
||||||
<Select
|
|
||||||
v-model="liveClass.timezone"
|
v-model="liveClass.timezone"
|
||||||
|
type="select"
|
||||||
:options="getTimezoneOptions()"
|
:options="getTimezoneOptions()"
|
||||||
|
:label="__('Timezone')"
|
||||||
/>
|
/>
|
||||||
</div>
|
</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>
|
</Tooltip>
|
||||||
</div>
|
<FormControl
|
||||||
<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"
|
v-model="liveClass.auto_recording"
|
||||||
|
type="select"
|
||||||
:options="getRecordingOptions()"
|
:options="getRecordingOptions()"
|
||||||
|
:label="__('Auto Recording')"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<FormControl
|
||||||
<div>
|
v-model="liveClass.description"
|
||||||
<div class="mb-1.5 text-sm text-gray-600">
|
type="textarea"
|
||||||
{{ __('Description') }}
|
:label="__('Description')"
|
||||||
</div>
|
/>
|
||||||
<Textarea v-model="liveClass.description" />
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
@@ -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>
|
||||||
279
frontend/src/components/Modals/Settings.vue
Normal file
279
frontend/src/components/Modals/Settings.vue
Normal file
@@ -0,0 +1,279 @@
|
|||||||
|
<template>
|
||||||
|
<Dialog v-model="show" :options="{ size: '3xl' }">
|
||||||
|
<template #body>
|
||||||
|
<div class="flex h-[calc(100vh_-_8rem)]">
|
||||||
|
<div class="flex w-52 shrink-0 flex-col bg-gray-50 p-2">
|
||||||
|
<h1 class="mb-3 px-2 pt-2 text-lg font-semibold">
|
||||||
|
{{ __('Settings') }}
|
||||||
|
</h1>
|
||||||
|
<div v-for="tab in tabs">
|
||||||
|
<div
|
||||||
|
v-if="!tab.hideLabel"
|
||||||
|
class="mb-2 mt-3 flex cursor-pointer gap-1.5 px-1 text-base font-medium text-gray-600 transition-all duration-300 ease-in-out"
|
||||||
|
>
|
||||||
|
<span>{{ __(tab.label) }}</span>
|
||||||
|
</div>
|
||||||
|
<nav class="space-y-1">
|
||||||
|
<SidebarLink
|
||||||
|
v-for="item in tab.items"
|
||||||
|
:link="item"
|
||||||
|
class="w-full"
|
||||||
|
:class="
|
||||||
|
activeTab?.label == item.label
|
||||||
|
? 'bg-white shadow-sm'
|
||||||
|
: 'hover:bg-gray-100'
|
||||||
|
"
|
||||||
|
@click="activeTab = item"
|
||||||
|
/>
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-if="activeTab && data.doc"
|
||||||
|
class="flex flex-1 flex-col overflow-y-auto"
|
||||||
|
>
|
||||||
|
<Members
|
||||||
|
v-if="activeTab.label === 'Members'"
|
||||||
|
:label="activeTab.label"
|
||||||
|
:description="activeTab.description"
|
||||||
|
v-model:show="show"
|
||||||
|
/>
|
||||||
|
<SettingDetails
|
||||||
|
v-else
|
||||||
|
:fields="activeTab.fields"
|
||||||
|
:label="activeTab.label"
|
||||||
|
:description="activeTab.description"
|
||||||
|
:data="data"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</Dialog>
|
||||||
|
</template>
|
||||||
|
<script setup>
|
||||||
|
import { Dialog, createDocumentResource } from 'frappe-ui'
|
||||||
|
import { ref, computed, watch } from 'vue'
|
||||||
|
import SettingDetails from '../SettingDetails.vue'
|
||||||
|
import SidebarLink from '@/components/SidebarLink.vue'
|
||||||
|
import Members from '@/components/Members.vue'
|
||||||
|
|
||||||
|
const show = defineModel()
|
||||||
|
const doctype = ref('LMS Settings')
|
||||||
|
const activeTab = ref(null)
|
||||||
|
|
||||||
|
const data = createDocumentResource({
|
||||||
|
doctype: doctype.value,
|
||||||
|
name: doctype.value,
|
||||||
|
fields: ['*'],
|
||||||
|
cache: doctype.value,
|
||||||
|
auto: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
const tabs = computed(() => {
|
||||||
|
let _tabs = [
|
||||||
|
{
|
||||||
|
label: 'Settings',
|
||||||
|
hideLabel: true,
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
label: 'Members',
|
||||||
|
description: 'Manage the members of your learning system',
|
||||||
|
icon: 'UserRoundPlus',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Payment Gateway',
|
||||||
|
icon: 'DollarSign',
|
||||||
|
description:
|
||||||
|
'Configure the payment gateway and other payment related settings',
|
||||||
|
fields: [
|
||||||
|
{
|
||||||
|
label: 'Razorpay Key',
|
||||||
|
name: 'razorpay_key',
|
||||||
|
type: 'text',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Razorpay Secret',
|
||||||
|
name: 'razorpay_secret',
|
||||||
|
type: 'password',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Default Currency',
|
||||||
|
name: 'default_currency',
|
||||||
|
type: 'Link',
|
||||||
|
doctype: 'Currency',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'Column Break',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Apply GST for India',
|
||||||
|
name: 'apply_gst',
|
||||||
|
type: 'checkbox',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Show USD equivalent amount',
|
||||||
|
name: 'show_usd_equivalent',
|
||||||
|
type: 'checkbox',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Apply rounding on equivalent',
|
||||||
|
name: 'apply_rounding',
|
||||||
|
type: 'checkbox',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Settings',
|
||||||
|
hideLabel: true,
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
label: 'Sidebar',
|
||||||
|
icon: 'PanelLeftIcon',
|
||||||
|
description: 'Customize the sidebar as per your needs',
|
||||||
|
fields: [
|
||||||
|
{
|
||||||
|
label: 'Courses',
|
||||||
|
name: 'courses',
|
||||||
|
type: 'checkbox',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Batches',
|
||||||
|
name: 'batches',
|
||||||
|
type: 'checkbox',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Certified Participants',
|
||||||
|
name: 'certified_participants',
|
||||||
|
type: 'checkbox',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'Column Break',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Jobs',
|
||||||
|
name: 'jobs',
|
||||||
|
type: 'checkbox',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Statistics',
|
||||||
|
name: 'statistics',
|
||||||
|
type: 'checkbox',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Notifications',
|
||||||
|
name: 'notifications',
|
||||||
|
type: 'checkbox',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Settings',
|
||||||
|
hideLabel: true,
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
label: 'Email Templates',
|
||||||
|
icon: 'MailPlus',
|
||||||
|
description: 'Create email templates with the content you want',
|
||||||
|
fields: [
|
||||||
|
{
|
||||||
|
label: 'Batch Confirmation Template',
|
||||||
|
name: 'batch_confirmation_template',
|
||||||
|
doctype: 'Email Template',
|
||||||
|
type: 'Link',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Certification Template',
|
||||||
|
name: 'certification_template',
|
||||||
|
doctype: 'Email Template',
|
||||||
|
type: 'Link',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Assignment Submission Template',
|
||||||
|
name: 'assignment_submission_template',
|
||||||
|
doctype: 'Email Template',
|
||||||
|
type: 'Link',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Settings',
|
||||||
|
hideLabel: true,
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
label: 'Signup',
|
||||||
|
icon: 'LogIn',
|
||||||
|
description:
|
||||||
|
'Customize the signup page to inform users about your terms and policies',
|
||||||
|
fields: [
|
||||||
|
{
|
||||||
|
label: 'Show terms of use on signup',
|
||||||
|
name: 'terms_of_use',
|
||||||
|
type: 'checkbox',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Terms of Use Page',
|
||||||
|
name: 'terms_page',
|
||||||
|
type: 'Link',
|
||||||
|
doctype: 'Web Page',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Show privacy policy on signup',
|
||||||
|
name: 'privacy_policy',
|
||||||
|
type: 'checkbox',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Privacy Policy Page',
|
||||||
|
name: 'privacy_policy_page',
|
||||||
|
type: 'Link',
|
||||||
|
doctype: 'Web Page',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'Column Break',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Show cookie policy on signup',
|
||||||
|
name: 'cookie_policy',
|
||||||
|
type: 'checkbox',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Cookie Policy Page',
|
||||||
|
name: 'cookie_policy_page',
|
||||||
|
type: 'Link',
|
||||||
|
doctype: 'Web Page',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Ask user category during signup',
|
||||||
|
name: 'user_category',
|
||||||
|
type: 'checkbox',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
return _tabs.map((tab) => {
|
||||||
|
tab.items = tab.items.filter((item) => {
|
||||||
|
if (item.condition) {
|
||||||
|
return item.condition()
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
return tab
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
watch(show, () => {
|
||||||
|
if (show.value) {
|
||||||
|
activeTab.value = tabs.value[0].items[0]
|
||||||
|
} else {
|
||||||
|
activeTab.value = null
|
||||||
|
}
|
||||||
|
})
|
||||||
|
</script>
|
||||||
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 = []
|
||||||
|
const type = questionDetails.data.type
|
||||||
|
|
||||||
|
if (type == 'Choices') {
|
||||||
selectedOptions.forEach((value, index) => {
|
selectedOptions.forEach((value, index) => {
|
||||||
if (selectedOptions[index])
|
if (selectedOptions[index])
|
||||||
answers.push(questionDetails.data[`option_${index + 1}`])
|
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,6 +414,8 @@ const checkAnswer = () => {
|
|||||||
},
|
},
|
||||||
auto: true,
|
auto: true,
|
||||||
onSuccess(data) {
|
onSuccess(data) {
|
||||||
|
let type = questionDetails.data.type
|
||||||
|
if (type == 'Choices') {
|
||||||
selectedOptions.forEach((option, index) => {
|
selectedOptions.forEach((option, index) => {
|
||||||
if (option) {
|
if (option) {
|
||||||
showAnswers[index] = option && data[index]
|
showAnswers[index] = option && data[index]
|
||||||
@@ -364,6 +425,9 @@ const checkAnswer = () => {
|
|||||||
showAnswers[index] = undefined
|
showAnswers[index] = undefined
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
} else {
|
||||||
|
showAnswers.push(data)
|
||||||
|
}
|
||||||
addToLocalStorage()
|
addToLocalStorage()
|
||||||
if (!quiz.data.show_answers) {
|
if (!quiz.data.show_answers) {
|
||||||
resetQuestion()
|
resetQuestion()
|
||||||
@@ -375,8 +439,8 @@ const checkAnswer = () => {
|
|||||||
const addToLocalStorage = () => {
|
const addToLocalStorage = () => {
|
||||||
let quizData = JSON.parse(localStorage.getItem(quiz.data.title))
|
let quizData = JSON.parse(localStorage.getItem(quiz.data.title))
|
||||||
let questionData = {
|
let questionData = {
|
||||||
question_index: activeQuestion.value,
|
question_name: currentQuestion.value,
|
||||||
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>
|
||||||
|
|||||||
96
frontend/src/components/SettingDetails.vue
Normal file
96
frontend/src/components/SettingDetails.vue
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
<template>
|
||||||
|
<div class="flex flex-col justify-between h-full p-4">
|
||||||
|
<div>
|
||||||
|
<div class="font-semibold mb-1">
|
||||||
|
{{ __(label) }}
|
||||||
|
</div>
|
||||||
|
<div class="text-xs text-gray-600">
|
||||||
|
{{ __(description) }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex space-x-8 my-5">
|
||||||
|
<div v-for="(column, index) in columns" :key="index">
|
||||||
|
<div class="flex flex-col space-y-4 w-60">
|
||||||
|
<div v-for="field in column">
|
||||||
|
<Link
|
||||||
|
v-if="field.type == 'Link'"
|
||||||
|
v-model="field.value"
|
||||||
|
:doctype="field.doctype"
|
||||||
|
:label="field.label"
|
||||||
|
/>
|
||||||
|
<FormControl
|
||||||
|
v-else
|
||||||
|
:key="field.name"
|
||||||
|
v-model="field.value"
|
||||||
|
:label="field.label"
|
||||||
|
:type="field.type"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-row-reverse mt-auto">
|
||||||
|
<Button variant="solid" :loading="data.save.loading" @click="update">
|
||||||
|
{{ __('Update') }}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { FormControl, Button } from 'frappe-ui'
|
||||||
|
import { computed } from 'vue'
|
||||||
|
import Link from '@/components/Controls/Link.vue'
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
fields: {
|
||||||
|
type: Array,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
data: {
|
||||||
|
type: Object,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
label: {
|
||||||
|
type: String,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
description: {
|
||||||
|
type: String,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const columns = computed(() => {
|
||||||
|
const cols = []
|
||||||
|
let currentColumn = []
|
||||||
|
|
||||||
|
props.fields.forEach((field) => {
|
||||||
|
if (field.type === 'Column Break') {
|
||||||
|
if (currentColumn.length > 0) {
|
||||||
|
cols.push(currentColumn)
|
||||||
|
currentColumn = []
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (field.type == 'checkbox') {
|
||||||
|
field.value = props.data.doc[field.name] ? true : false
|
||||||
|
} else {
|
||||||
|
field.value = props.data.doc[field.name]
|
||||||
|
}
|
||||||
|
currentColumn.push(field)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
if (currentColumn.length > 0) {
|
||||||
|
cols.push(currentColumn)
|
||||||
|
}
|
||||||
|
|
||||||
|
return cols
|
||||||
|
})
|
||||||
|
|
||||||
|
const update = () => {
|
||||||
|
props.fields.forEach((f) => {
|
||||||
|
props.data.doc[f.name] = f.value
|
||||||
|
})
|
||||||
|
props.data.save.submit()
|
||||||
|
}
|
||||||
|
</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() {
|
||||||
|
if (router.hasRoute(props.link.to)) {
|
||||||
router.push({ name: props.link.to })
|
router.push({ name: props.link.to })
|
||||||
|
} else if (props.link.to) {
|
||||||
|
window.location.href = `/${props.link.to}`
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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>
|
||||||
|
<Tooltip :text="user.full_name">
|
||||||
<Avatar
|
<Avatar
|
||||||
class="avatar border border-gray-300"
|
class="avatar border border-gray-300 cursor-auto"
|
||||||
v-if="user"
|
v-if="user"
|
||||||
:label="user.full_name"
|
:label="user.full_name"
|
||||||
:image="user.user_image"
|
:image="user.user_image"
|
||||||
:size="size"
|
:size="size"
|
||||||
v-bind="$attrs"
|
v-bind="$attrs"
|
||||||
/>
|
/>
|
||||||
|
</Tooltip>
|
||||||
</template>
|
</template>
|
||||||
<script setup>
|
<script setup>
|
||||||
import { Avatar } from 'frappe-ui'
|
import { Avatar, Tooltip } from 'frappe-ui'
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
user: {
|
user: {
|
||||||
type: Object,
|
type: Object,
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<template>
|
<template>
|
||||||
<Dropdown :options="userDropdownOptions">
|
<Dropdown class="p-2" :options="userDropdownOptions">
|
||||||
<template v-slot="{ open }">
|
<template v-slot="{ open }">
|
||||||
<button
|
<button
|
||||||
class="flex h-12 py-2 items-center rounded-md duration-300 ease-in-out"
|
class="flex h-12 py-2 items-center rounded-md duration-300 ease-in-out"
|
||||||
@@ -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
|
||||||
@@ -48,18 +56,37 @@
|
|||||||
</button>
|
</button>
|
||||||
</template>
|
</template>
|
||||||
</Dropdown>
|
</Dropdown>
|
||||||
|
<SettingsModal
|
||||||
|
v-if="userResource.data?.is_moderator"
|
||||||
|
v-model="showSettingsModal"
|
||||||
|
/>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import LMSLogo from '@/components/Icons/LMSLogo.vue'
|
import LMSLogo from '@/components/Icons/LMSLogo.vue'
|
||||||
import { sessionStore } from '@/stores/session'
|
import { sessionStore } from '@/stores/session'
|
||||||
import { Dropdown, createResource } from 'frappe-ui'
|
import { Dropdown } from 'frappe-ui'
|
||||||
import { ChevronDown, LogIn, LogOut, User } from 'lucide-vue-next'
|
import Apps from '@/components/Apps.vue'
|
||||||
|
import {
|
||||||
|
ChevronDown,
|
||||||
|
LogIn,
|
||||||
|
LogOut,
|
||||||
|
User,
|
||||||
|
ArrowRightLeft,
|
||||||
|
Settings,
|
||||||
|
} 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'
|
||||||
|
import { ref, markRaw } from 'vue'
|
||||||
|
import SettingsModal from '@/components/Modals/Settings.vue'
|
||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
const showSettingsModal = ref(false)
|
||||||
|
const { logout, branding } = sessionStore()
|
||||||
|
let { userResource } = usersStore()
|
||||||
|
let { isLoggedIn } = sessionStore()
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
isCollapsed: {
|
isCollapsed: {
|
||||||
type: Boolean,
|
type: Boolean,
|
||||||
@@ -67,28 +94,36 @@ 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
|
||||||
},
|
},
|
||||||
}, */
|
},
|
||||||
|
{
|
||||||
|
component: markRaw(Apps),
|
||||||
|
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: Settings,
|
||||||
|
label: 'Settings',
|
||||||
|
onClick: () => {
|
||||||
|
showSettingsModal.value = true
|
||||||
|
},
|
||||||
|
condition: () => {
|
||||||
|
return userResource.data?.is_moderator
|
||||||
|
},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
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>
|
</div>
|
||||||
<div class="flex items-center mb-6">
|
<CourseInstructors :instructors="batch.data.instructors" />
|
||||||
|
</div>
|
||||||
|
<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>
|
</div>
|
||||||
<div class="grid grid-cols-[60%,20%] gap-20 mt-10">
|
<CourseInstructors :instructors="batch.data.instructors" />
|
||||||
<div class="">
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="grid lg:grid-cols-[60%,20%] gap-4 lg:gap-20 mt-10">
|
||||||
|
<div class="order-2 lg:order-none">
|
||||||
<div
|
<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,31 +8,35 @@
|
|||||||
{{ __('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')"
|
||||||
/>
|
/>
|
||||||
|
<FormControl
|
||||||
|
v-model="batch.allow_self_enrollment"
|
||||||
|
type="checkbox"
|
||||||
|
:label="__('Allow self enrollment')"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="mb-4">
|
||||||
|
<div>
|
||||||
<FileUploader
|
<FileUploader
|
||||||
v-if="!batch.image"
|
v-if="!batch.image"
|
||||||
class="mt-4"
|
class="mt-4"
|
||||||
@@ -40,19 +44,15 @@
|
|||||||
:validateFile="validateFile"
|
:validateFile="validateFile"
|
||||||
@success="(file) => saveImage(file)"
|
@success="(file) => saveImage(file)"
|
||||||
>
|
>
|
||||||
<template
|
<template v-slot="{ file, progress, uploading, openFileSelector }">
|
||||||
v-slot="{ file, progress, uploading, openFileSelector }"
|
|
||||||
>
|
|
||||||
<div class="mb-4">
|
<div class="mb-4">
|
||||||
<Button @click="openFileSelector" :loading="uploading">
|
<Button @click="openFileSelector" :loading="uploading">
|
||||||
{{
|
{{ uploading ? `Uploading ${progress}%` : 'Upload an image' }}
|
||||||
uploading ? `Uploading ${progress}%` : 'Upload an image'
|
|
||||||
}}
|
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</FileUploader>
|
</FileUploader>
|
||||||
<div v-else class="mt-4">
|
<div v-else class="mb-4">
|
||||||
<div class="text-xs text-gray-600 mb-1">
|
<div class="text-xs text-gray-600 mb-1">
|
||||||
{{ __('Meta Image') }}
|
{{ __('Meta Image') }}
|
||||||
</div>
|
</div>
|
||||||
@@ -75,9 +75,19 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<MultiSelect
|
||||||
|
v-model="instructors"
|
||||||
|
doctype="User"
|
||||||
|
:label="__('Instructors')"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<div class="mb-4">
|
||||||
<div class="container border-b mb-5">
|
<FormControl
|
||||||
|
v-model="batch.description"
|
||||||
|
:label="__('Description')"
|
||||||
|
type="textarea"
|
||||||
|
class="my-4"
|
||||||
|
/>
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-sm text-gray-600 mb-1">
|
<label class="block text-sm text-gray-600 mb-1">
|
||||||
{{ __('Batch Details') }}
|
{{ __('Batch Details') }}
|
||||||
@@ -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)
|
||||||
})
|
})
|
||||||
let checkboxes = ['published', 'paid_batch']
|
} else if (Object.hasOwn(batch, key)) batch[key] = data[key]
|
||||||
|
})
|
||||||
|
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,
|
||||||
|
})
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const tabIndex = ref(0)
|
||||||
|
let tabs
|
||||||
|
|
||||||
|
const makeTabs = computed(() => {
|
||||||
|
tabs = []
|
||||||
|
addToTabs('Upcoming')
|
||||||
|
|
||||||
if (user.data?.is_moderator) {
|
if (user.data?.is_moderator) {
|
||||||
tabs.push({
|
addToTabs('Archived')
|
||||||
label: 'Archived',
|
addToTabs('Private')
|
||||||
batches: computed(() => batches.data?.archived),
|
|
||||||
count: computed(() => batches.data?.archived?.length),
|
|
||||||
})
|
|
||||||
tabs.push({
|
|
||||||
label: 'Private',
|
|
||||||
batches: computed(() => batches.data?.private),
|
|
||||||
count: computed(() => batches.data?.private?.length),
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (user.data) {
|
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: 'Enrolled',
|
label,
|
||||||
batches: computed(() => batches.data?.enrolled),
|
batches: computed(() => batches),
|
||||||
count: computed(() => batches.data?.enrolled?.length),
|
count: computed(() => batches.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">
|
||||||
|
<div
|
||||||
|
v-if="user.data?.is_moderator"
|
||||||
|
class="flex flex-col space-y-3"
|
||||||
|
>
|
||||||
<FormControl
|
<FormControl
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
v-model="course.published"
|
v-model="course.published"
|
||||||
:label="__('Published')"
|
:label="__('Published')"
|
||||||
/>
|
/>
|
||||||
|
<FormControl
|
||||||
|
v-model="course.published_on"
|
||||||
|
:label="__('Published On')"
|
||||||
|
type="date"
|
||||||
|
class="mb-5"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-col space-y-3">
|
||||||
<FormControl
|
<FormControl
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
v-model="course.upcoming"
|
v-model="course.upcoming"
|
||||||
:label="__('Upcoming')"
|
:label="__('Upcoming')"
|
||||||
/>
|
/>
|
||||||
|
<FormControl
|
||||||
|
type="checkbox"
|
||||||
|
v-model="course.featured"
|
||||||
|
:label="__('Featured')"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-col space-y-3">
|
||||||
<FormControl
|
<FormControl
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
v-model="course.disable_self_learning"
|
v-model="course.disable_self_learning"
|
||||||
:label="__('Disable Self Enrollment')"
|
:label="__('Disable Self Enrollment')"
|
||||||
/>
|
/>
|
||||||
|
<FormControl
|
||||||
|
type="checkbox"
|
||||||
|
v-model="course.enable_certification"
|
||||||
|
:label="__('Completion Certificate')"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div 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 text-gray-600" 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',
|
const makeTabs = computed(() => {
|
||||||
courses: computed(() => courses.data?.live || []),
|
tabs = []
|
||||||
count: computed(() => courses.data?.live?.length),
|
addToTabs('Live')
|
||||||
},
|
addToTabs('New')
|
||||||
{
|
addToTabs('Upcoming')
|
||||||
label: 'Upcoming',
|
|
||||||
courses: computed(() => courses.data?.upcoming),
|
|
||||||
count: computed(() => courses.data?.upcoming?.length),
|
|
||||||
},
|
|
||||||
]
|
|
||||||
|
|
||||||
if (user.data) {
|
if (user.data) {
|
||||||
tabs.push({
|
addToTabs('Enrolled')
|
||||||
label: 'Enrolled',
|
|
||||||
courses: computed(() => courses.data?.enrolled),
|
|
||||||
count: computed(() => courses.data?.enrolled?.length),
|
|
||||||
})
|
|
||||||
|
|
||||||
if (
|
if (
|
||||||
user.data.is_moderator ||
|
user.data.is_moderator ||
|
||||||
user.data.is_instructor ||
|
user.data.is_instructor ||
|
||||||
courses.data?.created?.length
|
courses.data?.created?.length
|
||||||
) {
|
) {
|
||||||
tabs.push({
|
addToTabs('Created')
|
||||||
label: 'Created',
|
|
||||||
courses: computed(() => courses.data?.created),
|
|
||||||
count: computed(() => courses.data?.created?.length),
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (user.data.is_moderator) {
|
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: 'Under Review',
|
label,
|
||||||
courses: computed(() => courses.data?.under_review),
|
courses: computed(() => courses),
|
||||||
count: computed(() => courses.data?.under_review?.length),
|
count: computed(() => courses.length),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const getCourses = (type) => {
|
||||||
|
if (searchQuery.value) {
|
||||||
|
let query = searchQuery.value.toLowerCase()
|
||||||
|
return courses.data[type].filter(
|
||||||
|
(course) =>
|
||||||
|
course.title.toLowerCase().includes(query) ||
|
||||||
|
course.short_introduction.toLowerCase().includes(query) ||
|
||||||
|
course.tags.filter((tag) => tag.toLowerCase().includes(query)).length
|
||||||
|
)
|
||||||
|
}
|
||||||
|
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') }}
|
|
||||||
<span class="font-medium">{{
|
|
||||||
dayjs(job.data.creation).format('DD MMM YYYY')
|
|
||||||
}}</span>
|
|
||||||
</div>
|
|
||||||
<div class="flex items-center mt-2">
|
|
||||||
<Badge :label="job.data.type" theme="green" size="lg" />
|
|
||||||
<Badge
|
|
||||||
:label="job.data.location"
|
|
||||||
theme="gray"
|
|
||||||
size="lg"
|
|
||||||
class="ml-4"
|
|
||||||
>
|
>
|
||||||
<template #prefix>
|
<div class="flex items-center space-x-2">
|
||||||
|
<Building2 class="h-4 w-4 stroke-1.5" />
|
||||||
|
<span>{{ job.data.company_name }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center space-x-2">
|
||||||
<MapPin class="h-4 w-4 stroke-1.5" />
|
<MapPin class="h-4 w-4 stroke-1.5" />
|
||||||
</template>
|
<span>{{ job.data.location }}</span>
|
||||||
</Badge>
|
</div>
|
||||||
|
<div class="flex items-center space-x-2">
|
||||||
|
<ClipboardType class="h-4 w-4 stroke-1.5" />
|
||||||
|
<span>{{ job.data.type }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center space-x-2">
|
||||||
|
<CalendarDays class="h-4 w-4 stroke-1.5" />
|
||||||
|
<span>
|
||||||
|
{{ dayjs(job.data.creation).format('DD MMM YYYY') }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-if="applicationCount.data"
|
||||||
|
class="flex items-center space-x-2"
|
||||||
|
>
|
||||||
|
<SquareUserRound class="h-4 w-4 stroke-1.5" />
|
||||||
|
<span
|
||||||
|
>{{ applicationCount.data }}
|
||||||
|
{{ __('applications received') }}</span
|
||||||
|
>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -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
|
||||||
<CourseOutline :courseName="courseName" :key="chapterNumber" />
|
:courseName="courseName"
|
||||||
|
:key="chapterNumber"
|
||||||
|
:getProgress="lesson.data.membership ? true : false"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<script setup>
|
<script setup>
|
||||||
import { createResource, Breadcrumbs, Button } from 'frappe-ui'
|
import { createResource, Breadcrumbs, Button } from 'frappe-ui'
|
||||||
import { computed, watch, 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,24 +242,28 @@ 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
|
||||||
|
if (document.getElementById(holder))
|
||||||
document.getElementById(holder).innerHTML = ''
|
document.getElementById(holder).innerHTML = ''
|
||||||
return new EditorJS({
|
return new EditorJS({
|
||||||
holder: holder,
|
holder: holder,
|
||||||
@@ -256,21 +274,11 @@ 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',
|
||||||
@@ -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++
|
||||||
|
if (timer.value == 30) {
|
||||||
|
clearInterval(timerInterval)
|
||||||
|
markProgress()
|
||||||
|
}
|
||||||
|
}, 1000)
|
||||||
|
}
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
clearInterval(timerInterval)
|
||||||
|
})
|
||||||
|
|
||||||
|
const checkIfDiscussionsAllowed = () => {
|
||||||
|
let quizPresent = false
|
||||||
|
JSON.parse(lesson.data?.content)?.blocks?.forEach((block) => {
|
||||||
|
if (block.type === 'quiz') quizPresent = true
|
||||||
|
})
|
||||||
|
|
||||||
|
if (
|
||||||
|
!quizPresent &&
|
||||||
|
(lesson.data?.membership ||
|
||||||
user.data?.is_moderator ||
|
user.data?.is_moderator ||
|
||||||
user.data?.is_instructor
|
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,6 +144,14 @@ 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
|
||||||
|
addLessonContent(data)
|
||||||
|
addInstructorNotes(data)
|
||||||
|
enableAutoSave()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const addLessonContent = (data) => {
|
||||||
editor.value.isReady.then(() => {
|
editor.value.isReady.then(() => {
|
||||||
if (data.lesson.content) {
|
if (data.lesson.content) {
|
||||||
editor.value.render(JSON.parse(data.lesson.content))
|
editor.value.render(JSON.parse(data.lesson.content))
|
||||||
@@ -149,11 +162,12 @@ const lessonDetails = createResource({
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const addInstructorNotes = (data) => {
|
||||||
instructorEditor.value.isReady.then(() => {
|
instructorEditor.value.isReady.then(() => {
|
||||||
if (data.lesson.instructor_content) {
|
if (data.lesson.instructor_content) {
|
||||||
instructorEditor.value.render(
|
instructorEditor.value.render(JSON.parse(data.lesson.instructor_content))
|
||||||
JSON.parse(data.lesson.instructor_content)
|
|
||||||
)
|
|
||||||
} else if (data.lesson.instructor_notes) {
|
} else if (data.lesson.instructor_notes) {
|
||||||
let blocks = convertToJSON(data.lesson)
|
let blocks = convertToJSON(data.lesson)
|
||||||
instructorEditor.value.render({
|
instructorEditor.value.render({
|
||||||
@@ -162,7 +176,15 @@ const lessonDetails = createResource({
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
},
|
|
||||||
|
const enableAutoSave = () => {
|
||||||
|
autoSaveInterval = setInterval(() => {
|
||||||
|
saveLesson()
|
||||||
|
}, 5000)
|
||||||
|
}
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
clearInterval(autoSaveInterval)
|
||||||
})
|
})
|
||||||
|
|
||||||
const newLessonResource = createResource({
|
const newLessonResource = createResource({
|
||||||
@@ -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;
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user