Compare commits
540 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
af0cc7126b | ||
|
|
ed79ea536b | ||
|
|
b3d0aecd14 | ||
|
|
5f43e67c0b | ||
|
|
49a765a9a6 | ||
|
|
4d82bc86e8 | ||
|
|
8fe02b83b8 | ||
|
|
9c9075606b | ||
|
|
9cdeaebb47 | ||
|
|
a9cb52c68b | ||
|
|
f33e950e83 | ||
|
|
9c9b5963fe | ||
|
|
1597054cc9 | ||
|
|
deba6aa845 | ||
|
|
2d8ba3b84e | ||
|
|
e56b28abad | ||
|
|
eb350c5a20 | ||
|
|
961d5ec77b | ||
|
|
fa566514aa | ||
|
|
6e97449bf7 | ||
|
|
016dafb3c3 | ||
|
|
675bcc8956 | ||
|
|
aba4c034fc | ||
|
|
c76d8c582f | ||
|
|
f1cb0e6f3c | ||
|
|
d296687456 | ||
|
|
5b68001c94 | ||
|
|
736d79b8c9 | ||
|
|
98c0bd5f3e | ||
|
|
8b1d9bb5a9 | ||
|
|
289a0f9122 | ||
|
|
3cd08c80c8 | ||
|
|
3d82c36250 | ||
|
|
9b9af0215a | ||
|
|
2e4cf02737 | ||
|
|
438e9e1c47 | ||
|
|
36ded70eef | ||
|
|
ba78a15a1f | ||
|
|
93061194bb | ||
|
|
6d41e4e552 | ||
|
|
3b06968d0a | ||
|
|
fc81f1aa26 | ||
|
|
59d8848125 | ||
|
|
a067695f71 | ||
|
|
be870e8145 | ||
|
|
8a17dca351 | ||
|
|
1c9f636ad1 | ||
|
|
008cc66cdd | ||
|
|
b6bf9c0032 | ||
|
|
d295898674 | ||
|
|
4fdca4691a | ||
|
|
7c055af496 | ||
|
|
60a3da283e | ||
|
|
576258ec6e | ||
|
|
01120fbc48 | ||
|
|
ad07f883b5 | ||
|
|
bb9b179e05 | ||
|
|
11a9bff57d | ||
|
|
e18f0c9dad | ||
|
|
41ad3d00de | ||
|
|
b74c1670ca | ||
|
|
33c76e842f | ||
|
|
35a7cce283 | ||
|
|
e0f569c382 | ||
|
|
d8ab88be28 | ||
|
|
04552bdef6 | ||
|
|
ad5bf89b35 | ||
|
|
88b38dfd83 | ||
|
|
75e9ca395f | ||
|
|
6fb206cc4e | ||
|
|
62cb198492 | ||
|
|
9609329f01 | ||
|
|
c93808af94 | ||
|
|
58866260ec | ||
|
|
e6157ff411 | ||
|
|
8cca8920ee | ||
|
|
ab039dbd46 | ||
|
|
9853ab3fd9 | ||
|
|
dc2bf9f13e | ||
|
|
7c90ca4040 | ||
|
|
75a90e1f39 | ||
|
|
bc4b17cc3d | ||
|
|
8c454a333e | ||
|
|
cef4b70182 | ||
|
|
3cda563583 | ||
|
|
545326a02a | ||
|
|
14ce5d7e23 | ||
|
|
b6422d1046 | ||
|
|
7196bbe221 | ||
|
|
bed16c3726 | ||
|
|
d18ca232e3 | ||
|
|
d1200d0fa9 | ||
|
|
d1c88b306f | ||
|
|
7f2723f9cb | ||
|
|
8df4bef71a | ||
|
|
aa87622606 | ||
|
|
b91339fe28 | ||
|
|
17d4973ab8 | ||
|
|
3c12548420 | ||
|
|
20c10f1645 | ||
|
|
a7843e0e3a | ||
|
|
169ea4385f | ||
|
|
9549f3a3ed | ||
|
|
ba66c2549f | ||
|
|
76c3e630cc | ||
|
|
7a0b952638 | ||
|
|
5966a3edad | ||
|
|
d44c7cd9fc | ||
|
|
46553987ac | ||
|
|
45725f1f6e | ||
|
|
58369ba65e | ||
|
|
5ce67dda2e | ||
|
|
237ff8db07 | ||
|
|
7da608ed44 | ||
|
|
60f2e86b42 | ||
|
|
b5e67a25d2 | ||
|
|
9d2ef4929c | ||
|
|
050084e552 | ||
|
|
86e9739218 | ||
|
|
bd94890da7 | ||
|
|
965f6adb90 | ||
|
|
4979569cf3 | ||
|
|
5c21a0532a | ||
|
|
a2025c0571 | ||
|
|
e07aae3fb0 | ||
|
|
65d628ffc0 | ||
|
|
bf290bbf0a | ||
|
|
3c9059025b | ||
|
|
4b0413720b | ||
|
|
f8b4ff4bd3 | ||
|
|
3b8ff171f4 | ||
|
|
dec270a10b | ||
|
|
152a339c4e | ||
|
|
395fe700e0 | ||
|
|
ec25e895dc | ||
|
|
e02e4c7ab4 | ||
|
|
e69cc9af1a | ||
|
|
98b8464e1a | ||
|
|
0170fcc111 | ||
|
|
0be5439e81 | ||
|
|
63f857b8fc | ||
|
|
a3b8ed8f91 | ||
|
|
cdd46667f3 | ||
|
|
2f8acea988 | ||
|
|
75f0e5b9f1 | ||
|
|
ce51129e84 | ||
|
|
86aa8b0a2a | ||
|
|
aeae62a45c | ||
|
|
6b12df44a0 | ||
|
|
a710183bc7 | ||
|
|
669316ba14 | ||
|
|
6c18f9a02f | ||
|
|
363edb9a50 | ||
|
|
afbf64170a | ||
|
|
14f36d0c64 | ||
|
|
ceecab395b | ||
|
|
b8eb9fd717 | ||
|
|
230a52f06b | ||
|
|
3e82608d5f | ||
|
|
cf2c2345c3 | ||
|
|
05ebe4b787 | ||
|
|
a744a43d14 | ||
|
|
5abdbfec1f | ||
|
|
0335b3b4d0 | ||
|
|
703fafd6c3 | ||
|
|
b956c4e383 | ||
|
|
d0d1fb2c8c | ||
|
|
d18a6f6e73 | ||
|
|
2994144718 | ||
|
|
62ab853605 | ||
|
|
7f7986d77a | ||
|
|
61f01cc51b | ||
|
|
86af8c6301 | ||
|
|
f1b0fcfbfc | ||
|
|
ab5ce39645 | ||
|
|
685e09ce4b | ||
|
|
8ed4f775e5 | ||
|
|
a3a3085b1f | ||
|
|
ed97640107 | ||
|
|
a9e93a679b | ||
|
|
418c36c09f | ||
|
|
935f7f1f7b | ||
|
|
9a0056b6ca | ||
|
|
cd56da5d85 | ||
|
|
97d5d853fc | ||
|
|
8adfe247b2 | ||
|
|
afe7df2989 | ||
|
|
cdb028c69c | ||
|
|
eed330662b | ||
|
|
26db10bbe0 | ||
|
|
14230bd588 | ||
|
|
699c821edd | ||
|
|
27ca13ece6 | ||
|
|
6820dfc820 | ||
|
|
471e7d9229 | ||
|
|
e0855a2c1b | ||
|
|
6a0b37a4d4 | ||
|
|
f7fd6916e2 | ||
|
|
30e61f4b7c | ||
|
|
48b37d58d8 | ||
|
|
b8c3bdc0b4 | ||
|
|
e96f18df7c | ||
|
|
7d15527831 | ||
|
|
794c0e760b | ||
|
|
e46a60d00a | ||
|
|
819aac70fd | ||
|
|
ed7db2d7c5 | ||
|
|
a450c846a6 | ||
|
|
fa774b0db2 | ||
|
|
98a56f9117 | ||
|
|
cbc4b8c59d | ||
|
|
69d266e018 | ||
|
|
4bc3ac1665 | ||
|
|
e0de9d70de | ||
|
|
493bab8163 | ||
|
|
25a2d82e82 | ||
|
|
0183677494 | ||
|
|
7ae9244896 | ||
|
|
15330cb41d | ||
|
|
166996d77a | ||
|
|
4943e0e902 | ||
|
|
1db6a8bfda | ||
|
|
57f43b256a | ||
|
|
23b2e8d682 | ||
|
|
6e1d62340f | ||
|
|
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 | ||
|
|
78698cfcbe | ||
|
|
64c02f14a9 | ||
|
|
2f68fc0d6e | ||
|
|
16570469e6 | ||
|
|
f2c14d09d4 | ||
|
|
7e81b9d45d | ||
|
|
a62b754d28 | ||
|
|
93859d6635 | ||
|
|
20fab8dbd3 | ||
|
|
e56c8cc5f8 | ||
|
|
13d0621881 | ||
|
|
13536aac51 | ||
|
|
ccb8721674 | ||
|
|
fbe1423edd | ||
|
|
d2713d7824 | ||
|
|
f24a15b4f8 | ||
|
|
bf74868bd4 | ||
|
|
6d75b8a3a2 | ||
|
|
055f917c61 | ||
|
|
d892a28069 | ||
|
|
838bc1fac2 | ||
|
|
a98d36513b | ||
|
|
2cfa6771ac | ||
|
|
4960c47377 | ||
|
|
a46306720b | ||
|
|
efcdba3a29 | ||
|
|
e7263d0566 | ||
|
|
5de7f5e283 | ||
|
|
c7bdf68bc6 | ||
|
|
998ff51c58 | ||
|
|
4eb9d84d8b | ||
|
|
5f000d8017 | ||
|
|
0cf9ad5228 | ||
|
|
71a526e7aa | ||
|
|
c5d9adb7fd | ||
|
|
05fdf163a9 | ||
|
|
de0a983033 | ||
|
|
44ee9b644f | ||
|
|
bd116c3e7b | ||
|
|
02e8a97f85 | ||
|
|
3525e4c90b | ||
|
|
e6d3819092 | ||
|
|
f15862cef4 | ||
|
|
86748b301d | ||
|
|
cc07dd849c | ||
|
|
63bcbb6506 | ||
|
|
83a1b03bb7 | ||
|
|
2126b4f657 | ||
|
|
0ce7c74778 | ||
|
|
b9f6a23412 | ||
|
|
9ae96bd1fa | ||
|
|
e863abe37c | ||
|
|
80e9984db0 | ||
|
|
8f504a8043 | ||
|
|
60a917e60c | ||
|
|
5317aa8fb5 | ||
|
|
36c4e2f4dc | ||
|
|
8e7c1da7af | ||
|
|
4f1f7c3fc0 | ||
|
|
084eeba2ed | ||
|
|
6401497422 | ||
|
|
684601f31b | ||
|
|
d7d222842b | ||
|
|
f59f6c617a | ||
|
|
af48ccfb57 | ||
|
|
4b9d3bd996 | ||
|
|
53eb95612c | ||
|
|
8f317d2f44 | ||
|
|
f4e581f6cb | ||
|
|
9671c4d63f | ||
|
|
b07940951c | ||
|
|
1f18ef4362 | ||
|
|
bf57a19e2c | ||
|
|
0845a6e2a3 | ||
|
|
041bae16e0 | ||
|
|
3313db844c | ||
|
|
3a5977a718 | ||
|
|
bcee74ce77 | ||
|
|
1a6a119f35 | ||
|
|
09ae61492f | ||
|
|
3a33f047f5 | ||
|
|
10cdd712d2 | ||
|
|
21959eef7b | ||
|
|
41c3522285 | ||
|
|
7087fde686 | ||
|
|
a6ae5e0675 | ||
|
|
cbd5ae9969 | ||
|
|
7389d080b6 | ||
|
|
8defd664c5 | ||
|
|
e1d61c9eb9 | ||
|
|
afcb15148f | ||
|
|
5adb36deaf | ||
|
|
4065b1b8cc | ||
|
|
eb3afbbad1 | ||
|
|
fbe219a888 | ||
|
|
5928b8e5f9 | ||
|
|
372425bed2 | ||
|
|
d2922fd361 | ||
|
|
e7b6001e5f | ||
|
|
4053984ca2 | ||
|
|
a1e06bf316 | ||
|
|
c50f2147fd | ||
|
|
d4671fb888 | ||
|
|
77cda10419 | ||
|
|
6de879cd2a | ||
|
|
25f24b98c6 | ||
|
|
11079dae00 | ||
|
|
d00da31f84 | ||
|
|
644fb698d8 | ||
|
|
92edb3a1bf | ||
|
|
0e2feac81e | ||
|
|
63bcf15900 | ||
|
|
25bcd10e93 | ||
|
|
0a784766b4 | ||
|
|
7678b89995 |
40
.github/helper/update_pot_file.sh
vendored
Normal file
40
.github/helper/update_pot_file.sh
vendored
Normal file
@@ -0,0 +1,40 @@
|
||||
#!/bin/bash
|
||||
set -e
|
||||
cd ~ || exit
|
||||
|
||||
echo "Setting Up Bench..."
|
||||
|
||||
pip install frappe-bench
|
||||
bench -v init frappe-bench --skip-assets --skip-redis-config-generation --python "$(which python)" --frappe-branch "${BASE_BRANCH}"
|
||||
cd ./frappe-bench || exit
|
||||
|
||||
echo "Get LMS..."
|
||||
bench get-app --skip-assets lms "${GITHUB_WORKSPACE}"
|
||||
|
||||
echo "Generating POT file..."
|
||||
bench generate-pot-file --app lms
|
||||
|
||||
cd ./apps/lms || exit
|
||||
|
||||
echo "Configuring git user..."
|
||||
git config user.email "developers@erpnext.com"
|
||||
git config user.name "frappe-pr-bot"
|
||||
|
||||
echo "Setting the correct git remote..."
|
||||
# Here, the git remote is a local file path by default. Let's change it to the upstream repo.
|
||||
git remote set-url upstream https://github.com/frappe/lms.git
|
||||
|
||||
echo "Creating a new branch..."
|
||||
isodate=$(date -u +"%Y-%m-%d")
|
||||
branch_name="pot_${BASE_BRANCH}_${isodate}"
|
||||
git checkout -b "${branch_name}"
|
||||
|
||||
echo "Commiting changes..."
|
||||
git add lms/locale/main.pot
|
||||
git commit -m "chore: update POT file"
|
||||
|
||||
gh auth setup-git
|
||||
git push -u upstream "${branch_name}"
|
||||
|
||||
echo "Creating a PR..."
|
||||
gh pr create --fill --base "${BASE_BRANCH}" --head "${branch_name}" -R frappe/lms
|
||||
34
.github/workflows/generate-pot-file.yml
vendored
Normal file
34
.github/workflows/generate-pot-file.yml
vendored
Normal file
@@ -0,0 +1,34 @@
|
||||
name: Regenerate POT file (translatable strings)
|
||||
on:
|
||||
schedule:
|
||||
- cron: "00 16 * * 5"
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
regenerate-pot-file:
|
||||
name: Release
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
branch: ["develop"]
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ matrix.branch }}
|
||||
|
||||
- name: Setup Python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: "3.12"
|
||||
|
||||
- name: Run script to update POT file
|
||||
run: |
|
||||
bash ${GITHUB_WORKSPACE}/.github/helper/update_pot_file.sh
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.RELEASE_TOKEN }}
|
||||
BASE_BRANCH: ${{ matrix.branch }}
|
||||
2
.github/workflows/linters.yml
vendored
2
.github/workflows/linters.yml
vendored
@@ -30,4 +30,4 @@ jobs:
|
||||
run: pip install semgrep
|
||||
|
||||
- name: Run Semgrep rules
|
||||
run: semgrep ci --config ./frappe-semgrep-rules/rules
|
||||
run: semgrep ci --config ./frappe-semgrep-rules/rules
|
||||
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/
|
||||
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 set-password frappe@example.com admin
|
||||
|
||||
- name: cypress pre-requisites
|
||||
run: |
|
||||
|
||||
5
.gitignore
vendored
5
.gitignore
vendored
@@ -9,4 +9,7 @@ __pycache__/
|
||||
*.py[cod]
|
||||
*$py.class
|
||||
node_modules
|
||||
package-lock.json
|
||||
package-lock.json
|
||||
lms/public/frontend
|
||||
lms/www/lms.html
|
||||
frappe-ui
|
||||
3
.gitmodules
vendored
Normal file
3
.gitmodules
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
[submodule "frappe-ui"]
|
||||
path = frappe-ui
|
||||
url = https://github.com/frappe/frappe-ui
|
||||
@@ -32,7 +32,7 @@ repos:
|
||||
rev: v2.7.1
|
||||
hooks:
|
||||
- id: prettier
|
||||
types_or: [javascript]
|
||||
types_or: [javascript, vue]
|
||||
# Ignore any files that might contain jinja / bundles
|
||||
exclude: |
|
||||
(?x)^(
|
||||
|
||||
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
|
||||
```
|
||||
|
||||
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
|
||||
|
||||
|
||||
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,
|
||||
},
|
||||
e2e: {
|
||||
baseUrl: "http://pyp:8000",
|
||||
baseUrl: "http://test_site_ui:8000",
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1,133 +1,159 @@
|
||||
describe("Course Creation", () => {
|
||||
it("creates a new course", () => {
|
||||
cy.login();
|
||||
cy.visit("/courses");
|
||||
cy.wait(1000);
|
||||
cy.visit("/lms/courses");
|
||||
|
||||
// Create a course
|
||||
cy.get("a.btn").contains("Create a Course").click();
|
||||
cy.wait(1000);
|
||||
cy.url().should("include", "/courses/new-course/edit");
|
||||
cy.get("#title").type("Test Course");
|
||||
cy.get("#intro").type("Test Course Short Introduction");
|
||||
cy.get("#description").type("Test Course Description");
|
||||
cy.get("#video-link").type("-LPmw2Znl2c");
|
||||
cy.get("#tags-input").type("Test");
|
||||
cy.get("#published").check();
|
||||
cy.get("a").contains("New").click();
|
||||
cy.wait(1000);
|
||||
cy.url().should("include", "/courses/new/edit");
|
||||
|
||||
cy.get("label").contains("Title").type("Test Course");
|
||||
cy.get("label")
|
||||
.contains("Short Introduction")
|
||||
.type("Test Course Short Introduction to test the UI");
|
||||
cy.get("div[contenteditable=true").invoke(
|
||||
"text",
|
||||
"Test Course Description. I need a very big description to test the UI. This is a very big description. It contains more than once sentence. Its meant to be this long as this is a UI test. Its unbearably long and I'm not sure why I'm typing this much. I'm just going to keep typing until I feel like its long enough. I think its long enough now. I'm going to stop typing now."
|
||||
);
|
||||
|
||||
cy.fixture("profile.png", "base64").then((fileContent) => {
|
||||
cy.get('input[type="file"]').attachFile({
|
||||
fileContent,
|
||||
fileName: "profile.png",
|
||||
mimeType: "image/png",
|
||||
encoding: "base64",
|
||||
});
|
||||
});
|
||||
|
||||
cy.get("label")
|
||||
.contains("Preview Video")
|
||||
.type("https://www.youtube.com/embed/-LPmw2Znl2c");
|
||||
cy.get("[id=tags]").type("Learning{enter}Frappe{enter}ERPNext{enter}");
|
||||
cy.get("label")
|
||||
.contains("Category")
|
||||
.parent()
|
||||
.within(() => {
|
||||
cy.get("button").click();
|
||||
});
|
||||
cy.get("[id^=headlessui-combobox-option-")
|
||||
.should("be.visible")
|
||||
.first()
|
||||
.click();
|
||||
|
||||
/* Instructor */
|
||||
cy.get("label")
|
||||
.contains("Instructors")
|
||||
.parent()
|
||||
.within(() => {
|
||||
cy.get("input").click().type("frappe");
|
||||
cy.get("input")
|
||||
.invoke("attr", "aria-controls")
|
||||
.as("instructor_list_id");
|
||||
});
|
||||
cy.get("@instructor_list_id").then((instructor_list_id) => {
|
||||
cy.get(`[id^=${instructor_list_id}`)
|
||||
.should("be.visible")
|
||||
.within(() => {
|
||||
cy.get("[id^=headlessui-combobox-option-").first().click();
|
||||
});
|
||||
});
|
||||
|
||||
cy.get("label").contains("Published").click();
|
||||
cy.get("label").contains("Published On").type("2021-01-01");
|
||||
cy.button("Save").click();
|
||||
|
||||
// Add Chapter
|
||||
cy.wait(1000);
|
||||
cy.link("Course Outline").click();
|
||||
cy.button("Add Chapter").click();
|
||||
|
||||
cy.wait(1000);
|
||||
cy.get(".edit-header .btn-add-chapter").click();
|
||||
cy.wait(500);
|
||||
cy.get("#chapter-title").type("Test Chapter");
|
||||
cy.get("#chapter-description").type("Test Chapter Description");
|
||||
cy.button("Save").click();
|
||||
cy.get("[id^=headlessui-dialog-panel-")
|
||||
.should("be.visible")
|
||||
.within(() => {
|
||||
cy.get("label").contains("Title").type("Test Chapter");
|
||||
cy.button("Add Chapter").click();
|
||||
});
|
||||
|
||||
// Add Lesson
|
||||
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.get("#lesson-title").type("Test Lesson");
|
||||
|
||||
// Content
|
||||
cy.get(".collapse-section.collapsed:first").click();
|
||||
cy.get("#lesson-content .ce-block")
|
||||
.click()
|
||||
.type(
|
||||
"This is an extremely big paragraph that is meant to test the UI. This is a very long paragraph. It contains more than once sentence. Its meant to be this long as this is a UI test. Its unbearably long and I'm not sure why I'm typing this much. I'm just going to keep typing until I feel like its long enough. I think its long enough now. I'm going to stop typing now. {enter}"
|
||||
);
|
||||
cy.get("#lesson-content .ce-toolbar__plus").click();
|
||||
cy.get('#lesson-content [data-item-name="youtube"]').click();
|
||||
cy.get('input[data-fieldname="youtube"]').type("GoDtyItReto");
|
||||
cy.button("Insert").click();
|
||||
cy.wait(1000);
|
||||
cy.get("label").contains("Title").type("Test Lesson");
|
||||
|
||||
cy.get("#content .ce-block").type(
|
||||
"This is an extremely big paragraph that is meant to test the UI. This is a very long paragraph. It contains more than once sentence. Its meant to be this long as this is a UI test. Its unbearably long and I'm not sure why I'm typing this much. I'm just going to keep typing until I feel like its long enough. I think its long enough now. I'm going to stop typing now."
|
||||
);
|
||||
cy.button("Save").click();
|
||||
|
||||
// View Course
|
||||
cy.wait(1000);
|
||||
cy.visit("/courses");
|
||||
cy.get(".course-card-title:first").contains("Test Course");
|
||||
cy.get(".course-card:first").click();
|
||||
cy.url().should("include", "/courses/test-course");
|
||||
cy.get("#title").contains("Test Course");
|
||||
cy.get(".preview-video").should(
|
||||
cy.visit("/lms");
|
||||
cy.wait(500);
|
||||
cy.url().should("include", "/lms/courses");
|
||||
cy.get(".grid a:first").within(() => {
|
||||
cy.get("div").contains("Test Course");
|
||||
cy.get("div").contains(
|
||||
"Test Course Short Introduction to test the UI"
|
||||
);
|
||||
cy.get(".course-image")
|
||||
.invoke("css", "background-image")
|
||||
.should("include", "/files/profile");
|
||||
});
|
||||
cy.get(".grid a:first").click();
|
||||
cy.url().should("include", "/lms/courses/test-course");
|
||||
cy.get("div").contains("Test Course");
|
||||
cy.get("div").contains("Test Course Short Introduction to test the UI");
|
||||
cy.get("div").contains("Learning");
|
||||
cy.get("div").contains("Frappe");
|
||||
cy.get("div").contains("ERPNext");
|
||||
cy.get("iframe").should(
|
||||
"have.attr",
|
||||
"src",
|
||||
"https://www.youtube.com/embed/-LPmw2Znl2c"
|
||||
);
|
||||
cy.get("#intro").contains("Test Course Short Introduction");
|
||||
|
||||
// View Chapter
|
||||
cy.get(".chapter-title-main:first").contains("Test Chapter");
|
||||
cy.get(".chapter-description:first").contains(
|
||||
"Test Chapter Description"
|
||||
);
|
||||
cy.get(".lesson-info:first").contains("Test Lesson");
|
||||
cy.get(".lesson-info:first").click();
|
||||
cy.get("div").contains("Test Chapter");
|
||||
cy.get("[id^=headlessui-disclosure-panel-").within(() => {
|
||||
cy.get("div").contains("Test Lesson").click();
|
||||
});
|
||||
cy.wait(3000);
|
||||
|
||||
// View Lesson
|
||||
cy.wait(1000);
|
||||
cy.url().should("include", "learn/1.1");
|
||||
cy.get("#title").contains("Test Lesson");
|
||||
cy.get(".lesson-video iframe").should(
|
||||
"have.attr",
|
||||
"src",
|
||||
"https://www.youtube.com/embed/GoDtyItReto"
|
||||
);
|
||||
cy.get(".lesson-content-card").contains(
|
||||
cy.url().should("include", "/learn/1-1");
|
||||
cy.get("div").contains("Test Lesson");
|
||||
|
||||
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."
|
||||
);
|
||||
|
||||
// Add Discussion
|
||||
cy.get(".reply").click();
|
||||
cy.button("New Question").click();
|
||||
cy.wait(500);
|
||||
cy.get(".discussion-modal").should("be.visible");
|
||||
|
||||
// Enter title
|
||||
cy.get(".modal .topic-title")
|
||||
.type("Discussion from tests")
|
||||
.should("have.value", "Discussion from tests");
|
||||
|
||||
// Enter comment
|
||||
cy.get(".modal .discussions-comment").type(
|
||||
"This is a discussion from the cypress ui tests."
|
||||
);
|
||||
|
||||
// Submit
|
||||
cy.get(".modal .submit-discussion").click();
|
||||
cy.wait(2000);
|
||||
|
||||
// Check if discussion is added to page and content is visible
|
||||
cy.get(".sidebar-parent:first .discussion-topic-title").should(
|
||||
"have.text",
|
||||
"Discussion from tests"
|
||||
);
|
||||
cy.get(".sidebar-parent:first .discussion-topic-title").click();
|
||||
cy.get(".discussion-on-page:visible").should("have.class", "show");
|
||||
cy.get(
|
||||
".discussion-on-page:visible .reply-card .reply-text .ql-editor p"
|
||||
).should(
|
||||
"have.text",
|
||||
"This is a discussion from the cypress ui tests."
|
||||
);
|
||||
|
||||
cy.get(".discussion-form:visible .discussions-comment").type(
|
||||
"This is a discussion from the cypress ui tests. \n\nThis comment was entered through the commentbox on the page."
|
||||
);
|
||||
|
||||
cy.get(".discussion-form:visible .submit-discussion").click();
|
||||
cy.wait(3000);
|
||||
cy.get(".discussion-on-page:visible").should("have.class", "show");
|
||||
cy.get(".discussion-on-page:visible")
|
||||
.children(".reply-card")
|
||||
.eq(1)
|
||||
.find(".reply-text")
|
||||
.should(
|
||||
"have.text",
|
||||
"This is a discussion from the cypress ui tests. This comment was entered through the commentbox on the page.\n"
|
||||
cy.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();
|
||||
});
|
||||
|
||||
// View Discussion
|
||||
cy.wait(500);
|
||||
cy.get("div").contains("Test Discussion").click();
|
||||
cy.get("div[contenteditable=true").invoke(
|
||||
"text",
|
||||
"This is a test comment. This will check if the UI is working properly."
|
||||
);
|
||||
|
||||
cy.get("div").contains(
|
||||
"This is a test comment. This will check if the UI is working properly."
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
BIN
cypress/fixtures/Youtube.mov
Normal file
BIN
cypress/fixtures/Youtube.mov
Normal file
Binary file not shown.
BIN
cypress/fixtures/profile.png
Normal file
BIN
cypress/fixtures/profile.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.2 MiB |
@@ -24,6 +24,8 @@
|
||||
// -- This will overwrite an existing command --
|
||||
// Cypress.Commands.overwrite('visit', (originalFn, url, options) => { ... })
|
||||
|
||||
import "cypress-file-upload";
|
||||
|
||||
Cypress.Commands.add("login", (email, password) => {
|
||||
if (!email) {
|
||||
email = Cypress.config("testUser") || "Administrator";
|
||||
@@ -53,3 +55,13 @@ Cypress.Commands.add("iconButton", (text) => {
|
||||
Cypress.Commands.add("dialog", (selector) => {
|
||||
return cy.get(`[role=dialog] ${selector}`);
|
||||
});
|
||||
|
||||
Cypress.Commands.add("paste", { prevSubject: true }, (subject, text) => {
|
||||
cy.wrap(subject).then(($element) => {
|
||||
const element = $element[0];
|
||||
element.focus();
|
||||
element.textContent = text;
|
||||
const event = new Event("paste", { bubbles: true });
|
||||
element.dispatchEvent(event);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -35,7 +35,6 @@ bench new-site lms.localhost \
|
||||
bench --site lms.localhost install-app lms
|
||||
bench --site lms.localhost set-config developer_mode 1
|
||||
bench --site lms.localhost clear-cache
|
||||
bench --site lms.localhost set-config mute_emails 1
|
||||
bench use lms.localhost
|
||||
|
||||
bench start
|
||||
|
||||
Submodule frappe-ui updated: 2898a0bdd1...8cd9b06a5e
5
frontend/.gitignore
vendored
Normal file
5
frontend/.gitignore
vendored
Normal file
@@ -0,0 +1,5 @@
|
||||
node_modules
|
||||
.DS_Store
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
4
frontend/.prettierrc.json
Normal file
4
frontend/.prettierrc.json
Normal file
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"semi": false,
|
||||
"singleQuote": true
|
||||
}
|
||||
42
frontend/README.md
Normal file
42
frontend/README.md
Normal file
@@ -0,0 +1,42 @@
|
||||
# Frappe UI Starter
|
||||
|
||||
This template should help get you started developing custom frontend for Frappe
|
||||
apps with Vue 3 and the Frappe UI package.
|
||||
|
||||
This boilerplate sets up Vue 3, Vue Router, TailwindCSS, and Frappe UI out of
|
||||
the box.
|
||||
|
||||
## Usage
|
||||
|
||||
This template is meant to be cloned inside an existing Frappe App. Assuming your
|
||||
apps name is `todo`. Clone this template in the root folder of your app using `degit`.
|
||||
|
||||
```
|
||||
cd apps/todo
|
||||
npx degit netchampfaris/frappe-ui-starter frontend
|
||||
cd frontend
|
||||
yarn
|
||||
yarn dev
|
||||
```
|
||||
|
||||
In a development environment, you need to put the below key-value pair in your `site_config.json` file:
|
||||
|
||||
```
|
||||
"ignore_csrf": 1
|
||||
```
|
||||
|
||||
This will prevent `CSRFToken` errors while using the vite dev server. In production environment, the `csrf_token` is attached to the `window` object in `index.html` for you.
|
||||
|
||||
The Vite dev server will start on the port `8080`. This can be changed from `vite.config.js`.
|
||||
The development server is configured to proxy your frappe app (usually running on port `8000`). If you have a site named `todo.test`, open `http://todo.test:8080` in your browser. If you see a button named "Click to send 'ping' request", congratulations!
|
||||
|
||||
If you notice the browser URL is `/frontend`, this is the base URL where your frontend app will run in production.
|
||||
To change this, open `src/router.js` and change the base URL passed to `createWebHistory`.
|
||||
|
||||
## Resources
|
||||
|
||||
- [Vue 3](https://v3.vuejs.org/guide/introduction.html)
|
||||
- [Vue Router](https://next.router.vuejs.org/guide/)
|
||||
- [Frappe UI](https://github.com/frappe/frappe-ui)
|
||||
- [TailwindCSS](https://tailwindcss.com/docs/utility-first)
|
||||
- [Vite](https://vitejs.dev/guide/)
|
||||
49
frontend/index.html
Normal file
49
frontend/index.html
Normal file
@@ -0,0 +1,49 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" href="/favicon.png" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Frappe Learning</title>
|
||||
<meta name="title" content="{{ meta.title }}" />
|
||||
<meta name="image" content="{{ meta.image }}" />
|
||||
<meta name="description" content="{{ meta.description }}" />
|
||||
<meta name="keywords" content="{{ meta.keywords }}" />
|
||||
<meta property="og:title" content="{{ meta.title }}" />
|
||||
<meta property="og:image" content="{{ meta.image }}" />
|
||||
<meta property="og:description" content="{{ meta.description }}" />
|
||||
<meta name="twitter:title" content="{{ meta.title }}" />
|
||||
<meta name="twitter:image" content="{{ meta.image }}" />
|
||||
<meta name="twitter:description" content="{{ meta.description }}" />
|
||||
</head>
|
||||
<body>
|
||||
<div id="app">
|
||||
<div id="seo-content">
|
||||
<h1>{{ meta.title }}</h1>
|
||||
<p>
|
||||
{{ meta.description }}
|
||||
</p>
|
||||
<p>
|
||||
The content here is just for seo purposes. The actual content will be loaded in a few seconds.
|
||||
</p>
|
||||
<p>
|
||||
Seo checks if a page has more than 300 words. So, here are some more words to make it more than 300 words.
|
||||
Page descriptions are the HTML meta tags that provide a brief summary of a web page.
|
||||
Search engines use meta descriptions to help identify the page's topic - they don't use them to rank the page, but they do use them to determine whether or not to display the page in search results.
|
||||
Meta descriptions are important because they're often the first thing people see when they're deciding which search result to click on.
|
||||
They're also important because they can help improve your click-through rate (CTR) from search results.
|
||||
A good meta description can entice people to click on your page instead of someone else's.
|
||||
</p>
|
||||
<a href="{{ meta.link }}">Know More</a>
|
||||
</div>
|
||||
</div>
|
||||
<div id="modals"></div>
|
||||
<div id="popovers"></div>
|
||||
|
||||
<script>
|
||||
window.csrf_token = '{{ csrf_token }}'
|
||||
document.getElementById('seo-content').style.display = 'none';
|
||||
</script>
|
||||
<script type="module" src="/src/main.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
44
frontend/package.json
Normal file
44
frontend/package.json
Normal file
@@ -0,0 +1,44 @@
|
||||
{
|
||||
"name": "frappe-ui-frontend",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"serve": "vite preview",
|
||||
"build": "vite build --base=/assets/lms/frontend/ && yarn copy-html-entry",
|
||||
"copy-html-entry": "cp ../lms/public/frontend/index.html ../lms/www/lms.html"
|
||||
},
|
||||
"dependencies": {
|
||||
"@editorjs/checklist": "^1.6.0",
|
||||
"@editorjs/code": "^2.9.0",
|
||||
"@editorjs/editorjs": "^2.29.0",
|
||||
"@editorjs/embed": "^2.7.0",
|
||||
"@editorjs/header": "^2.8.1",
|
||||
"@editorjs/inline-code": "^1.5.0",
|
||||
"@editorjs/nested-list": "^1.4.2",
|
||||
"@editorjs/paragraph": "^2.11.3",
|
||||
"@editorjs/simple-image": "^1.6.0",
|
||||
"ace-builds": "^1.36.2",
|
||||
"chart.js": "^4.4.1",
|
||||
"codemirror-editor-vue3": "^2.8.0",
|
||||
"dayjs": "^1.11.6",
|
||||
"feather-icons": "^4.28.0",
|
||||
"frappe-ui": "^0.1.69",
|
||||
"lucide-vue-next": "^0.383.0",
|
||||
"markdown-it": "^14.0.0",
|
||||
"pinia": "^2.0.33",
|
||||
"socket.io-client": "^4.7.2",
|
||||
"tailwindcss": "^3.3.3",
|
||||
"vue": "^3.4.23",
|
||||
"vue-chartjs": "^5.3.0",
|
||||
"vue-draggable-next": "^2.2.1",
|
||||
"vue-router": "^4.0.12",
|
||||
"vuedraggable": "4.1.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitejs/plugin-vue": "^5.0.3",
|
||||
"autoprefixer": "^10.4.2",
|
||||
"postcss": "^8.4.5",
|
||||
"vite": "^5.0.11"
|
||||
}
|
||||
}
|
||||
6
frontend/postcss.config.js
Normal file
6
frontend/postcss.config.js
Normal file
@@ -0,0 +1,6 @@
|
||||
module.exports = {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
BIN
frontend/public/Quiz.mp4
Normal file
BIN
frontend/public/Quiz.mp4
Normal file
Binary file not shown.
BIN
frontend/public/Upload.mp4
Normal file
BIN
frontend/public/Upload.mp4
Normal file
Binary file not shown.
BIN
frontend/public/Youtube.mp4
Normal file
BIN
frontend/public/Youtube.mp4
Normal file
Binary file not shown.
BIN
frontend/public/favicon.png
Normal file
BIN
frontend/public/favicon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 440 B |
38
frontend/src/App.vue
Normal file
38
frontend/src/App.vue
Normal file
@@ -0,0 +1,38 @@
|
||||
<template>
|
||||
<Layout>
|
||||
<router-view />
|
||||
</Layout>
|
||||
<Dialogs />
|
||||
<Toasts />
|
||||
</template>
|
||||
<script setup>
|
||||
import { Toasts } from 'frappe-ui'
|
||||
import { Dialogs } from '@/utils/dialogs'
|
||||
import { computed, onMounted, onUnmounted } from 'vue'
|
||||
import { useScreenSize } from './utils/composables'
|
||||
import DesktopLayout from './components/DesktopLayout.vue'
|
||||
import MobileLayout from './components/MobileLayout.vue'
|
||||
import { stopSession } from '@/telemetry'
|
||||
import { init as initTelemetry } from '@/telemetry'
|
||||
import { usersStore } from '@/stores/user'
|
||||
|
||||
const screenSize = useScreenSize()
|
||||
let { userResource } = usersStore()
|
||||
|
||||
const Layout = computed(() => {
|
||||
if (screenSize.width < 640) {
|
||||
return MobileLayout
|
||||
} else {
|
||||
return DesktopLayout
|
||||
}
|
||||
})
|
||||
|
||||
onMounted(async () => {
|
||||
if (!userResource.data) return
|
||||
await initTelemetry()
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
stopSession()
|
||||
})
|
||||
</script>
|
||||
BIN
frontend/src/assets/Inter/Inter-Black.woff
Normal file
BIN
frontend/src/assets/Inter/Inter-Black.woff
Normal file
Binary file not shown.
BIN
frontend/src/assets/Inter/Inter-Black.woff2
Normal file
BIN
frontend/src/assets/Inter/Inter-Black.woff2
Normal file
Binary file not shown.
BIN
frontend/src/assets/Inter/Inter-BlackItalic.woff
Normal file
BIN
frontend/src/assets/Inter/Inter-BlackItalic.woff
Normal file
Binary file not shown.
BIN
frontend/src/assets/Inter/Inter-BlackItalic.woff2
Normal file
BIN
frontend/src/assets/Inter/Inter-BlackItalic.woff2
Normal file
Binary file not shown.
BIN
frontend/src/assets/Inter/Inter-Bold.woff
Normal file
BIN
frontend/src/assets/Inter/Inter-Bold.woff
Normal file
Binary file not shown.
BIN
frontend/src/assets/Inter/Inter-Bold.woff2
Normal file
BIN
frontend/src/assets/Inter/Inter-Bold.woff2
Normal file
Binary file not shown.
BIN
frontend/src/assets/Inter/Inter-BoldItalic.woff
Normal file
BIN
frontend/src/assets/Inter/Inter-BoldItalic.woff
Normal file
Binary file not shown.
BIN
frontend/src/assets/Inter/Inter-BoldItalic.woff2
Normal file
BIN
frontend/src/assets/Inter/Inter-BoldItalic.woff2
Normal file
Binary file not shown.
BIN
frontend/src/assets/Inter/Inter-ExtraBold.woff
Normal file
BIN
frontend/src/assets/Inter/Inter-ExtraBold.woff
Normal file
Binary file not shown.
BIN
frontend/src/assets/Inter/Inter-ExtraBold.woff2
Normal file
BIN
frontend/src/assets/Inter/Inter-ExtraBold.woff2
Normal file
Binary file not shown.
BIN
frontend/src/assets/Inter/Inter-ExtraBoldItalic.woff
Normal file
BIN
frontend/src/assets/Inter/Inter-ExtraBoldItalic.woff
Normal file
Binary file not shown.
BIN
frontend/src/assets/Inter/Inter-ExtraBoldItalic.woff2
Normal file
BIN
frontend/src/assets/Inter/Inter-ExtraBoldItalic.woff2
Normal file
Binary file not shown.
BIN
frontend/src/assets/Inter/Inter-ExtraLight.woff
Normal file
BIN
frontend/src/assets/Inter/Inter-ExtraLight.woff
Normal file
Binary file not shown.
BIN
frontend/src/assets/Inter/Inter-ExtraLight.woff2
Normal file
BIN
frontend/src/assets/Inter/Inter-ExtraLight.woff2
Normal file
Binary file not shown.
BIN
frontend/src/assets/Inter/Inter-ExtraLightItalic.woff
Normal file
BIN
frontend/src/assets/Inter/Inter-ExtraLightItalic.woff
Normal file
Binary file not shown.
BIN
frontend/src/assets/Inter/Inter-ExtraLightItalic.woff2
Normal file
BIN
frontend/src/assets/Inter/Inter-ExtraLightItalic.woff2
Normal file
Binary file not shown.
BIN
frontend/src/assets/Inter/Inter-Italic.woff
Normal file
BIN
frontend/src/assets/Inter/Inter-Italic.woff
Normal file
Binary file not shown.
BIN
frontend/src/assets/Inter/Inter-Italic.woff2
Normal file
BIN
frontend/src/assets/Inter/Inter-Italic.woff2
Normal file
Binary file not shown.
BIN
frontend/src/assets/Inter/Inter-Light.woff
Normal file
BIN
frontend/src/assets/Inter/Inter-Light.woff
Normal file
Binary file not shown.
BIN
frontend/src/assets/Inter/Inter-Light.woff2
Normal file
BIN
frontend/src/assets/Inter/Inter-Light.woff2
Normal file
Binary file not shown.
BIN
frontend/src/assets/Inter/Inter-LightItalic.woff
Normal file
BIN
frontend/src/assets/Inter/Inter-LightItalic.woff
Normal file
Binary file not shown.
BIN
frontend/src/assets/Inter/Inter-LightItalic.woff2
Normal file
BIN
frontend/src/assets/Inter/Inter-LightItalic.woff2
Normal file
Binary file not shown.
BIN
frontend/src/assets/Inter/Inter-Medium.woff
Normal file
BIN
frontend/src/assets/Inter/Inter-Medium.woff
Normal file
Binary file not shown.
BIN
frontend/src/assets/Inter/Inter-Medium.woff2
Normal file
BIN
frontend/src/assets/Inter/Inter-Medium.woff2
Normal file
Binary file not shown.
BIN
frontend/src/assets/Inter/Inter-MediumItalic.woff
Normal file
BIN
frontend/src/assets/Inter/Inter-MediumItalic.woff
Normal file
Binary file not shown.
BIN
frontend/src/assets/Inter/Inter-MediumItalic.woff2
Normal file
BIN
frontend/src/assets/Inter/Inter-MediumItalic.woff2
Normal file
Binary file not shown.
BIN
frontend/src/assets/Inter/Inter-Regular.woff
Normal file
BIN
frontend/src/assets/Inter/Inter-Regular.woff
Normal file
Binary file not shown.
BIN
frontend/src/assets/Inter/Inter-Regular.woff2
Normal file
BIN
frontend/src/assets/Inter/Inter-Regular.woff2
Normal file
Binary file not shown.
BIN
frontend/src/assets/Inter/Inter-SemiBold.woff
Normal file
BIN
frontend/src/assets/Inter/Inter-SemiBold.woff
Normal file
Binary file not shown.
BIN
frontend/src/assets/Inter/Inter-SemiBold.woff2
Normal file
BIN
frontend/src/assets/Inter/Inter-SemiBold.woff2
Normal file
Binary file not shown.
BIN
frontend/src/assets/Inter/Inter-SemiBoldItalic.woff
Normal file
BIN
frontend/src/assets/Inter/Inter-SemiBoldItalic.woff
Normal file
Binary file not shown.
BIN
frontend/src/assets/Inter/Inter-SemiBoldItalic.woff2
Normal file
BIN
frontend/src/assets/Inter/Inter-SemiBoldItalic.woff2
Normal file
Binary file not shown.
BIN
frontend/src/assets/Inter/Inter-Thin.woff
Normal file
BIN
frontend/src/assets/Inter/Inter-Thin.woff
Normal file
Binary file not shown.
BIN
frontend/src/assets/Inter/Inter-Thin.woff2
Normal file
BIN
frontend/src/assets/Inter/Inter-Thin.woff2
Normal file
Binary file not shown.
BIN
frontend/src/assets/Inter/Inter-ThinItalic.woff
Normal file
BIN
frontend/src/assets/Inter/Inter-ThinItalic.woff
Normal file
Binary file not shown.
BIN
frontend/src/assets/Inter/Inter-ThinItalic.woff2
Normal file
BIN
frontend/src/assets/Inter/Inter-ThinItalic.woff2
Normal file
Binary file not shown.
BIN
frontend/src/assets/Inter/Inter-italic.var.woff2
Normal file
BIN
frontend/src/assets/Inter/Inter-italic.var.woff2
Normal file
Binary file not shown.
BIN
frontend/src/assets/Inter/Inter-roman.var.woff2
Normal file
BIN
frontend/src/assets/Inter/Inter-roman.var.woff2
Normal file
Binary file not shown.
BIN
frontend/src/assets/Inter/Inter.var.woff2
Normal file
BIN
frontend/src/assets/Inter/Inter.var.woff2
Normal file
Binary file not shown.
152
frontend/src/assets/Inter/inter.css
Normal file
152
frontend/src/assets/Inter/inter.css
Normal file
@@ -0,0 +1,152 @@
|
||||
@font-face {
|
||||
font-family: 'Inter';
|
||||
font-style: normal;
|
||||
font-weight: 100;
|
||||
font-display: swap;
|
||||
src: url("Inter-Thin.woff2?v=3.12") format("woff2"),
|
||||
url("Inter-Thin.woff?v=3.12") format("woff");
|
||||
}
|
||||
@font-face {
|
||||
font-family: 'Inter';
|
||||
font-style: italic;
|
||||
font-weight: 100;
|
||||
font-display: swap;
|
||||
src: url("Inter-ThinItalic.woff2?v=3.12") format("woff2"),
|
||||
url("Inter-ThinItalic.woff?v=3.12") format("woff");
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'Inter';
|
||||
font-style: normal;
|
||||
font-weight: 200;
|
||||
font-display: swap;
|
||||
src: url("Inter-ExtraLight.woff2?v=3.12") format("woff2"),
|
||||
url("Inter-ExtraLight.woff?v=3.12") format("woff");
|
||||
}
|
||||
@font-face {
|
||||
font-family: 'Inter';
|
||||
font-style: italic;
|
||||
font-weight: 200;
|
||||
font-display: swap;
|
||||
src: url("Inter-ExtraLightItalic.woff2?v=3.12") format("woff2"),
|
||||
url("Inter-ExtraLightItalic.woff?v=3.12") format("woff");
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'Inter';
|
||||
font-style: normal;
|
||||
font-weight: 300;
|
||||
font-display: swap;
|
||||
src: url("Inter-Light.woff2?v=3.12") format("woff2"),
|
||||
url("Inter-Light.woff?v=3.12") format("woff");
|
||||
}
|
||||
@font-face {
|
||||
font-family: 'Inter';
|
||||
font-style: italic;
|
||||
font-weight: 300;
|
||||
font-display: swap;
|
||||
src: url("Inter-LightItalic.woff2?v=3.12") format("woff2"),
|
||||
url("Inter-LightItalic.woff?v=3.12") format("woff");
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'Inter';
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
font-display: swap;
|
||||
src: url("Inter-Regular.woff2?v=3.12") format("woff2"),
|
||||
url("Inter-Regular.woff?v=3.12") format("woff");
|
||||
}
|
||||
@font-face {
|
||||
font-family: 'Inter';
|
||||
font-style: italic;
|
||||
font-weight: 400;
|
||||
font-display: swap;
|
||||
src: url("Inter-Italic.woff2?v=3.12") format("woff2"),
|
||||
url("Inter-Italic.woff?v=3.12") format("woff");
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'Inter';
|
||||
font-style: normal;
|
||||
font-weight: 500;
|
||||
font-display: swap;
|
||||
src: url("Inter-Medium.woff2?v=3.12") format("woff2"),
|
||||
url("Inter-Medium.woff?v=3.12") format("woff");
|
||||
}
|
||||
@font-face {
|
||||
font-family: 'Inter';
|
||||
font-style: italic;
|
||||
font-weight: 500;
|
||||
font-display: swap;
|
||||
src: url("Inter-MediumItalic.woff2?v=3.12") format("woff2"),
|
||||
url("Inter-MediumItalic.woff?v=3.12") format("woff");
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'Inter';
|
||||
font-style: normal;
|
||||
font-weight: 600;
|
||||
font-display: swap;
|
||||
src: url("Inter-SemiBold.woff2?v=3.12") format("woff2"),
|
||||
url("Inter-SemiBold.woff?v=3.12") format("woff");
|
||||
}
|
||||
@font-face {
|
||||
font-family: 'Inter';
|
||||
font-style: italic;
|
||||
font-weight: 600;
|
||||
font-display: swap;
|
||||
src: url("Inter-SemiBoldItalic.woff2?v=3.12") format("woff2"),
|
||||
url("Inter-SemiBoldItalic.woff?v=3.12") format("woff");
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'Inter';
|
||||
font-style: normal;
|
||||
font-weight: 700;
|
||||
font-display: swap;
|
||||
src: url("Inter-Bold.woff2?v=3.12") format("woff2"),
|
||||
url("Inter-Bold.woff?v=3.12") format("woff");
|
||||
}
|
||||
@font-face {
|
||||
font-family: 'Inter';
|
||||
font-style: italic;
|
||||
font-weight: 700;
|
||||
font-display: swap;
|
||||
src: url("Inter-BoldItalic.woff2?v=3.12") format("woff2"),
|
||||
url("Inter-BoldItalic.woff?v=3.12") format("woff");
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'Inter';
|
||||
font-style: normal;
|
||||
font-weight: 800;
|
||||
font-display: swap;
|
||||
src: url("Inter-ExtraBold.woff2?v=3.12") format("woff2"),
|
||||
url("Inter-ExtraBold.woff?v=3.12") format("woff");
|
||||
}
|
||||
@font-face {
|
||||
font-family: 'Inter';
|
||||
font-style: italic;
|
||||
font-weight: 800;
|
||||
font-display: swap;
|
||||
src: url("Inter-ExtraBoldItalic.woff2?v=3.12") format("woff2"),
|
||||
url("Inter-ExtraBoldItalic.woff?v=3.12") format("woff");
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'Inter';
|
||||
font-style: normal;
|
||||
font-weight: 900;
|
||||
font-display: swap;
|
||||
src: url("Inter-Black.woff2?v=3.12") format("woff2"),
|
||||
url("Inter-Black.woff?v=3.12") format("woff");
|
||||
}
|
||||
@font-face {
|
||||
font-family: 'Inter';
|
||||
font-style: italic;
|
||||
font-weight: 900;
|
||||
font-display: swap;
|
||||
src: url("Inter-BlackItalic.woff2?v=3.12") format("woff2"),
|
||||
url("Inter-BlackItalic.woff?v=3.12") format("woff");
|
||||
}
|
||||
62
frontend/src/components/Annoucements.vue
Normal file
62
frontend/src/components/Annoucements.vue
Normal file
@@ -0,0 +1,62 @@
|
||||
<template>
|
||||
<div v-if="communications.data?.length">
|
||||
<div v-for="comm in communications.data">
|
||||
<div class="mb-8">
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<div class="flex items-center">
|
||||
<Avatar :label="comm.sender_full_name" size="lg" />
|
||||
<div class="ml-2">
|
||||
{{ comm.sender_full_name }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-sm">
|
||||
{{ timeAgo(comm.communication_date) }}
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="prose prose-sm bg-gray-50 !min-w-full px-4 py-2 rounded-md"
|
||||
v-html="comm.content"
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="text-sm italic text-gray-600">
|
||||
{{ __('No announcements') }}
|
||||
</div>
|
||||
</template>
|
||||
<script setup>
|
||||
import { createListResource, Avatar } from 'frappe-ui'
|
||||
import { timeAgo } from '@/utils'
|
||||
|
||||
const props = defineProps({
|
||||
batch: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
})
|
||||
|
||||
const communications = createListResource({
|
||||
doctype: 'Communication',
|
||||
fields: [
|
||||
'subject',
|
||||
'content',
|
||||
'recipients',
|
||||
'cc',
|
||||
'communication_date',
|
||||
'sender',
|
||||
'sender_full_name',
|
||||
],
|
||||
filters: {
|
||||
reference_doctype: 'LMS Batch',
|
||||
reference_name: props.batch,
|
||||
},
|
||||
orderBy: 'communication_date desc',
|
||||
auto: true,
|
||||
cache: ['batch', props.batch],
|
||||
})
|
||||
</script>
|
||||
<style>
|
||||
.prose-sm p {
|
||||
margin: 0 0 0.5rem;
|
||||
}
|
||||
</style>
|
||||
218
frontend/src/components/AppSidebar.vue
Normal file
218
frontend/src/components/AppSidebar.vue
Normal file
@@ -0,0 +1,218 @@
|
||||
<template>
|
||||
<div
|
||||
class="flex h-full flex-col justify-between transition-all duration-300 ease-in-out bg-gray-50"
|
||||
:class="isSidebarCollapsed ? 'w-14' : 'w-56'"
|
||||
>
|
||||
<div
|
||||
class="flex flex-col overflow-hidden"
|
||||
:class="isSidebarCollapsed ? 'items-center' : ''"
|
||||
>
|
||||
<UserDropdown :isCollapsed="isSidebarCollapsed" />
|
||||
<div class="flex flex-col" v-if="sidebarSettings.data">
|
||||
<SidebarLink
|
||||
v-for="link in sidebarLinks"
|
||||
:link="link"
|
||||
:isCollapsed="isSidebarCollapsed"
|
||||
class="mx-2 my-0.5"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
v-if="sidebarSettings.data?.web_pages?.length || isModerator"
|
||||
class="mt-4"
|
||||
>
|
||||
<div
|
||||
class="flex items-center justify-between pr-2 cursor-pointer"
|
||||
:class="isSidebarCollapsed ? 'pl-3' : 'pl-4'"
|
||||
@click="showWebPages = !showWebPages"
|
||||
>
|
||||
<div
|
||||
v-if="!isSidebarCollapsed"
|
||||
class="flex items-center text-sm text-gray-600 my-1"
|
||||
>
|
||||
<span class="grid h-5 w-6 flex-shrink-0 place-items-center">
|
||||
<ChevronRight
|
||||
class="h-4 w-4 stroke-1.5 text-gray-900 transition-all duration-300 ease-in-out"
|
||||
:class="{ 'rotate-90': showWebPages }"
|
||||
/>
|
||||
</span>
|
||||
<span class="ml-2">
|
||||
{{ __('More') }}
|
||||
</span>
|
||||
</div>
|
||||
<Button v-if="isModerator" variant="ghost" @click="openPageModal()">
|
||||
<template #icon>
|
||||
<Plus class="h-4 w-4 text-gray-700 stroke-1.5" />
|
||||
</template>
|
||||
</Button>
|
||||
</div>
|
||||
<div
|
||||
v-if="sidebarSettings.data?.web_pages?.length"
|
||||
class="flex flex-col transition-all duration-300 ease-in-out"
|
||||
:class="showWebPages ? 'block' : 'hidden'"
|
||||
>
|
||||
<SidebarLink
|
||||
v-for="link in sidebarSettings.data.web_pages"
|
||||
:link="link"
|
||||
:isCollapsed="isSidebarCollapsed"
|
||||
class="mx-2 my-0.5"
|
||||
:showControls="isModerator ? true : false"
|
||||
@openModal="openPageModal"
|
||||
@deletePage="deletePage"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<SidebarLink
|
||||
:link="{
|
||||
label: isSidebarCollapsed ? 'Expand' : 'Collapse',
|
||||
}"
|
||||
:isCollapsed="isSidebarCollapsed"
|
||||
@click="isSidebarCollapsed = !isSidebarCollapsed"
|
||||
class="m-2"
|
||||
>
|
||||
<template #icon>
|
||||
<span class="grid h-5 w-6 flex-shrink-0 place-items-center">
|
||||
<CollapseSidebar
|
||||
class="h-4.5 w-4.5 text-gray-700 duration-300 ease-in-out"
|
||||
:class="{ '[transform:rotateY(180deg)]': isSidebarCollapsed }"
|
||||
/>
|
||||
</span>
|
||||
</template>
|
||||
</SidebarLink>
|
||||
</div>
|
||||
<PageModal
|
||||
v-model="showPageModal"
|
||||
v-model:reloadSidebar="sidebarSettings"
|
||||
:page="pageToEdit"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import UserDropdown from '@/components/UserDropdown.vue'
|
||||
import CollapseSidebar from '@/components/Icons/CollapseSidebar.vue'
|
||||
import SidebarLink from '@/components/SidebarLink.vue'
|
||||
import { useStorage } from '@vueuse/core'
|
||||
import { ref, onMounted, inject, watch } from 'vue'
|
||||
import { getSidebarLinks } from '../utils'
|
||||
import { usersStore } from '@/stores/user'
|
||||
import { sessionStore } from '@/stores/session'
|
||||
import { ChevronRight, Plus } from 'lucide-vue-next'
|
||||
import { createResource, Button } from 'frappe-ui'
|
||||
import PageModal from '@/components/Modals/PageModal.vue'
|
||||
|
||||
const { user, 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 isInstructor = 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 addQuizzes = () => {
|
||||
if (isInstructor.value || isModerator.value) {
|
||||
sidebarLinks.value.push({
|
||||
label: 'Quizzes',
|
||||
icon: 'CircleHelp',
|
||||
to: 'Quizzes',
|
||||
activeFor: ['Quizzes', 'QuizForm'],
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
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 = () => {
|
||||
return useStorage('sidebar_is_collapsed', false)
|
||||
}
|
||||
|
||||
watch(userResource, () => {
|
||||
if (userResource.data) {
|
||||
isModerator.value = userResource.data.is_moderator
|
||||
isInstructor.value = userResource.data.is_instructor
|
||||
addQuizzes()
|
||||
}
|
||||
})
|
||||
|
||||
let isSidebarCollapsed = ref(getSidebarFromStorage())
|
||||
</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>
|
||||
196
frontend/src/components/Assessments.vue
Normal file
196
frontend/src/components/Assessments.vue
Normal file
@@ -0,0 +1,196 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="text-lg font-semibold mb-4">
|
||||
{{ __('Assessments') }}
|
||||
</div>
|
||||
<Button v-if="canSeeAddButton()" @click="showModal = true">
|
||||
<template #prefix>
|
||||
<Plus class="h-4 w-4" />
|
||||
</template>
|
||||
{{ __('Add') }}
|
||||
</Button>
|
||||
</div>
|
||||
<div v-if="assessments.data?.length">
|
||||
<ListView
|
||||
:columns="getAssessmentColumns()"
|
||||
:rows="assessments.data"
|
||||
row-key="name"
|
||||
:options="{
|
||||
showTooltip: false,
|
||||
getRowRoute: (row) => getRowRoute(row),
|
||||
}"
|
||||
>
|
||||
<ListHeader
|
||||
class="mb-2 grid items-center space-x-4 rounded bg-gray-100 p-2"
|
||||
>
|
||||
<ListHeaderItem :item="item" v-for="item in getAssessmentColumns()">
|
||||
<template #prefix="{ item }">
|
||||
<component
|
||||
v-if="item.icon"
|
||||
:is="item.icon"
|
||||
class="h-4 w-4 stroke-1.5 ml-4"
|
||||
/>
|
||||
</template>
|
||||
</ListHeaderItem>
|
||||
</ListHeader>
|
||||
<ListRows>
|
||||
<ListRow :row="row" v-for="row in assessments.data">
|
||||
<template #default="{ column, item }">
|
||||
<ListRowItem :item="row[column.key]" :align="column.align">
|
||||
<div>
|
||||
{{ row[column.key] }}
|
||||
</div>
|
||||
</ListRowItem>
|
||||
</template>
|
||||
</ListRow>
|
||||
</ListRows>
|
||||
<ListSelectBanner>
|
||||
<template #actions="{ unselectAll, selections }">
|
||||
<div class="flex gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
@click="removeAssessments(selections, unselectAll)"
|
||||
>
|
||||
<Trash2 class="h-4 w-4 stroke-1.5" />
|
||||
</Button>
|
||||
</div>
|
||||
</template>
|
||||
</ListSelectBanner>
|
||||
</ListView>
|
||||
</div>
|
||||
<div v-else class="text-sm italic text-gray-600">
|
||||
{{ __('No Assessments') }}
|
||||
</div>
|
||||
</div>
|
||||
<AssessmentModal
|
||||
v-model="showModal"
|
||||
v-model:assessments="assessments"
|
||||
:batch="props.batch"
|
||||
/>
|
||||
</template>
|
||||
<script setup>
|
||||
import {
|
||||
ListView,
|
||||
ListRow,
|
||||
ListRows,
|
||||
ListHeader,
|
||||
ListHeaderItem,
|
||||
ListRowItem,
|
||||
ListSelectBanner,
|
||||
createResource,
|
||||
Button,
|
||||
} from 'frappe-ui'
|
||||
import { inject, ref } from 'vue'
|
||||
import AssessmentModal from '@/components/Modals/AssessmentModal.vue'
|
||||
import { Plus, Trash2 } from 'lucide-vue-next'
|
||||
|
||||
const user = inject('$user')
|
||||
const showModal = ref(false)
|
||||
|
||||
const props = defineProps({
|
||||
batch: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
rows: {
|
||||
type: Array,
|
||||
},
|
||||
columns: {
|
||||
type: Array,
|
||||
},
|
||||
options: {
|
||||
type: Object,
|
||||
default: () => ({
|
||||
selectable: true,
|
||||
totalCount: 0,
|
||||
rowCount: 0,
|
||||
}),
|
||||
},
|
||||
})
|
||||
|
||||
const assessments = createResource({
|
||||
url: 'lms.lms.utils.get_assessments',
|
||||
params: {
|
||||
batch: props.batch,
|
||||
},
|
||||
auto: true,
|
||||
})
|
||||
|
||||
const deleteAssessments = createResource({
|
||||
url: 'lms.lms.api.delete_documents',
|
||||
makeParams(values) {
|
||||
return {
|
||||
doctype: 'LMS Assessment',
|
||||
documents: values.assessments,
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
const removeAssessments = (selections, unselectAll) => {
|
||||
deleteAssessments.submit(
|
||||
{ assessments: Array.from(selections) },
|
||||
{
|
||||
onSuccess(data) {
|
||||
assessments.reload()
|
||||
unselectAll()
|
||||
},
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
const getRowRoute = (row) => {
|
||||
if (row.assessment_type == 'LMS Assignment') {
|
||||
if (row.submission) {
|
||||
return {
|
||||
name: 'AssignmentSubmission',
|
||||
params: {
|
||||
assignmentName: row.assessment_name,
|
||||
submissionName: row.submission.name,
|
||||
},
|
||||
}
|
||||
} else {
|
||||
return {
|
||||
name: 'AssignmentSubmission',
|
||||
params: {
|
||||
assignmentName: row.assessment_name,
|
||||
submissionName: 'new',
|
||||
},
|
||||
}
|
||||
}
|
||||
} else {
|
||||
return {
|
||||
name: 'QuizPage',
|
||||
params: {
|
||||
quizID: row.assessment_name,
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const canSeeAddButton = () => {
|
||||
return user.data?.is_moderator || user.data?.is_evaluator
|
||||
}
|
||||
|
||||
const getAssessmentColumns = () => {
|
||||
let columns = [
|
||||
{
|
||||
label: 'Assessment',
|
||||
key: 'title',
|
||||
},
|
||||
{
|
||||
label: 'Type',
|
||||
key: 'assessment_type',
|
||||
},
|
||||
]
|
||||
|
||||
if (!user.data?.is_moderator) {
|
||||
columns.push({
|
||||
label: 'Status/Score',
|
||||
key: 'status',
|
||||
align: 'center',
|
||||
})
|
||||
}
|
||||
return columns
|
||||
}
|
||||
</script>
|
||||
134
frontend/src/components/AudioBlock.vue
Normal file
134
frontend/src/components/AudioBlock.vue
Normal file
@@ -0,0 +1,134 @@
|
||||
<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')
|
||||
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>
|
||||
109
frontend/src/components/BatchCard.vue
Normal file
109
frontend/src/components/BatchCard.vue
Normal file
@@ -0,0 +1,109 @@
|
||||
<template>
|
||||
<div
|
||||
class="flex flex-col shadow hover:bg-gray-100 rounded-md p-4 h-full"
|
||||
style="min-height: 150px"
|
||||
>
|
||||
<div class="text-lg leading-5 font-semibold mb-2">
|
||||
{{ batch.title }}
|
||||
</div>
|
||||
<Badge
|
||||
v-if="batch.seat_count && batch.seats_left > 0"
|
||||
theme="green"
|
||||
class="self-start mb-2"
|
||||
>
|
||||
{{ batch.seats_left }}
|
||||
<span v-if="batch.seats_left > 1">{{ __('Seats Left') }}</span
|
||||
><span v-else-if="batch.seats_left == 1">{{ __('Seat Left') }}</span>
|
||||
</Badge>
|
||||
<Badge
|
||||
v-else-if="batch.seat_count && batch.seats_left <= 0"
|
||||
theme="red"
|
||||
class="self-start mb-2"
|
||||
>
|
||||
{{ __('Sold Out') }}
|
||||
</Badge>
|
||||
<div class="short-introduction text-sm text-gray-700">
|
||||
{{ batch.description }}
|
||||
</div>
|
||||
<div v-if="batch.amount" class="font-semibold mb-4">
|
||||
{{ batch.price }}
|
||||
</div>
|
||||
<div class="flex flex-col space-y-2 mt-auto">
|
||||
<DateRange
|
||||
:startDate="batch.start_date"
|
||||
:endDate="batch.end_date"
|
||||
class="text-sm text-gray-700"
|
||||
/>
|
||||
<div class="flex items-center text-sm text-gray-700">
|
||||
<Clock class="h-4 w-4 stroke-1.5 mr-2 text-gray-700" />
|
||||
<span>
|
||||
{{ formatTime(batch.start_time) }} - {{ formatTime(batch.end_time) }}
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
v-if="batch.timezone"
|
||||
class="flex items-center text-sm text-gray-700"
|
||||
>
|
||||
<Globe class="h-4 w-4 stroke-1.5 mr-2 text-gray-600" />
|
||||
<span>
|
||||
{{ batch.timezone }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<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>
|
||||
</template>
|
||||
<script setup>
|
||||
import { Badge } from 'frappe-ui'
|
||||
import { formatTime } from '../utils'
|
||||
import { Clock, BookOpen, Globe } from 'lucide-vue-next'
|
||||
import DateRange from '@/components/Common/DateRange.vue'
|
||||
import CourseInstructors from '@/components/CourseInstructors.vue'
|
||||
import UserAvatar from '@/components/UserAvatar.vue'
|
||||
|
||||
const props = defineProps({
|
||||
batch: {
|
||||
type: Object,
|
||||
default: null,
|
||||
},
|
||||
})
|
||||
</script>
|
||||
<style>
|
||||
.short-introduction {
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
text-overflow: ellipsis;
|
||||
width: 100%;
|
||||
overflow: hidden;
|
||||
margin: 0.25rem 0 1rem;
|
||||
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>
|
||||
160
frontend/src/components/BatchCourses.vue
Normal file
160
frontend/src/components/BatchCourses.vue
Normal file
@@ -0,0 +1,160 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<div class="text-xl font-semibold">
|
||||
{{ __('Courses') }}
|
||||
</div>
|
||||
<Button v-if="canSeeAddButton()" @click="openCourseModal()">
|
||||
<template #prefix>
|
||||
<Plus class="h-4 w-4" />
|
||||
</template>
|
||||
{{ __('Add') }}
|
||||
</Button>
|
||||
</div>
|
||||
<div v-if="courses.data?.length">
|
||||
<ListView
|
||||
:columns="getCoursesColumns()"
|
||||
:rows="courses.data"
|
||||
row-key="batch_course"
|
||||
:options="{
|
||||
showTooltip: false,
|
||||
getRowRoute: (row) => ({
|
||||
name: 'CourseDetail',
|
||||
params: { courseName: row.name },
|
||||
}),
|
||||
}"
|
||||
>
|
||||
<ListHeader
|
||||
class="mb-2 grid items-center space-x-4 rounded bg-gray-100 p-2"
|
||||
>
|
||||
<ListHeaderItem :item="item" v-for="item in getCoursesColumns()">
|
||||
<template #prefix="{ item }">
|
||||
<component
|
||||
v-if="item.icon"
|
||||
:is="item.icon"
|
||||
class="h-4 w-4 stroke-1.5 ml-4"
|
||||
/>
|
||||
</template>
|
||||
</ListHeaderItem>
|
||||
</ListHeader>
|
||||
<ListRows>
|
||||
<ListRow :row="row" v-for="row in courses.data">
|
||||
<template #default="{ column, item }">
|
||||
<ListRowItem :item="row[column.key]" :align="column.align">
|
||||
<div>
|
||||
{{ row[column.key] }}
|
||||
</div>
|
||||
</ListRowItem>
|
||||
</template>
|
||||
</ListRow>
|
||||
</ListRows>
|
||||
<ListSelectBanner>
|
||||
<template #actions="{ unselectAll, selections }">
|
||||
<div class="flex gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
@click="removeCourses(selections, unselectAll)"
|
||||
>
|
||||
<Trash2 class="h-4 w-4 stroke-1.5" />
|
||||
</Button>
|
||||
</div>
|
||||
</template>
|
||||
</ListSelectBanner>
|
||||
</ListView>
|
||||
</div>
|
||||
<BatchCourseModal
|
||||
v-model="showCourseModal"
|
||||
:batch="batch"
|
||||
v-model:courses="courses"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
<script setup>
|
||||
import { ref, inject } from 'vue'
|
||||
import BatchCourseModal from '@/components/Modals/BatchCourseModal.vue'
|
||||
import {
|
||||
createResource,
|
||||
Button,
|
||||
ListHeader,
|
||||
ListHeaderItem,
|
||||
ListSelectBanner,
|
||||
ListRow,
|
||||
ListRows,
|
||||
ListView,
|
||||
ListRowItem,
|
||||
} from 'frappe-ui'
|
||||
import { Plus, Trash2 } from 'lucide-vue-next'
|
||||
import { showToast } from '@/utils'
|
||||
|
||||
const showCourseModal = ref(false)
|
||||
const user = inject('$user')
|
||||
|
||||
const props = defineProps({
|
||||
batch: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
})
|
||||
|
||||
const courses = createResource({
|
||||
url: 'lms.lms.utils.get_batch_courses',
|
||||
params: {
|
||||
batch: props.batch,
|
||||
},
|
||||
cache: ['batchCourses', props.batchName],
|
||||
auto: true,
|
||||
})
|
||||
|
||||
const openCourseModal = () => {
|
||||
showCourseModal.value = true
|
||||
}
|
||||
|
||||
const getCoursesColumns = () => {
|
||||
return [
|
||||
{
|
||||
label: 'Title',
|
||||
key: 'title',
|
||||
width: 2,
|
||||
},
|
||||
{
|
||||
label: 'Lessons',
|
||||
key: 'lesson_count',
|
||||
align: 'right',
|
||||
},
|
||||
{
|
||||
label: 'Enrollments',
|
||||
align: 'right',
|
||||
key: 'enrollment_count',
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
const deleteCourses = createResource({
|
||||
url: 'lms.lms.api.delete_documents',
|
||||
makeParams(values) {
|
||||
return {
|
||||
doctype: 'Batch Course',
|
||||
documents: values.courses,
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
const removeCourses = (selections, unselectAll) => {
|
||||
deleteCourses.submit(
|
||||
{
|
||||
courses: Array.from(selections),
|
||||
},
|
||||
{
|
||||
onSuccess(data) {
|
||||
courses.reload()
|
||||
showToast(__('Success'), __('Courses deleted successfully'), 'check')
|
||||
unselectAll()
|
||||
},
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
const canSeeAddButton = () => {
|
||||
return user.data?.is_moderator || user.data?.is_evaluator
|
||||
}
|
||||
</script>
|
||||
26
frontend/src/components/BatchDashboard.vue
Normal file
26
frontend/src/components/BatchDashboard.vue
Normal file
@@ -0,0 +1,26 @@
|
||||
<template>
|
||||
<div>
|
||||
<UpcomingEvaluations
|
||||
:batch="batch.data.name"
|
||||
:endDate="batch.data.evaluation_end_date"
|
||||
:courses="batch.data.courses"
|
||||
:isStudent="isStudent"
|
||||
/>
|
||||
<Assessments :batch="batch.data.name" />
|
||||
</div>
|
||||
</template>
|
||||
<script setup>
|
||||
import UpcomingEvaluations from '@/components/UpcomingEvaluations.vue'
|
||||
import Assessments from '@/components/Assessments.vue'
|
||||
|
||||
const props = defineProps({
|
||||
batch: {
|
||||
type: Object,
|
||||
default: null,
|
||||
},
|
||||
isStudent: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
})
|
||||
</script>
|
||||
164
frontend/src/components/BatchOverlay.vue
Normal file
164
frontend/src/components/BatchOverlay.vue
Normal file
@@ -0,0 +1,164 @@
|
||||
<template>
|
||||
<div v-if="batch.data" class="shadow rounded-md p-5 lg:w-72">
|
||||
<Badge
|
||||
v-if="batch.data.seat_count && seats_left > 0"
|
||||
theme="green"
|
||||
class="self-start mb-2 float-right"
|
||||
>
|
||||
{{ seats_left }} <span v-if="seats_left > 1">{{ __('Seats Left') }}</span
|
||||
><span v-else-if="seats_left == 1">{{ __('Seat Left') }}</span>
|
||||
</Badge>
|
||||
<Badge
|
||||
v-else-if="batch.data.seat_count && seats_left <= 0"
|
||||
theme="red"
|
||||
class="self-start mb-2 float-right"
|
||||
>
|
||||
{{ __('Sold Out') }}
|
||||
</Badge>
|
||||
<div v-if="batch.data.amount" class="text-lg font-semibold mb-3">
|
||||
{{ formatNumberIntoCurrency(batch.data.amount, batch.data.currency) }}
|
||||
</div>
|
||||
<div class="flex items-center mb-3">
|
||||
<BookOpen class="h-4 w-4 stroke-1.5 mr-2 text-gray-700" />
|
||||
<span> {{ batch.data.courses.length }} {{ __('Courses') }} </span>
|
||||
</div>
|
||||
<DateRange
|
||||
:startDate="batch.data.start_date"
|
||||
:endDate="batch.data.end_date"
|
||||
class="mb-3"
|
||||
/>
|
||||
<div class="flex items-center mb-3">
|
||||
<Clock class="h-4 w-4 stroke-1.5 mr-2 text-gray-700" />
|
||||
<span>
|
||||
{{ formatTime(batch.data.start_time) }} -
|
||||
{{ formatTime(batch.data.end_time) }}
|
||||
</span>
|
||||
</div>
|
||||
<div v-if="batch.data.timezone" class="flex items-center">
|
||||
<Globe class="h-4 w-4 stroke-1.5 mr-2 text-gray-700" />
|
||||
<span>
|
||||
{{ batch.data.timezone }}
|
||||
</span>
|
||||
</div>
|
||||
<router-link
|
||||
v-if="isModerator || isStudent"
|
||||
:to="{
|
||||
name: 'Batch',
|
||||
params: {
|
||||
batchName: batch.data.name,
|
||||
},
|
||||
}"
|
||||
>
|
||||
<Button variant="solid" class="w-full mt-4">
|
||||
<span>
|
||||
{{ isModerator ? __('Manage Batch') : __('Visit Batch') }}
|
||||
</span>
|
||||
</Button>
|
||||
</router-link>
|
||||
<router-link
|
||||
:to="{
|
||||
name: 'Billing',
|
||||
params: {
|
||||
type: 'batch',
|
||||
name: batch.data.name,
|
||||
},
|
||||
}"
|
||||
v-else-if="batch.data.paid_batch && batch.data.seats_left"
|
||||
>
|
||||
<Button v-if="!isStudent" class="w-full mt-4" variant="solid">
|
||||
<span>
|
||||
{{ __('Register Now') }}
|
||||
</span>
|
||||
</Button>
|
||||
</router-link>
|
||||
<Button
|
||||
variant="solid"
|
||||
class="w-full mt-2"
|
||||
v-else-if="batch.data.allow_self_enrollment && batch.data.seats_left"
|
||||
@click="enrollInBatch()"
|
||||
>
|
||||
{{ __('Enroll Now') }}
|
||||
</Button>
|
||||
<router-link
|
||||
v-if="isModerator"
|
||||
:to="{
|
||||
name: 'BatchForm',
|
||||
params: {
|
||||
batchName: batch.data.name,
|
||||
},
|
||||
}"
|
||||
>
|
||||
<Button class="w-full mt-2">
|
||||
<span>
|
||||
{{ __('Edit') }}
|
||||
</span>
|
||||
</Button>
|
||||
</router-link>
|
||||
</div>
|
||||
</template>
|
||||
<script setup>
|
||||
import { inject, computed } from 'vue'
|
||||
import { Badge, Button, createResource } from 'frappe-ui'
|
||||
import { BookOpen, Clock, Globe } from 'lucide-vue-next'
|
||||
import { formatNumberIntoCurrency, formatTime, showToast } from '@/utils'
|
||||
import DateRange from '@/components/Common/DateRange.vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
const router = useRouter()
|
||||
const user = inject('$user')
|
||||
|
||||
const props = defineProps({
|
||||
batch: {
|
||||
type: Object,
|
||||
default: null,
|
||||
},
|
||||
})
|
||||
|
||||
const enroll = createResource({
|
||||
url: 'lms.lms.utils.enroll_in_batch',
|
||||
makeParams(values) {
|
||||
return {
|
||||
batch: props.batch.data.name,
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
const enrollInBatch = () => {
|
||||
if (!user.data) {
|
||||
window.location.href = `/login?redirect-to=/batches/details/${props.batch.data.name}`
|
||||
}
|
||||
enroll.submit(
|
||||
{},
|
||||
{
|
||||
onSuccess(data) {
|
||||
showToast(
|
||||
__('Success'),
|
||||
__('You have been enrolled in this batch'),
|
||||
'check'
|
||||
)
|
||||
router.push({
|
||||
name: 'Batch',
|
||||
params: {
|
||||
batchName: props.batch.data.name,
|
||||
},
|
||||
})
|
||||
},
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
const seats_left = computed(() => {
|
||||
if (props.batch.data?.seat_count) {
|
||||
return props.batch.data?.seat_count - props.batch.data?.students?.length
|
||||
}
|
||||
return null
|
||||
})
|
||||
|
||||
const isStudent = computed(() => {
|
||||
return props.batch.data?.students?.includes(user.data?.name)
|
||||
})
|
||||
|
||||
const isModerator = computed(() => {
|
||||
return user.data?.is_moderator
|
||||
})
|
||||
</script>
|
||||
163
frontend/src/components/BatchStudents.vue
Normal file
163
frontend/src/components/BatchStudents.vue
Normal file
@@ -0,0 +1,163 @@
|
||||
<template>
|
||||
<Button class="float-right mb-3" @click="openStudentModal()">
|
||||
<template #prefix>
|
||||
<Plus class="h-4 w-4" />
|
||||
</template>
|
||||
{{ __('Add') }}
|
||||
</Button>
|
||||
<div class="text-lg font-semibold mb-4">
|
||||
{{ __('Students') }}
|
||||
</div>
|
||||
<div v-if="students.data?.length">
|
||||
<ListView
|
||||
:columns="getStudentColumns()"
|
||||
:rows="students.data"
|
||||
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 getStudentColumns()">
|
||||
<template #prefix="{ item }">
|
||||
<component
|
||||
v-if="item.icon"
|
||||
:is="item.icon"
|
||||
class="h-4 w-4 stroke-1.5 ml-4"
|
||||
/>
|
||||
</template>
|
||||
</ListHeaderItem>
|
||||
</ListHeader>
|
||||
<ListRows>
|
||||
<ListRow :row="row" v-for="row in students.data">
|
||||
<template #default="{ column, item }">
|
||||
<ListRowItem :item="row[column.key]" :align="column.align">
|
||||
<template #prefix>
|
||||
<div v-if="column.key == 'full_name'">
|
||||
<Avatar
|
||||
class="flex items-center"
|
||||
:image="row['user_image']"
|
||||
:label="item"
|
||||
size="sm"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
<div>
|
||||
{{ row[column.key] }}
|
||||
</div>
|
||||
</ListRowItem>
|
||||
</template>
|
||||
</ListRow>
|
||||
</ListRows>
|
||||
<ListSelectBanner>
|
||||
<template #actions="{ unselectAll, selections }">
|
||||
<div class="flex gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
@click="removeStudents(selections, unselectAll)"
|
||||
>
|
||||
<Trash2 class="h-4 w-4 stroke-1.5" />
|
||||
</Button>
|
||||
</div>
|
||||
</template>
|
||||
</ListSelectBanner>
|
||||
</ListView>
|
||||
</div>
|
||||
<div v-else class="text-sm italic text-gray-600">
|
||||
{{ __('There are no students in this batch.') }}
|
||||
</div>
|
||||
<StudentModal
|
||||
:batch="props.batch"
|
||||
v-model="showStudentModal"
|
||||
v-model:reloadStudents="students"
|
||||
/>
|
||||
</template>
|
||||
<script setup>
|
||||
import {
|
||||
createResource,
|
||||
ListHeader,
|
||||
ListHeaderItem,
|
||||
ListSelectBanner,
|
||||
ListRow,
|
||||
ListRows,
|
||||
ListView,
|
||||
ListRowItem,
|
||||
Avatar,
|
||||
Button,
|
||||
} from 'frappe-ui'
|
||||
import { Trash2, Plus } from 'lucide-vue-next'
|
||||
import { ref } from 'vue'
|
||||
import StudentModal from '@/components/Modals/StudentModal.vue'
|
||||
import { showToast } from '@/utils'
|
||||
|
||||
const showStudentModal = ref(false)
|
||||
|
||||
const props = defineProps({
|
||||
batch: {
|
||||
type: String,
|
||||
default: null,
|
||||
},
|
||||
})
|
||||
|
||||
const students = createResource({
|
||||
url: 'lms.lms.utils.get_batch_students',
|
||||
cache: ['students', props.batch],
|
||||
params: {
|
||||
batch: props.batch,
|
||||
},
|
||||
auto: true,
|
||||
})
|
||||
|
||||
const getStudentColumns = () => {
|
||||
return [
|
||||
{
|
||||
label: 'Full Name',
|
||||
key: 'full_name',
|
||||
width: 2,
|
||||
},
|
||||
{
|
||||
label: 'Courses Done',
|
||||
key: 'courses_completed',
|
||||
align: 'center',
|
||||
},
|
||||
{
|
||||
label: 'Assessments Done',
|
||||
key: 'assessments_completed',
|
||||
align: 'center',
|
||||
},
|
||||
{
|
||||
label: 'Last Active',
|
||||
key: 'last_active',
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
const openStudentModal = () => {
|
||||
showStudentModal.value = true
|
||||
}
|
||||
|
||||
const deleteStudents = createResource({
|
||||
url: 'lms.lms.api.delete_documents',
|
||||
makeParams(values) {
|
||||
return {
|
||||
doctype: 'Batch Student',
|
||||
documents: values.students,
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
const removeStudents = (selections, unselectAll) => {
|
||||
deleteStudents.submit(
|
||||
{
|
||||
students: Array.from(selections),
|
||||
},
|
||||
{
|
||||
onSuccess(data) {
|
||||
students.reload()
|
||||
showToast(__('Success'), __('Students deleted successfully'), 'check')
|
||||
unselectAll()
|
||||
},
|
||||
}
|
||||
)
|
||||
}
|
||||
</script>
|
||||
92
frontend/src/components/BrandSettings.vue
Normal file
92
frontend/src/components/BrandSettings.vue
Normal file
@@ -0,0 +1,92 @@
|
||||
<template>
|
||||
<div class="flex flex-col justify-between min-h-0">
|
||||
<div>
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="font-semibold mb-1">
|
||||
{{ __(label) }}
|
||||
</div>
|
||||
<Badge
|
||||
v-if="isDirty"
|
||||
:label="__('Not Saved')"
|
||||
variant="subtle"
|
||||
theme="orange"
|
||||
/>
|
||||
</div>
|
||||
<div class="text-xs text-gray-600">
|
||||
{{ __(description) }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="overflow-y-auto">
|
||||
<SettingFields :fields="fields" :data="data.data" />
|
||||
<div class="flex flex-row-reverse mt-auto">
|
||||
<Button variant="solid" :loading="saveSettings.loading" @click="update">
|
||||
{{ __('Update') }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<script setup>
|
||||
import { createResource, Button, Badge } from 'frappe-ui'
|
||||
import SettingFields from '@/components/SettingFields.vue'
|
||||
import { watch, ref } from 'vue'
|
||||
|
||||
const isDirty = ref(false)
|
||||
|
||||
const props = defineProps({
|
||||
fields: {
|
||||
type: Array,
|
||||
required: true,
|
||||
},
|
||||
data: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
label: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
description: {
|
||||
type: String,
|
||||
},
|
||||
})
|
||||
|
||||
const saveSettings = createResource({
|
||||
url: 'frappe.client.set_value',
|
||||
makeParams(values) {
|
||||
return {
|
||||
doctype: 'Website Settings',
|
||||
name: 'Website Settings',
|
||||
fieldname: values.fields,
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
const update = () => {
|
||||
let fieldsToSave = {}
|
||||
let imageFields = ['favicon', 'banner_image', 'footer_logo']
|
||||
props.fields.forEach((f) => {
|
||||
if (imageFields.includes(f.name)) {
|
||||
fieldsToSave[f.name] = f.value ? f.value.file_url : null
|
||||
} else {
|
||||
fieldsToSave[f.name] = f.value
|
||||
}
|
||||
})
|
||||
saveSettings.submit(
|
||||
{
|
||||
fields: fieldsToSave,
|
||||
},
|
||||
{
|
||||
onSuccess(data) {
|
||||
isDirty.value = false
|
||||
},
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
watch(props.data, (newData) => {
|
||||
if (newData && !isDirty.value) {
|
||||
isDirty.value = true
|
||||
}
|
||||
})
|
||||
</script>
|
||||
151
frontend/src/components/Categories.vue
Normal file
151
frontend/src/components/Categories.vue
Normal file
@@ -0,0 +1,151 @@
|
||||
<template>
|
||||
<div class="flex flex-col min-h-0">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="text-xl font-semibold mb-1">
|
||||
{{ label }}
|
||||
</div>
|
||||
<Button @click="() => showCategoryForm()">
|
||||
<template #icon>
|
||||
<Plus v-if="!showForm" class="h-3 w-3 stroke-1.5" />
|
||||
<X v-else class="h-3 w-3 stroke-1.5" />
|
||||
</template>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="showForm"
|
||||
class="flex items-center justify-between my-4 space-x-2"
|
||||
>
|
||||
<FormControl
|
||||
ref="categoryInput"
|
||||
v-model="category"
|
||||
:placeholder="__('Category Name')"
|
||||
class="flex-1"
|
||||
/>
|
||||
<Button @click="addCategory()" variant="subtle">
|
||||
{{ __('Add') }}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div class="overflow-y-scroll">
|
||||
<div class="text-base divide-y">
|
||||
<FormControl
|
||||
:value="cat.category"
|
||||
type="text"
|
||||
v-for="cat in categories.data"
|
||||
class="form-control"
|
||||
@change.stop="(e) => update(cat.name, e.target.value)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<script setup>
|
||||
import {
|
||||
Button,
|
||||
FormControl,
|
||||
createListResource,
|
||||
createResource,
|
||||
debounce,
|
||||
} from 'frappe-ui'
|
||||
import { Plus, X } from 'lucide-vue-next'
|
||||
import { ref } from 'vue'
|
||||
|
||||
const showForm = ref(false)
|
||||
const category = ref(null)
|
||||
const categoryInput = ref(null)
|
||||
|
||||
const props = defineProps({
|
||||
label: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
description: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
})
|
||||
|
||||
const categories = createListResource({
|
||||
doctype: 'LMS Category',
|
||||
fields: ['name', 'category'],
|
||||
auto: true,
|
||||
})
|
||||
|
||||
const newCategory = createResource({
|
||||
url: 'frappe.client.insert',
|
||||
makeParams(values) {
|
||||
return {
|
||||
doc: {
|
||||
doctype: 'LMS Category',
|
||||
category: category.value,
|
||||
},
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
const addCategory = () => {
|
||||
newCategory.submit(
|
||||
{},
|
||||
{
|
||||
onSuccess(data) {
|
||||
categories.reload()
|
||||
category.value = null
|
||||
},
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
const showCategoryForm = () => {
|
||||
showForm.value = !showForm.value
|
||||
setTimeout(() => {
|
||||
categoryInput.value.$el.querySelector('input').focus()
|
||||
}, 0)
|
||||
}
|
||||
|
||||
const updateCategory = createResource({
|
||||
url: 'frappe.client.rename_doc',
|
||||
makeParams(values) {
|
||||
return {
|
||||
doctype: 'LMS Category',
|
||||
old_name: values.name,
|
||||
new_name: values.category,
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
const update = (name, value) => {
|
||||
updateCategory.submit(
|
||||
{
|
||||
name: name,
|
||||
category: value,
|
||||
},
|
||||
{
|
||||
onSuccess() {
|
||||
categories.reload()
|
||||
},
|
||||
}
|
||||
)
|
||||
}
|
||||
</script>
|
||||
<style>
|
||||
.form-control input {
|
||||
padding: 1.25rem 0;
|
||||
border-color: transparent;
|
||||
background: white;
|
||||
}
|
||||
|
||||
.form-control input:focus {
|
||||
outline: transparent;
|
||||
background: white;
|
||||
box-shadow: none;
|
||||
border-color: transparent;
|
||||
}
|
||||
|
||||
.form-control input:hover {
|
||||
outline: transparent;
|
||||
background: white;
|
||||
box-shadow: none;
|
||||
border-color: transparent;
|
||||
}
|
||||
</style>
|
||||
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>
|
||||
286
frontend/src/components/Controls/Autocomplete.vue
Normal file
286
frontend/src/components/Controls/Autocomplete.vue
Normal file
@@ -0,0 +1,286 @@
|
||||
<template>
|
||||
<Combobox v-model="selectedValue" nullable v-slot="{ open: isComboboxOpen }">
|
||||
<Popover class="w-full" v-model:show="showOptions">
|
||||
<template #target="{ open: openPopover, togglePopover }">
|
||||
<slot name="target" v-bind="{ open: openPopover, togglePopover }">
|
||||
<div class="w-full">
|
||||
<button
|
||||
class="flex w-full items-center justify-between focus:outline-none"
|
||||
:class="inputClasses"
|
||||
@click="() => togglePopover()"
|
||||
>
|
||||
<div class="flex items-center">
|
||||
<slot name="prefix" />
|
||||
<span
|
||||
class="overflow-hidden text-ellipsis whitespace-nowrap text-base leading-5"
|
||||
v-if="selectedValue"
|
||||
>
|
||||
{{ displayValue(selectedValue) }}
|
||||
</span>
|
||||
<span class="text-base leading-5 text-gray-500" v-else>
|
||||
{{ placeholder || '' }}
|
||||
</span>
|
||||
</div>
|
||||
<ChevronDown class="h-4 w-4 stroke-1.5" />
|
||||
</button>
|
||||
</div>
|
||||
</slot>
|
||||
</template>
|
||||
<template #body="{ isOpen }">
|
||||
<div v-show="isOpen">
|
||||
<div class="mt-1 rounded-lg bg-white py-1 text-base shadow-2xl">
|
||||
<div class="relative px-1.5 pt-0.5">
|
||||
<ComboboxInput
|
||||
ref="search"
|
||||
class="form-input w-full"
|
||||
type="text"
|
||||
@change="
|
||||
(e) => {
|
||||
query = e.target.value
|
||||
}
|
||||
"
|
||||
:value="query"
|
||||
autocomplete="off"
|
||||
placeholder="Search"
|
||||
/>
|
||||
<button
|
||||
class="absolute right-1.5 inline-flex h-7 w-7 items-center justify-center"
|
||||
@click="selectedValue = null"
|
||||
>
|
||||
<X class="h-4 w-4 stroke-1.5" />
|
||||
</button>
|
||||
</div>
|
||||
<ComboboxOptions
|
||||
class="my-1 max-h-[12rem] overflow-y-auto px-1.5"
|
||||
static
|
||||
>
|
||||
<div
|
||||
class="mt-1.5"
|
||||
v-for="group in groups"
|
||||
:key="group.key"
|
||||
v-show="group.items.length > 0"
|
||||
>
|
||||
<div
|
||||
v-if="group.group && !group.hideLabel"
|
||||
class="px-2.5 py-1.5 text-sm font-medium text-gray-500"
|
||||
>
|
||||
{{ group.group }}
|
||||
</div>
|
||||
<ComboboxOption
|
||||
as="template"
|
||||
v-for="option in group.items"
|
||||
:key="option.value"
|
||||
:value="option"
|
||||
v-slot="{ active, selected }"
|
||||
>
|
||||
<li
|
||||
:class="[
|
||||
'flex items-center rounded px-2.5 py-2 text-base',
|
||||
{ 'bg-gray-100': active },
|
||||
]"
|
||||
>
|
||||
<slot
|
||||
name="item-prefix"
|
||||
v-bind="{ active, selected, option }"
|
||||
/>
|
||||
<slot
|
||||
name="item-label"
|
||||
v-bind="{ active, selected, option }"
|
||||
>
|
||||
<div class="flex flex-col space-y-1">
|
||||
<div>
|
||||
{{ option.label }}
|
||||
</div>
|
||||
<div
|
||||
v-if="option.label != option.description"
|
||||
class="text-xs text-gray-700"
|
||||
v-html="option.description"
|
||||
></div>
|
||||
</div>
|
||||
</slot>
|
||||
</li>
|
||||
</ComboboxOption>
|
||||
</div>
|
||||
<li
|
||||
v-if="groups.length == 0"
|
||||
class="mt-1.5 rounded-md px-2.5 py-1.5 text-base text-gray-600"
|
||||
>
|
||||
No results found
|
||||
</li>
|
||||
</ComboboxOptions>
|
||||
<div v-if="slots.footer" class="border-t p-1.5 pb-0.5">
|
||||
<slot
|
||||
name="footer"
|
||||
v-bind="{ value: search?.el._value, close }"
|
||||
></slot>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</Popover>
|
||||
</Combobox>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import {
|
||||
Combobox,
|
||||
ComboboxInput,
|
||||
ComboboxOptions,
|
||||
ComboboxOption,
|
||||
} from '@headlessui/vue'
|
||||
import { Popover, Button } from 'frappe-ui'
|
||||
import { ChevronDown, X } from 'lucide-vue-next'
|
||||
import { ref, computed, useAttrs, useSlots, watch, nextTick } from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
options: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
size: {
|
||||
type: String,
|
||||
default: 'md',
|
||||
},
|
||||
variant: {
|
||||
type: String,
|
||||
default: 'subtle',
|
||||
},
|
||||
placeholder: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
disabled: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
filterable: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
})
|
||||
const emit = defineEmits(['update:modelValue', 'update:query', 'change'])
|
||||
|
||||
const query = ref('')
|
||||
const showOptions = ref(false)
|
||||
const search = ref(null)
|
||||
|
||||
const attrs = useAttrs()
|
||||
const slots = useSlots()
|
||||
|
||||
const valuePropPassed = computed(() => 'value' in attrs)
|
||||
|
||||
const selectedValue = computed({
|
||||
get() {
|
||||
return valuePropPassed.value ? attrs.value : props.modelValue
|
||||
},
|
||||
set(val) {
|
||||
query.value = ''
|
||||
if (val) {
|
||||
showOptions.value = false
|
||||
}
|
||||
emit(valuePropPassed.value ? 'change' : 'update:modelValue', val)
|
||||
},
|
||||
})
|
||||
|
||||
function close() {
|
||||
showOptions.value = false
|
||||
}
|
||||
|
||||
const groups = computed(() => {
|
||||
if (!props.options || props.options.length == 0) return []
|
||||
|
||||
let groups = props.options[0]?.group
|
||||
? props.options
|
||||
: [{ group: '', items: props.options }]
|
||||
|
||||
return groups
|
||||
.map((group, i) => {
|
||||
return {
|
||||
key: i,
|
||||
group: group.group,
|
||||
hideLabel: group.hideLabel || false,
|
||||
items: props.filterable ? filterOptions(group.items) : group.items,
|
||||
}
|
||||
})
|
||||
.filter((group) => group.items.length > 0)
|
||||
})
|
||||
|
||||
function filterOptions(options) {
|
||||
if (!query.value) {
|
||||
return options
|
||||
}
|
||||
return options.filter((option) => {
|
||||
let searchTexts = [option.label, option.value]
|
||||
return searchTexts.some((text) =>
|
||||
(text || '').toString().toLowerCase().includes(query.value.toLowerCase())
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
function displayValue(option) {
|
||||
if (typeof option === 'string') {
|
||||
let allOptions = groups.value.flatMap((group) => group.items)
|
||||
let selectedOption = allOptions.find((o) => o.value === option)
|
||||
return selectedOption?.label || option
|
||||
}
|
||||
return option?.label
|
||||
}
|
||||
|
||||
watch(query, (q) => {
|
||||
emit('update:query', q)
|
||||
})
|
||||
|
||||
watch(showOptions, (val) => {
|
||||
if (val) {
|
||||
nextTick(() => {
|
||||
search.value.el.focus()
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
const textColor = computed(() => {
|
||||
return props.disabled ? 'text-gray-600' : 'text-gray-800'
|
||||
})
|
||||
|
||||
const inputClasses = computed(() => {
|
||||
let sizeClasses = {
|
||||
sm: 'text-base rounded h-7',
|
||||
md: 'text-base rounded h-8',
|
||||
lg: 'text-lg rounded-md h-10',
|
||||
xl: 'text-xl rounded-md h-10',
|
||||
}[props.size]
|
||||
|
||||
let paddingClasses = {
|
||||
sm: 'py-1.5 px-2',
|
||||
md: 'py-1.5 px-2.5',
|
||||
lg: 'py-1.5 px-3',
|
||||
xl: 'py-1.5 px-3',
|
||||
}[props.size]
|
||||
|
||||
let variant = props.disabled ? 'disabled' : props.variant
|
||||
let variantClasses = {
|
||||
subtle:
|
||||
'border border-gray-100 bg-gray-100 placeholder-gray-500 hover:border-gray-200 hover:bg-gray-200 focus:bg-white focus:border-gray-500 focus:shadow-sm focus:ring-0 focus-visible:ring-2 focus-visible:ring-gray-400',
|
||||
outline:
|
||||
'border border-gray-300 bg-white placeholder-gray-500 hover:border-gray-400 hover:shadow-sm focus:bg-white focus:border-gray-500 focus:shadow-sm focus:ring-0 focus-visible:ring-2 focus-visible:ring-gray-400',
|
||||
disabled: [
|
||||
'border bg-gray-50 placeholder-gray-400',
|
||||
props.variant === 'outline' ? 'border-gray-300' : 'border-transparent',
|
||||
],
|
||||
}[variant]
|
||||
|
||||
return [
|
||||
sizeClasses,
|
||||
paddingClasses,
|
||||
variantClasses,
|
||||
textColor.value,
|
||||
'transition-colors w-full',
|
||||
]
|
||||
})
|
||||
|
||||
defineExpose({ query })
|
||||
</script>
|
||||
204
frontend/src/components/Controls/CodeEditor.vue
Normal file
204
frontend/src/components/Controls/CodeEditor.vue
Normal file
@@ -0,0 +1,204 @@
|
||||
<template>
|
||||
<div
|
||||
class="editor flex flex-col gap-1"
|
||||
:style="{
|
||||
height: height,
|
||||
}"
|
||||
>
|
||||
<span class="text-xs" v-if="label">
|
||||
{{ label }}
|
||||
</span>
|
||||
<div
|
||||
ref="editor"
|
||||
class="h-auto flex-1 overflow-hidden overscroll-none !rounded border border-outline-gray-2 bg-surface-gray-2 dark:bg-gray-900"
|
||||
/>
|
||||
<span
|
||||
class="mt-1 text-xs text-gray-600"
|
||||
v-show="description"
|
||||
v-html="description"
|
||||
></span>
|
||||
<Button
|
||||
v-if="showSaveButton"
|
||||
@click="emit('save', aceEditor?.getValue())"
|
||||
class="mt-3"
|
||||
>
|
||||
{{ __('Save') }}
|
||||
</Button>
|
||||
</div>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import { useDark } from '@vueuse/core'
|
||||
import ace from 'ace-builds'
|
||||
import 'ace-builds/src-min-noconflict/ext-searchbox'
|
||||
import 'ace-builds/src-min-noconflict/theme-chrome'
|
||||
import 'ace-builds/src-min-noconflict/theme-twilight'
|
||||
import { PropType, onMounted, ref, watch } from 'vue'
|
||||
import { Button } from 'frappe-ui'
|
||||
|
||||
const isDark = useDark({
|
||||
attribute: 'data-theme',
|
||||
})
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: {
|
||||
type: [Object, String, Array],
|
||||
},
|
||||
type: {
|
||||
type: String as PropType<'JSON' | 'HTML' | 'Python' | 'JavaScript' | 'CSS'>,
|
||||
default: 'JSON',
|
||||
},
|
||||
label: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
readonly: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
height: {
|
||||
type: String,
|
||||
default: '250px',
|
||||
},
|
||||
showLineNumbers: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
autofocus: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
showSaveButton: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
description: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
})
|
||||
|
||||
const emit = defineEmits(['save', 'update:modelValue'])
|
||||
const editor = ref<HTMLElement | null>(null)
|
||||
let aceEditor = null as ace.Ace.Editor | null
|
||||
|
||||
onMounted(() => {
|
||||
setupEditor()
|
||||
})
|
||||
|
||||
const setupEditor = () => {
|
||||
aceEditor = ace.edit(editor.value as HTMLElement)
|
||||
resetEditor(props.modelValue as string, true)
|
||||
aceEditor.setReadOnly(props.readonly)
|
||||
aceEditor.setOptions({
|
||||
fontSize: '12px',
|
||||
useWorker: false,
|
||||
showGutter: props.showLineNumbers,
|
||||
wrap: props.showLineNumbers,
|
||||
})
|
||||
if (props.type === 'CSS') {
|
||||
import('ace-builds/src-noconflict/mode-css').then(() => {
|
||||
aceEditor?.session.setMode('ace/mode/css')
|
||||
})
|
||||
} else if (props.type === 'JavaScript') {
|
||||
import('ace-builds/src-noconflict/mode-javascript').then(() => {
|
||||
aceEditor?.session.setMode('ace/mode/javascript')
|
||||
})
|
||||
} else if (props.type === 'Python') {
|
||||
import('ace-builds/src-noconflict/mode-python').then(() => {
|
||||
aceEditor?.session.setMode('ace/mode/python')
|
||||
})
|
||||
} else if (props.type === 'JSON') {
|
||||
import('ace-builds/src-noconflict/mode-json').then(() => {
|
||||
aceEditor?.session.setMode('ace/mode/json')
|
||||
})
|
||||
} else {
|
||||
import('ace-builds/src-noconflict/mode-html').then(() => {
|
||||
aceEditor?.session.setMode('ace/mode/html')
|
||||
})
|
||||
}
|
||||
aceEditor.on('blur', () => {
|
||||
try {
|
||||
let value = aceEditor?.getValue() || ''
|
||||
if (props.type === 'JSON') {
|
||||
value = JSON.parse(value)
|
||||
}
|
||||
if (value === props.modelValue) return
|
||||
if (!props.showSaveButton && !props.readonly) {
|
||||
emit('update:modelValue', value)
|
||||
}
|
||||
} catch (e) {
|
||||
// do nothing
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const getModelValue = () => {
|
||||
let value = props.modelValue || ''
|
||||
try {
|
||||
if (props.type === 'JSON' || typeof value === 'object') {
|
||||
value = JSON.stringify(value, null, 2)
|
||||
}
|
||||
} catch (e) {
|
||||
// do nothing
|
||||
}
|
||||
return value as string
|
||||
}
|
||||
|
||||
function resetEditor(value: string, resetHistory = false) {
|
||||
value = getModelValue()
|
||||
aceEditor?.setValue(value)
|
||||
aceEditor?.clearSelection()
|
||||
aceEditor?.setTheme(isDark.value ? 'ace/theme/twilight' : 'ace/theme/chrome')
|
||||
props.autofocus && aceEditor?.focus()
|
||||
if (resetHistory) {
|
||||
aceEditor?.session.getUndoManager().reset()
|
||||
}
|
||||
}
|
||||
|
||||
watch(isDark, () => {
|
||||
aceEditor?.setTheme(isDark.value ? 'ace/theme/twilight' : 'ace/theme/chrome')
|
||||
})
|
||||
|
||||
watch(
|
||||
() => props.type,
|
||||
() => {
|
||||
setupEditor()
|
||||
}
|
||||
)
|
||||
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
() => {
|
||||
resetEditor(props.modelValue as string)
|
||||
}
|
||||
)
|
||||
|
||||
defineExpose({ resetEditor })
|
||||
</script>
|
||||
<style scoped>
|
||||
.editor .ace_editor {
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
border-radius: 5px;
|
||||
overscroll-behavior: none;
|
||||
}
|
||||
.editor :deep(.ace_scrollbar-h) {
|
||||
display: none;
|
||||
}
|
||||
.editor :deep(.ace_search) {
|
||||
@apply dark:bg-gray-800 dark:text-gray-200;
|
||||
@apply dark:border-gray-800;
|
||||
}
|
||||
.editor :deep(.ace_searchbtn) {
|
||||
@apply dark:bg-gray-800 dark:text-gray-200;
|
||||
@apply dark:border-gray-800;
|
||||
}
|
||||
.editor :deep(.ace_button) {
|
||||
@apply dark:bg-gray-800 dark:text-gray-200;
|
||||
}
|
||||
|
||||
.editor :deep(.ace_search_field) {
|
||||
@apply dark:bg-gray-900 dark:text-gray-200;
|
||||
@apply dark:border-gray-800;
|
||||
}
|
||||
</style>
|
||||
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>
|
||||
148
frontend/src/components/Controls/Link.vue
Normal file
148
frontend/src/components/Controls/Link.vue
Normal file
@@ -0,0 +1,148 @@
|
||||
<template>
|
||||
<div class="space-y-1.5">
|
||||
<label class="block" :class="labelClasses" v-if="attrs.label">
|
||||
{{ attrs.label }}
|
||||
</label>
|
||||
<Autocomplete
|
||||
ref="autocomplete"
|
||||
:options="options.data"
|
||||
v-model="value"
|
||||
:size="attrs.size || 'sm'"
|
||||
:variant="attrs.variant"
|
||||
:placeholder="attrs.placeholder"
|
||||
:filterable="false"
|
||||
>
|
||||
<template #target="{ open, togglePopover }">
|
||||
<slot name="target" v-bind="{ open, togglePopover }" />
|
||||
</template>
|
||||
|
||||
<template #prefix>
|
||||
<slot name="prefix" />
|
||||
</template>
|
||||
|
||||
<template #item-prefix="{ active, selected, option }">
|
||||
<slot name="item-prefix" v-bind="{ active, selected, option }" />
|
||||
</template>
|
||||
|
||||
<template #item-label="{ active, selected, option }">
|
||||
<slot name="item-label" v-bind="{ active, selected, option }" />
|
||||
</template>
|
||||
|
||||
<template v-if="attrs.onCreate" #footer="{ value, close }">
|
||||
<div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
class="w-full !justify-start"
|
||||
label="Create New"
|
||||
@click="attrs.onCreate(value, close)"
|
||||
>
|
||||
<template #prefix>
|
||||
<Plus class="h-4 w-4 stroke-1.5" />
|
||||
</template>
|
||||
</Button>
|
||||
</div>
|
||||
</template>
|
||||
</Autocomplete>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import Autocomplete from '@/components/Controls/Autocomplete.vue'
|
||||
import { watchDebounced } from '@vueuse/core'
|
||||
import { createResource, Button } from 'frappe-ui'
|
||||
import { Plus } from 'lucide-vue-next'
|
||||
import { useAttrs, computed, ref } from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
doctype: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
filters: {
|
||||
type: Object,
|
||||
default: () => ({}),
|
||||
},
|
||||
modelValue: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update:modelValue', 'change'])
|
||||
|
||||
const attrs = useAttrs()
|
||||
|
||||
const valuePropPassed = computed(() => 'value' in attrs)
|
||||
|
||||
const value = computed({
|
||||
get: () => (valuePropPassed.value ? attrs.value : props.modelValue),
|
||||
set: (val) => {
|
||||
return (
|
||||
val?.value &&
|
||||
emit(valuePropPassed.value ? 'change' : 'update:modelValue', val?.value)
|
||||
)
|
||||
},
|
||||
})
|
||||
|
||||
const autocomplete = ref(null)
|
||||
const text = ref('')
|
||||
|
||||
watchDebounced(
|
||||
() => autocomplete.value?.query,
|
||||
(val) => {
|
||||
val = val || ''
|
||||
if (text.value === val) return
|
||||
text.value = val
|
||||
reload(val)
|
||||
},
|
||||
{ debounce: 300, immediate: true }
|
||||
)
|
||||
|
||||
watchDebounced(
|
||||
() => props.doctype,
|
||||
() => reload(''),
|
||||
{ debounce: 300, immediate: true }
|
||||
)
|
||||
|
||||
const options = createResource({
|
||||
url: 'frappe.desk.search.search_link',
|
||||
cache: [props.doctype, text.value],
|
||||
method: 'POST',
|
||||
auto: true,
|
||||
params: {
|
||||
txt: text.value,
|
||||
doctype: props.doctype,
|
||||
filters: props.filters,
|
||||
},
|
||||
transform: (data) => {
|
||||
return data.map((option) => {
|
||||
return {
|
||||
label: option.value,
|
||||
value: option.value,
|
||||
description: option.description,
|
||||
}
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
function reload(val) {
|
||||
options.update({
|
||||
params: {
|
||||
txt: val,
|
||||
doctype: props.doctype,
|
||||
filters: props.filters,
|
||||
},
|
||||
})
|
||||
options.reload()
|
||||
}
|
||||
|
||||
const labelClasses = computed(() => {
|
||||
return [
|
||||
{
|
||||
sm: 'text-xs',
|
||||
md: 'text-base',
|
||||
}[attrs.size || 'sm'],
|
||||
'text-gray-600',
|
||||
]
|
||||
})
|
||||
</script>
|
||||
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>
|
||||
81
frontend/src/components/Controls/Rating.vue
Normal file
81
frontend/src/components/Controls/Rating.vue
Normal file
@@ -0,0 +1,81 @@
|
||||
<template>
|
||||
<div class="space-y-1">
|
||||
<label class="block text-xs text-gray-600" v-if="props.label">
|
||||
{{ props.label }}
|
||||
</label>
|
||||
<div class="flex text-center">
|
||||
<div
|
||||
v-for="index in 5"
|
||||
@mouseover="hoveredRating = index"
|
||||
@mouseleave="hoveredRating = 0"
|
||||
>
|
||||
<Star
|
||||
class="fill-gray-400 text-gray-50 stroke-1 mr-1 cursor-pointer"
|
||||
:class="iconClasses(index)"
|
||||
@click="markRating(index)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { Star } from 'lucide-vue-next'
|
||||
import { ref, watch } from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
id: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
modelValue: {
|
||||
type: Number,
|
||||
default: 0,
|
||||
},
|
||||
label: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
size: {
|
||||
type: String,
|
||||
default: 'md',
|
||||
},
|
||||
})
|
||||
|
||||
const iconClasses = (index) => {
|
||||
let classes = [
|
||||
{
|
||||
sm: 'size-4',
|
||||
md: 'size-5',
|
||||
lg: 'size-6',
|
||||
xl: 'size-7',
|
||||
}[props.size],
|
||||
]
|
||||
if (index <= hoveredRating.value && index > rating.value) {
|
||||
classes.push('fill-yellow-200')
|
||||
} else if (index <= rating.value) {
|
||||
classes.push('fill-yellow-500')
|
||||
}
|
||||
return classes.join(' ')
|
||||
}
|
||||
|
||||
const emit = defineEmits(['update:modelValue'])
|
||||
const rating = ref(props.modelValue)
|
||||
const hoveredRating = ref(0)
|
||||
|
||||
let emitChange = (value) => {
|
||||
emit('update:modelValue', value)
|
||||
}
|
||||
|
||||
function markRating(index) {
|
||||
emitChange(index)
|
||||
rating.value = index
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
(newVal) => {
|
||||
rating.value = newVal
|
||||
}
|
||||
)
|
||||
</script>
|
||||
186
frontend/src/components/CourseCard.vue
Normal file
186
frontend/src/components/CourseCard.vue
Normal file
@@ -0,0 +1,186 @@
|
||||
<template>
|
||||
<div
|
||||
v-if="course.title"
|
||||
class="flex flex-col h-full rounded-md shadow-md text-base overflow-auto"
|
||||
style="min-height: 350px"
|
||||
>
|
||||
<div
|
||||
class="course-image"
|
||||
:class="{ 'default-image': !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"
|
||||
>
|
||||
{{ tag }}
|
||||
</Badge>
|
||||
</div>
|
||||
<div v-if="!course.image" class="image-placeholder">
|
||||
{{ course.title[0] }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-col flex-auto p-4">
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<div v-if="course.lesson_count">
|
||||
<Tooltip :text="__('Lessons')">
|
||||
<span class="flex items-center">
|
||||
<BookOpen class="h-4 w-4 stroke-1.5 text-gray-700 mr-1" />
|
||||
{{ course.lesson_count }}
|
||||
</span>
|
||||
</Tooltip>
|
||||
</div>
|
||||
|
||||
<div v-if="course.enrollment_count">
|
||||
<Tooltip :text="__('Enrolled Students')">
|
||||
<span class="flex items-center">
|
||||
<Users class="h-4 w-4 stroke-1.5 text-gray-700 mr-1" />
|
||||
{{ course.enrollment_count }}
|
||||
</span>
|
||||
</Tooltip>
|
||||
</div>
|
||||
|
||||
<div v-if="course.avg_rating">
|
||||
<Tooltip :text="__('Average Rating')">
|
||||
<span class="flex items-center">
|
||||
<Star class="h-4 w-4 stroke-1.5 text-gray-700 mr-1" />
|
||||
{{ course.avg_rating }}
|
||||
</span>
|
||||
</Tooltip>
|
||||
</div>
|
||||
|
||||
<div v-if="course.status != 'Approved'">
|
||||
<Badge
|
||||
variant="solid"
|
||||
:theme="course.status === 'Under Review' ? 'orange' : 'blue'"
|
||||
size="sm"
|
||||
>
|
||||
{{ course.status }}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="text-xl font-semibold leading-6">
|
||||
{{ course.title }}
|
||||
</div>
|
||||
|
||||
<div class="short-introduction text-gray-700 text-sm">
|
||||
{{ course.short_introduction }}
|
||||
</div>
|
||||
|
||||
<ProgressBar
|
||||
v-if="user && course.membership"
|
||||
:progress="course.membership.progress"
|
||||
/>
|
||||
|
||||
<div v-if="user && course.membership" class="text-sm mb-4">
|
||||
{{ Math.ceil(course.membership.progress) }}% completed
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between mt-auto">
|
||||
<div class="flex avatar-group overlap">
|
||||
<div
|
||||
class="h-6 mr-1"
|
||||
:class="{ 'avatar-group overlap': course.instructors.length > 1 }"
|
||||
>
|
||||
<UserAvatar
|
||||
v-for="instructor in course.instructors"
|
||||
:user="instructor"
|
||||
/>
|
||||
</div>
|
||||
<CourseInstructors :instructors="course.instructors" />
|
||||
</div>
|
||||
|
||||
<div class="font-semibold">
|
||||
{{ course.price }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<script setup>
|
||||
import { BookOpen, Users, Star } from 'lucide-vue-next'
|
||||
import UserAvatar from '@/components/UserAvatar.vue'
|
||||
import { sessionStore } from '@/stores/session'
|
||||
import { Badge, Tooltip } from 'frappe-ui'
|
||||
import CourseInstructors from '@/components/CourseInstructors.vue'
|
||||
import ProgressBar from '@/components/ProgressBar.vue'
|
||||
|
||||
const { user } = sessionStore()
|
||||
|
||||
const props = defineProps({
|
||||
course: {
|
||||
type: Object,
|
||||
default: null,
|
||||
},
|
||||
})
|
||||
</script>
|
||||
<style>
|
||||
.course-image {
|
||||
height: 168px;
|
||||
width: 100%;
|
||||
background-size: cover;
|
||||
background-position: center;
|
||||
background-repeat: no-repeat;
|
||||
}
|
||||
|
||||
.course-card-pills {
|
||||
background: #ffffff;
|
||||
margin-left: 0;
|
||||
margin-right: 0.5rem;
|
||||
padding: 3.5px 8px;
|
||||
font-size: 11px;
|
||||
text-align: center;
|
||||
letter-spacing: 0.011em;
|
||||
text-transform: uppercase;
|
||||
font-weight: 600;
|
||||
width: fit-content;
|
||||
}
|
||||
|
||||
.default-image {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
background-color: theme('colors.green.100');
|
||||
color: theme('colors.green.600');
|
||||
}
|
||||
|
||||
.avatar-group {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.avatar-group .avatar {
|
||||
transition: margin 0.1s ease-in-out;
|
||||
}
|
||||
.image-placeholder {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex: 1;
|
||||
font-size: 5rem;
|
||||
color: theme('colors.gray.700');
|
||||
font-weight: 600;
|
||||
}
|
||||
.avatar-group.overlap .avatar + .avatar {
|
||||
margin-left: calc(-8px);
|
||||
}
|
||||
|
||||
.short-introduction {
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
text-overflow: ellipsis;
|
||||
width: 100%;
|
||||
overflow: hidden;
|
||||
margin: 0.25rem 0 1.25rem;
|
||||
line-height: 1.5;
|
||||
}
|
||||
</style>
|
||||
224
frontend/src/components/CourseCardOverlay.vue
Normal file
224
frontend/src/components/CourseCardOverlay.vue
Normal file
@@ -0,0 +1,224 @@
|
||||
<template>
|
||||
<div class="shadow rounded-md min-w-80">
|
||||
<iframe
|
||||
v-if="course.data.video_link"
|
||||
:src="video_link"
|
||||
class="rounded-t-md min-h-56 w-full"
|
||||
/>
|
||||
<div class="p-5">
|
||||
<div v-if="course.data.price" class="text-2xl font-semibold mb-3">
|
||||
{{ course.data.price }}
|
||||
</div>
|
||||
<router-link
|
||||
v-if="course.data.membership"
|
||||
:to="{
|
||||
name: 'Lesson',
|
||||
params: {
|
||||
courseName: course.name,
|
||||
chapterNumber: course.data.current_lesson
|
||||
? course.data.current_lesson.split('-')[0]
|
||||
: 1,
|
||||
lessonNumber: course.data.current_lesson
|
||||
? course.data.current_lesson.split('-')[1]
|
||||
: 1,
|
||||
},
|
||||
}"
|
||||
>
|
||||
<Button variant="solid" size="md" class="w-full">
|
||||
<span>
|
||||
{{ __('Continue Learning') }}
|
||||
</span>
|
||||
</Button>
|
||||
</router-link>
|
||||
<router-link
|
||||
v-else-if="course.data.paid_course"
|
||||
:to="{
|
||||
name: 'Billing',
|
||||
params: {
|
||||
type: 'course',
|
||||
name: course.data.name,
|
||||
},
|
||||
}"
|
||||
>
|
||||
<Button variant="solid" size="md" class="w-full">
|
||||
<span>
|
||||
{{ __('Buy this course') }}
|
||||
</span>
|
||||
</Button>
|
||||
</router-link>
|
||||
<div
|
||||
v-else-if="course.data.disable_self_learning"
|
||||
class="bg-blue-100 text-blue-900 text-sm rounded-md py-1 px-3"
|
||||
>
|
||||
{{ __('Contact the Administrator to enroll for this course.') }}
|
||||
</div>
|
||||
<Button
|
||||
v-else
|
||||
@click="enrollStudent()"
|
||||
variant="solid"
|
||||
class="w-full"
|
||||
size="md"
|
||||
>
|
||||
<span>
|
||||
{{ __('Start Learning') }}
|
||||
</span>
|
||||
</Button>
|
||||
<Button
|
||||
v-if="canGetCertificate"
|
||||
@click="fetchCertificate()"
|
||||
variant="subtle"
|
||||
class="w-full mt-2"
|
||||
size="md"
|
||||
>
|
||||
{{ __('Get Certificate') }}
|
||||
</Button>
|
||||
<router-link
|
||||
v-if="user?.data?.is_moderator || is_instructor()"
|
||||
:to="{
|
||||
name: 'CourseForm',
|
||||
params: {
|
||||
courseName: course.data.name,
|
||||
},
|
||||
}"
|
||||
>
|
||||
<Button variant="subtle" class="w-full mt-2" size="md">
|
||||
<span>
|
||||
{{ __('Edit') }}
|
||||
</span>
|
||||
</Button>
|
||||
</router-link>
|
||||
<div class="mt-8 mb-4 font-medium">
|
||||
{{ __('This course has:') }}
|
||||
</div>
|
||||
<div class="flex items-center mb-3">
|
||||
<BookOpen class="h-5 w-5 stroke-1.5 text-gray-600" />
|
||||
<span class="ml-2">
|
||||
{{ course.data.lesson_count }} {{ __('Lessons') }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex items-center mb-3">
|
||||
<Users class="h-5 w-5 stroke-1.5 text-gray-600" />
|
||||
<span class="ml-2">
|
||||
{{ course.data.enrollment_count_formatted }}
|
||||
{{ __('Enrolled Students') }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex items-center">
|
||||
<Star class="h-5 w-5 stroke-1.5 fill-orange-500 text-gray-50" />
|
||||
<span class="ml-2">
|
||||
{{ course.data.avg_rating }} {{ __('Rating') }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<script setup>
|
||||
import { BookOpen, Users, Star } from 'lucide-vue-next'
|
||||
import { computed, inject } from 'vue'
|
||||
import { Button, createResource } from 'frappe-ui'
|
||||
import { showToast } from '@/utils/'
|
||||
import { capture } from '@/telemetry'
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
const router = useRouter()
|
||||
const user = inject('$user')
|
||||
|
||||
const props = defineProps({
|
||||
course: {
|
||||
type: Object,
|
||||
default: null,
|
||||
},
|
||||
})
|
||||
|
||||
const video_link = computed(() => {
|
||||
if (props.course.data.video_link) {
|
||||
return 'https://www.youtube.com/embed/' + props.course.data.video_link
|
||||
}
|
||||
return null
|
||||
})
|
||||
|
||||
function enrollStudent() {
|
||||
if (!user.data) {
|
||||
showToast(
|
||||
__('Please Login'),
|
||||
__('You need to login first to enroll for this course'),
|
||||
'circle-warn'
|
||||
)
|
||||
setTimeout(() => {
|
||||
window.location.href = `/login?redirect-to=${window.location.pathname}`
|
||||
}, 2000)
|
||||
} else {
|
||||
const enrollStudentResource = createResource({
|
||||
url: 'lms.lms.doctype.lms_enrollment.lms_enrollment.create_membership',
|
||||
})
|
||||
enrollStudentResource
|
||||
.submit({
|
||||
course: props.course.data.name,
|
||||
})
|
||||
.then(() => {
|
||||
capture('enrolled_in_course', {
|
||||
course: props.course.data.name,
|
||||
})
|
||||
showToast(
|
||||
__('Success'),
|
||||
__('You have been enrolled in this course'),
|
||||
'check'
|
||||
)
|
||||
setTimeout(() => {
|
||||
router.push({
|
||||
name: 'Lesson',
|
||||
params: {
|
||||
courseName: props.course.data.name,
|
||||
chapterNumber: 1,
|
||||
lessonNumber: 1,
|
||||
},
|
||||
})
|
||||
}, 2000)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
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) {
|
||||
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>
|
||||
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>
|
||||
253
frontend/src/components/CourseOutline.vue
Normal file
253
frontend/src/components/CourseOutline.vue
Normal file
@@ -0,0 +1,253 @@
|
||||
<template>
|
||||
<div class="text-base">
|
||||
<div
|
||||
v-if="title && (outline.data?.length || allowEdit)"
|
||||
class="grid grid-cols-[70%,30%] mb-4 px-2"
|
||||
>
|
||||
<div class="font-semibold text-lg leading-5">
|
||||
{{ __(title) }}
|
||||
</div>
|
||||
<Button size="sm" v-if="allowEdit" @click="openChapterModal()">
|
||||
{{ __('Add Chapter') }}
|
||||
</Button>
|
||||
<!-- <span class="font-medium cursor-pointer" @click="expandAllChapters()">
|
||||
{{ expandAll ? __("Collapse all chapters") : __("Expand all chapters") }}
|
||||
</span> -->
|
||||
</div>
|
||||
<div
|
||||
:class="{
|
||||
'shadow rounded-md pt-2 px-2': showOutline && outline.data?.length,
|
||||
}"
|
||||
>
|
||||
<Disclosure
|
||||
v-slot="{ open }"
|
||||
v-for="(chapter, index) in outline.data"
|
||||
:key="chapter.name"
|
||||
:defaultOpen="openChapterDetail(chapter.idx)"
|
||||
>
|
||||
<DisclosureButton ref="" class="flex w-full p-2">
|
||||
<ChevronRight
|
||||
:class="{
|
||||
'rotate-90 transform duration-200': open,
|
||||
'duration-200': !open,
|
||||
open: index == 1,
|
||||
}"
|
||||
class="h-4 w-4 text-gray-900 stroke-1 mr-2"
|
||||
/>
|
||||
<div class="text-base text-left font-medium leading-5">
|
||||
{{ chapter.title }}
|
||||
</div>
|
||||
</DisclosureButton>
|
||||
<DisclosurePanel>
|
||||
<Draggable
|
||||
: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
|
||||
:to="{
|
||||
name: allowEdit ? 'LessonForm' : 'Lesson',
|
||||
params: {
|
||||
courseName: courseName,
|
||||
chapterNumber: lesson.number.split('.')[0],
|
||||
lessonNumber: lesson.number.split('.')[1],
|
||||
},
|
||||
}"
|
||||
>
|
||||
<div class="flex items-center text-sm leading-5 group">
|
||||
<MonitorPlay
|
||||
v-if="lesson.icon === 'icon-youtube'"
|
||||
class="h-4 w-4 text-gray-900 stroke-1 mr-2"
|
||||
/>
|
||||
<HelpCircle
|
||||
v-else-if="lesson.icon === 'icon-quiz'"
|
||||
class="h-4 w-4 text-gray-900 stroke-1 mr-2"
|
||||
/>
|
||||
<FileText
|
||||
v-else-if="lesson.icon === 'icon-list'"
|
||||
class="h-4 w-4 text-gray-900 stroke-1 mr-2"
|
||||
/>
|
||||
{{ lesson.title }}
|
||||
<Trash2
|
||||
v-if="allowEdit"
|
||||
@click.prevent="trashLesson(lesson.name, chapter.name)"
|
||||
class="h-4 w-4 text-red-500 ml-auto invisible group-hover:visible"
|
||||
/>
|
||||
<Check
|
||||
v-if="lesson.is_complete"
|
||||
class="h-4 w-4 text-green-700 ml-2"
|
||||
/>
|
||||
</div>
|
||||
</router-link>
|
||||
</div>
|
||||
</template>
|
||||
</Draggable>
|
||||
<div v-if="allowEdit" class="flex mt-2 mb-4 pl-8">
|
||||
<router-link
|
||||
:to="{
|
||||
name: 'LessonForm',
|
||||
params: {
|
||||
courseName: courseName,
|
||||
chapterNumber: chapter.idx,
|
||||
lessonNumber: chapter.lessons.length + 1,
|
||||
},
|
||||
}"
|
||||
>
|
||||
<Button>
|
||||
{{ __('Add Lesson') }}
|
||||
</Button>
|
||||
</router-link>
|
||||
<Button class="ml-2" @click="openChapterModal(chapter)">
|
||||
{{ __('Edit Chapter') }}
|
||||
</Button>
|
||||
</div>
|
||||
</DisclosurePanel>
|
||||
</Disclosure>
|
||||
</div>
|
||||
</div>
|
||||
<ChapterModal
|
||||
v-model="showChapterModal"
|
||||
v-model:outline="outline"
|
||||
:course="courseName"
|
||||
:chapterDetail="getCurrentChapter()"
|
||||
/>
|
||||
</template>
|
||||
<script setup>
|
||||
import { Button, createResource } from 'frappe-ui'
|
||||
import { ref, getCurrentInstance } from 'vue'
|
||||
import Draggable from 'vuedraggable'
|
||||
import { Disclosure, DisclosureButton, DisclosurePanel } from '@headlessui/vue'
|
||||
import {
|
||||
ChevronRight,
|
||||
MonitorPlay,
|
||||
HelpCircle,
|
||||
FileText,
|
||||
Check,
|
||||
Trash2,
|
||||
} from 'lucide-vue-next'
|
||||
import { useRoute } from 'vue-router'
|
||||
import ChapterModal from '@/components/Modals/ChapterModal.vue'
|
||||
import { showToast } from '@/utils'
|
||||
|
||||
const route = useRoute()
|
||||
const expandAll = ref(true)
|
||||
const showChapterModal = ref(false)
|
||||
const currentChapter = ref(null)
|
||||
const app = getCurrentInstance()
|
||||
const { $dialog } = app.appContext.config.globalProperties
|
||||
|
||||
const props = defineProps({
|
||||
courseName: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
showOutline: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
title: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
allowEdit: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
getProgress: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
})
|
||||
|
||||
const outline = createResource({
|
||||
url: 'lms.lms.utils.get_course_outline',
|
||||
cache: ['course_outline', props.courseName],
|
||||
params: {
|
||||
course: props.courseName,
|
||||
progress: props.getProgress,
|
||||
},
|
||||
auto: true,
|
||||
})
|
||||
|
||||
const deleteLesson = createResource({
|
||||
url: 'lms.lms.api.delete_lesson',
|
||||
makeParams(values) {
|
||||
return {
|
||||
lesson: values.lesson,
|
||||
chapter: values.chapter,
|
||||
}
|
||||
},
|
||||
onSuccess() {
|
||||
outline.reload()
|
||||
showToast('Success', 'Lesson deleted successfully', 'check')
|
||||
},
|
||||
})
|
||||
|
||||
const updateLessonIndex = createResource({
|
||||
url: 'lms.lms.api.update_lesson_index',
|
||||
makeParams(values) {
|
||||
return {
|
||||
lesson: values.lesson,
|
||||
sourceChapter: values.sourceChapter,
|
||||
targetChapter: values.targetChapter,
|
||||
idx: values.idx,
|
||||
}
|
||||
},
|
||||
onSuccess() {
|
||||
showToast('Success', 'Lesson moved successfully', 'check')
|
||||
},
|
||||
})
|
||||
|
||||
const trashLesson = (lessonName, chapterName) => {
|
||||
$dialog({
|
||||
title: __('Delete Lesson'),
|
||||
message: __('Are you sure you want to delete this lesson?'),
|
||||
actions: [
|
||||
{
|
||||
label: __('Delete'),
|
||||
theme: 'red',
|
||||
variant: 'solid',
|
||||
onClick(close) {
|
||||
deleteLesson.submit({
|
||||
lesson: lessonName,
|
||||
chapter: chapterName,
|
||||
})
|
||||
close()
|
||||
},
|
||||
},
|
||||
],
|
||||
})
|
||||
}
|
||||
|
||||
const openChapterDetail = (index) => {
|
||||
return index == route.params.chapterNumber || index == 1
|
||||
}
|
||||
|
||||
const openChapterModal = (chapter = null) => {
|
||||
currentChapter.value = chapter
|
||||
showChapterModal.value = true
|
||||
}
|
||||
|
||||
const getCurrentChapter = () => {
|
||||
return currentChapter.value
|
||||
}
|
||||
|
||||
const updateOutline = (e) => {
|
||||
updateLessonIndex.submit({
|
||||
lesson: e.item.__draggable_context.element.name,
|
||||
sourceChapter: e.from.dataset.chapter,
|
||||
targetChapter: e.to.dataset.chapter,
|
||||
idx: e.newIndex,
|
||||
})
|
||||
}
|
||||
</script>
|
||||
<style>
|
||||
.outline-lesson:has(.router-link-active) {
|
||||
background-color: theme('colors.gray.100');
|
||||
}
|
||||
</style>
|
||||
115
frontend/src/components/CourseReviews.vue
Normal file
115
frontend/src/components/CourseReviews.vue
Normal file
@@ -0,0 +1,115 @@
|
||||
<template>
|
||||
<div v-if="reviews.data?.length || membership" class="mt-20 mb-10">
|
||||
<Button
|
||||
v-if="membership && !hasReviewed.data"
|
||||
@click="openReviewModal()"
|
||||
class="float-right"
|
||||
>
|
||||
{{ __('Write a Review') }}
|
||||
</Button>
|
||||
<div class="flex items-center font-semibold text-2xl">
|
||||
{{ __('Student Reviews') }}
|
||||
</div>
|
||||
<div class="grid gap-8 mt-10">
|
||||
<div v-for="(review, index) in reviews.data">
|
||||
<div class="flex items-center">
|
||||
<router-link
|
||||
:to="{
|
||||
name: 'Profile',
|
||||
params: { username: review.owner_details.username },
|
||||
}"
|
||||
>
|
||||
<UserAvatar :user="review.owner_details" :size="'2xl'" />
|
||||
</router-link>
|
||||
<div class="mx-4">
|
||||
<router-link
|
||||
:to="{
|
||||
name: 'Profile',
|
||||
params: { username: review.owner_details.username },
|
||||
}"
|
||||
>
|
||||
<span class="text-lg font-medium mr-4">
|
||||
{{ review.owner_details.full_name }}
|
||||
</span>
|
||||
</router-link>
|
||||
<span>
|
||||
{{ review.creation }}
|
||||
</span>
|
||||
<div class="flex mt-2">
|
||||
<Star
|
||||
v-for="index in 5"
|
||||
class="h-5 w-5 text-gray-100 bg-gray-200 rounded-sm mr-2"
|
||||
:class="
|
||||
index <= Math.ceil(review.rating)
|
||||
? 'fill-orange-500'
|
||||
: 'fill-gray-600'
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="review.review" class="mt-4 leading-5">
|
||||
{{ review.review }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<ReviewModal
|
||||
v-model="showReviewModal"
|
||||
v-model:reloadReviews="reviews"
|
||||
v-model:hasReviewed="hasReviewed"
|
||||
:courseName="courseName"
|
||||
/>
|
||||
</template>
|
||||
<script setup>
|
||||
import { Star } from 'lucide-vue-next'
|
||||
import { createResource, Button } from 'frappe-ui'
|
||||
import { computed, ref, inject } from 'vue'
|
||||
import UserAvatar from '@/components/UserAvatar.vue'
|
||||
import ReviewModal from '@/components/Modals/ReviewModal.vue'
|
||||
|
||||
const user = inject('$user')
|
||||
|
||||
const props = defineProps({
|
||||
courseName: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
avg_rating: {
|
||||
type: Number,
|
||||
required: true,
|
||||
},
|
||||
membership: {
|
||||
type: Object,
|
||||
required: false,
|
||||
},
|
||||
})
|
||||
|
||||
const hasReviewed = createResource({
|
||||
url: 'frappe.client.get_count',
|
||||
cache: ['eligible_to_review', props.courseName, props.membership?.member],
|
||||
params: {
|
||||
doctype: 'LMS Course Review',
|
||||
filters: {
|
||||
course: props.courseName,
|
||||
owner: props.membership?.member,
|
||||
},
|
||||
},
|
||||
auto: user.data?.name ? true : false,
|
||||
})
|
||||
|
||||
const reviews = createResource({
|
||||
url: 'lms.lms.utils.get_reviews',
|
||||
cache: ['course_reviews', props.courseName],
|
||||
params: {
|
||||
course: props.courseName,
|
||||
},
|
||||
auto: true,
|
||||
})
|
||||
|
||||
const showReviewModal = ref(false)
|
||||
|
||||
function openReviewModal() {
|
||||
showReviewModal.value = true
|
||||
}
|
||||
</script>
|
||||
30
frontend/src/components/CreateOutline.vue
Normal file
30
frontend/src/components/CreateOutline.vue
Normal file
@@ -0,0 +1,30 @@
|
||||
<template>
|
||||
<div v-if="course">
|
||||
<div class="text-xl font-semibold">
|
||||
{{ course.title }}
|
||||
</div>
|
||||
<div v-if="course.chapters.length">
|
||||
{{ course.chapters }}
|
||||
</div>
|
||||
<div v-else class="border bg-white rounded-md p-5 text-center mt-4">
|
||||
<div>
|
||||
{{
|
||||
__(
|
||||
'There are no chapters in this course. Create and manage chapters from here.'
|
||||
)
|
||||
}}
|
||||
</div>
|
||||
<Button class="mt-4">
|
||||
{{ __('Add Chapter') }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<script setup>
|
||||
const props = defineProps({
|
||||
course: {
|
||||
type: Object,
|
||||
default: {},
|
||||
},
|
||||
})
|
||||
</script>
|
||||
19
frontend/src/components/DesktopLayout.vue
Normal file
19
frontend/src/components/DesktopLayout.vue
Normal file
@@ -0,0 +1,19 @@
|
||||
<template>
|
||||
<div class="relative flex h-full flex-col">
|
||||
<div class="h-full flex-1">
|
||||
<div class="flex h-screen text-base">
|
||||
<div
|
||||
class="relative block min-h-0 flex-shrink-0 overflow-hidden hover:overflow-auto"
|
||||
>
|
||||
<AppSidebar />
|
||||
</div>
|
||||
<div class="w-full overflow-auto" id="scrollContainer">
|
||||
<slot />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<script setup>
|
||||
import AppSidebar from './AppSidebar.vue'
|
||||
</script>
|
||||
244
frontend/src/components/DiscussionReplies.vue
Normal file
244
frontend/src/components/DiscussionReplies.vue
Normal file
@@ -0,0 +1,244 @@
|
||||
<template>
|
||||
<div class="mt-6">
|
||||
<div v-if="!singleThread" class="flex items-center mb-5">
|
||||
<Button variant="outline" @click="showTopics = true">
|
||||
<template #icon>
|
||||
<ChevronLeft class="w-5 h-5 stroke-1.5 text-gray-700" />
|
||||
</template>
|
||||
</Button>
|
||||
<span class="text-lg font-semibold ml-2">
|
||||
{{ topic.title }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div v-for="(reply, index) in replies.data">
|
||||
<div
|
||||
class="py-3"
|
||||
:class="{ 'border-b': index + 1 != replies.data.length }"
|
||||
>
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<div class="flex items-center">
|
||||
<UserAvatar :user="reply.user" class="mr-2" />
|
||||
<span>
|
||||
{{ reply.user.full_name }}
|
||||
</span>
|
||||
<span class="text-sm ml-2">
|
||||
{{ timeAgo(reply.creation) }}
|
||||
</span>
|
||||
</div>
|
||||
<Dropdown
|
||||
v-if="user.data.name == reply.owner && !reply.editable"
|
||||
:options="[
|
||||
{
|
||||
label: 'Edit',
|
||||
onClick() {
|
||||
reply.editable = true
|
||||
},
|
||||
},
|
||||
{
|
||||
label: 'Delete',
|
||||
onClick() {
|
||||
deleteReply(reply)
|
||||
},
|
||||
},
|
||||
]"
|
||||
>
|
||||
<template v-slot="{ open }">
|
||||
<MoreHorizontal class="w-4 h-4 stroke-1.5 cursor-pointer" />
|
||||
</template>
|
||||
</Dropdown>
|
||||
<div v-if="reply.editable">
|
||||
<Button variant="ghost" @click="postEdited(reply)">
|
||||
{{ __('Post') }}
|
||||
</Button>
|
||||
<Button variant="ghost" @click="reply.editable = false">
|
||||
{{ __('Discard') }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<TextEditor
|
||||
:content="reply.reply"
|
||||
@change="(val) => (reply.reply = val)"
|
||||
:editable="reply.editable || false"
|
||||
:fixedMenu="reply.editable || false"
|
||||
:editorClass="
|
||||
reply.editable
|
||||
? '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'
|
||||
: 'prose-sm'
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<TextEditor
|
||||
class="mt-5"
|
||||
:content="newReply"
|
||||
:mentions="mentionUsers"
|
||||
@change="(val) => (newReply = val)"
|
||||
placeholder="Type your reply here..."
|
||||
:fixedMenu="true"
|
||||
editorClass="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 border border-gray-300 rounded-b-md min-h-[7rem] py-1 px-2"
|
||||
/>
|
||||
<div class="flex justify-between mt-2">
|
||||
<span> </span>
|
||||
<Button @click="postReply()">
|
||||
<span>
|
||||
{{ __('Post') }}
|
||||
</span>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<script setup>
|
||||
import { createResource, TextEditor, Button, Dropdown } from 'frappe-ui'
|
||||
import { timeAgo } from '../utils'
|
||||
import UserAvatar from '@/components/UserAvatar.vue'
|
||||
import { ChevronLeft, MoreHorizontal } from 'lucide-vue-next'
|
||||
import { ref, inject, onMounted, computed } from 'vue'
|
||||
import { createToast } from '../utils'
|
||||
|
||||
const showTopics = defineModel('showTopics')
|
||||
const newReply = ref('')
|
||||
const socket = inject('$socket')
|
||||
const user = inject('$user')
|
||||
const allUsers = inject('$allUsers')
|
||||
|
||||
const props = defineProps({
|
||||
topic: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
singleThread: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
socket.on('publish_message', (data) => {
|
||||
replies.reload()
|
||||
})
|
||||
socket.on('update_message', (data) => {
|
||||
replies.reload()
|
||||
})
|
||||
socket.on('delete_message', (data) => {
|
||||
replies.reload()
|
||||
})
|
||||
})
|
||||
|
||||
const replies = createResource({
|
||||
url: 'lms.lms.utils.get_discussion_replies',
|
||||
cache: ['replies', props.topic],
|
||||
makeParams(values) {
|
||||
return {
|
||||
topic: props.topic.name,
|
||||
}
|
||||
},
|
||||
auto: true,
|
||||
})
|
||||
|
||||
const newReplyResource = createResource({
|
||||
url: 'frappe.client.insert',
|
||||
makeParams(values) {
|
||||
return {
|
||||
doc: {
|
||||
doctype: 'Discussion Reply',
|
||||
reply: newReply.value,
|
||||
topic: props.topic.name,
|
||||
},
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
const mentionUsers = computed(() => {
|
||||
let users = Object.values(allUsers.data).map((user) => {
|
||||
return {
|
||||
value: user.name,
|
||||
label: user.full_name,
|
||||
}
|
||||
})
|
||||
return users
|
||||
})
|
||||
|
||||
const postReply = () => {
|
||||
newReplyResource.submit(
|
||||
{},
|
||||
{
|
||||
validate() {
|
||||
if (!newReply.value) {
|
||||
return 'Reply cannot be empty'
|
||||
}
|
||||
},
|
||||
onSuccess() {
|
||||
newReply.value = ''
|
||||
replies.reload()
|
||||
},
|
||||
onError(err) {
|
||||
createToast({
|
||||
title: 'Error',
|
||||
text: err.messages?.[0] || err,
|
||||
icon: 'x',
|
||||
iconClasses: 'bg-red-600 text-white rounded-md p-px',
|
||||
position: 'top-center',
|
||||
timeout: 10,
|
||||
})
|
||||
},
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
const editReplyResource = createResource({
|
||||
url: 'frappe.client.set_value',
|
||||
makeParams(values) {
|
||||
return {
|
||||
doctype: 'Discussion Reply',
|
||||
name: values.name,
|
||||
fieldname: 'reply',
|
||||
value: values.reply,
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
const postEdited = (reply) => {
|
||||
editReplyResource.submit(
|
||||
{
|
||||
name: reply.name,
|
||||
reply: reply.reply,
|
||||
},
|
||||
{
|
||||
validate() {
|
||||
if (!reply.reply) {
|
||||
return 'Reply cannot be empty'
|
||||
}
|
||||
},
|
||||
onSuccess() {
|
||||
reply.editable = false
|
||||
replies.reload()
|
||||
},
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
const deleteReplyResource = createResource({
|
||||
url: 'frappe.client.delete',
|
||||
makeParams(values) {
|
||||
return {
|
||||
doctype: 'Discussion Reply',
|
||||
name: values.name,
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
const deleteReply = (reply) => {
|
||||
deleteReplyResource.submit(
|
||||
{
|
||||
name: reply.name,
|
||||
},
|
||||
{
|
||||
onSuccess() {
|
||||
replies.reload()
|
||||
},
|
||||
}
|
||||
)
|
||||
}
|
||||
</script>
|
||||
151
frontend/src/components/Discussions.vue
Normal file
151
frontend/src/components/Discussions.vue
Normal file
@@ -0,0 +1,151 @@
|
||||
<template>
|
||||
<div>
|
||||
<Button v-if="!singleThread" class="float-right" @click="openTopicModal()">
|
||||
{{ __('New {0}').format(singularize(title)) }}
|
||||
</Button>
|
||||
<div class="text-xl font-semibold">
|
||||
{{ __(title) }}
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="topics.data?.length && !singleThread">
|
||||
<div v-if="showTopics" v-for="(topic, index) in topics.data">
|
||||
<div
|
||||
@click="showReplies(topic)"
|
||||
class="flex items-center cursor-pointer py-5 w-full"
|
||||
:class="{ 'border-b': index + 1 != topics.data.length }"
|
||||
>
|
||||
<UserAvatar :user="topic.user" size="2xl" class="mr-4" />
|
||||
<div>
|
||||
<div class="text-lg font-semibold mb-1">
|
||||
{{ topic.title }}
|
||||
</div>
|
||||
<div class="flex items-center">
|
||||
<span>
|
||||
{{ topic.user.full_name }}
|
||||
</span>
|
||||
<span class="text-sm ml-3">
|
||||
{{ timeAgo(topic.creation) }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else>
|
||||
<DiscussionReplies
|
||||
:topic="currentTopic"
|
||||
v-model:showTopics="showTopics"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else-if="singleThread && topics.data">
|
||||
<DiscussionReplies :topic="topics.data" :singleThread="singleThread" />
|
||||
</div>
|
||||
<div
|
||||
v-else
|
||||
class="flex flex-col items-center justify-center border-2 border-dashed mt-5 py-8 rounded-md"
|
||||
>
|
||||
<MessageSquareText class="w-7 h-7 text-gray-500 stroke-1.5 mr-2" />
|
||||
<div class="">
|
||||
<div v-if="emptyStateTitle" class="font-medium mb-2">
|
||||
{{ __(emptyStateTitle) }}
|
||||
</div>
|
||||
<div class="text-gray-600">
|
||||
{{ __(emptyStateText) }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<DiscussionModal
|
||||
v-model="showTopicModal"
|
||||
:title="__('New {0}').format(title)"
|
||||
:doctype="props.doctype"
|
||||
:docname="props.docname"
|
||||
v-model:reloadTopics="topics"
|
||||
/>
|
||||
</template>
|
||||
<script setup>
|
||||
import { createResource, Button } from 'frappe-ui'
|
||||
import UserAvatar from '@/components/UserAvatar.vue'
|
||||
import { singularize, timeAgo } from '../utils'
|
||||
import { ref, onMounted, inject } from 'vue'
|
||||
import DiscussionReplies from '@/components/DiscussionReplies.vue'
|
||||
import DiscussionModal from '@/components/Modals/DiscussionModal.vue'
|
||||
import { MessageSquareText } from 'lucide-vue-next'
|
||||
import { getScrollContainer } from '@/utils/scrollContainer'
|
||||
|
||||
const showTopics = ref(true)
|
||||
const currentTopic = ref(null)
|
||||
const socket = inject('$socket')
|
||||
const user = inject('$user')
|
||||
const showTopicModal = ref(false)
|
||||
|
||||
const props = defineProps({
|
||||
title: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
doctype: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
docname: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
emptyStateTitle: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
emptyStateText: {
|
||||
type: String,
|
||||
default: 'Start a discussion',
|
||||
},
|
||||
singleThread: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
scrollToBottom: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
if (user.data) topics.reload()
|
||||
|
||||
socket.on('new_discussion_topic', (data) => {
|
||||
topics.refresh()
|
||||
})
|
||||
|
||||
if (props.scrollToBottom) {
|
||||
setTimeout(() => {
|
||||
scrollToEnd()
|
||||
}, 100)
|
||||
}
|
||||
})
|
||||
|
||||
const scrollToEnd = () => {
|
||||
let scrollContainer = getScrollContainer()
|
||||
scrollContainer.scrollTop = scrollContainer.scrollHeight
|
||||
}
|
||||
|
||||
const topics = createResource({
|
||||
url: 'lms.lms.utils.get_discussion_topics',
|
||||
cache: ['topics', props.doctype, props.docname],
|
||||
makeParams() {
|
||||
return {
|
||||
doctype: props.doctype,
|
||||
docname: props.docname,
|
||||
single_thread: props.singleThread,
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
const showReplies = (topic) => {
|
||||
showTopics.value = false
|
||||
currentTopic.value = topic
|
||||
}
|
||||
|
||||
const openTopicModal = () => {
|
||||
showTopicModal.value = true
|
||||
}
|
||||
</script>
|
||||
23
frontend/src/components/Icons/BatchIcon.vue
Normal file
23
frontend/src/components/Icons/BatchIcon.vue
Normal file
@@ -0,0 +1,23 @@
|
||||
<template>
|
||||
<svg
|
||||
width="18"
|
||||
height="18"
|
||||
viewBox="0 0 18 18"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<g clip-path="url(#clip0_1584_1676)">
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
clip-rule="evenodd"
|
||||
d="M3.17474 0.625C2.34632 0.625 1.67474 1.29657 1.67474 2.125V7.475C1.67474 8.30343 2.34632 8.975 3.17474 8.975H14.8247C15.6532 8.975 16.3247 8.30343 16.3247 7.475V2.125C16.3247 1.29657 15.6532 0.625 14.8247 0.625H3.17474ZM2.67474 2.125C2.67474 1.84886 2.8986 1.625 3.17474 1.625H14.8247C15.1009 1.625 15.3247 1.84886 15.3247 2.125V7.475C15.3247 7.75114 15.1009 7.975 14.8247 7.975H3.17474C2.8986 7.975 2.67474 7.75114 2.67474 7.475V2.125ZM4.27478 10.0749C3.99864 10.0749 3.77478 10.2987 3.77478 10.5749V12.6749C3.77478 12.951 3.99864 13.1749 4.27478 13.1749C4.55092 13.1749 4.77478 12.951 4.77478 12.6749V11.0749H6.92478V12.6749C6.92478 12.951 7.14864 13.1749 7.42478 13.1749C7.70092 13.1749 7.92478 12.951 7.92478 12.6749V10.5749C7.92478 10.2987 7.70092 10.0749 7.42478 10.0749H4.27478ZM10.0749 10.5749C10.0749 10.2987 10.2987 10.0749 10.5749 10.0749H13.7249C14.001 10.0749 14.2249 10.2987 14.2249 10.5749V12.6749C14.2249 12.951 14.001 13.1749 13.7249 13.1749C13.4487 13.1749 13.2249 12.951 13.2249 12.6749V11.0749H11.0749V12.6749C11.0749 12.951 10.851 13.1749 10.5749 13.1749C10.2987 13.1749 10.0749 12.951 10.0749 12.6749V10.5749ZM1.125 14.275C0.848858 14.275 0.625 14.4988 0.625 14.775V16.875C0.625 17.1511 0.848858 17.375 1.125 17.375C1.40114 17.375 1.625 17.1511 1.625 16.875V15.275H3.775V16.875C3.775 17.1511 3.99886 17.375 4.275 17.375C4.55114 17.375 4.775 17.1511 4.775 16.875V14.775C4.775 14.4988 4.55114 14.275 4.275 14.275H1.125ZM13.2252 14.775C13.2252 14.4988 13.4491 14.275 13.7252 14.275H16.8752C17.1514 14.275 17.3752 14.4988 17.3752 14.775V16.875C17.3752 17.1511 17.1514 17.375 16.8752 17.375C16.5991 17.375 16.3752 17.1511 16.3752 16.875V15.275H14.2252V16.875C14.2252 17.1511 14.0014 17.375 13.7252 17.375C13.4491 17.375 13.2252 17.1511 13.2252 16.875V14.775ZM7.42511 14.275C7.14897 14.275 6.92511 14.4988 6.92511 14.775V16.875C6.92511 17.1511 7.14897 17.375 7.42511 17.375C7.70125 17.375 7.92511 17.1511 7.92511 16.875V15.275H10.0751V16.875C10.0751 17.1511 10.299 17.375 10.5751 17.375C10.8513 17.375 11.0751 17.1511 11.0751 16.875V14.775C11.0751 14.4988 10.8513 14.275 10.5751 14.275H7.42511Z"
|
||||
fill="#525252"
|
||||
/>
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="clip0_1584_1676">
|
||||
<rect width="18" height="18" fill="white" />
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
</template>
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user