Compare commits
540 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
57c1a6b540 | ||
|
|
8dba0e8242 | ||
|
|
ee715f6387 | ||
|
|
b770b30334 | ||
|
|
d61abac126 | ||
|
|
ccf28b8012 | ||
|
|
3762cb06bb | ||
|
|
15400f2a3e | ||
|
|
20d1b1fe83 | ||
|
|
73844f8813 | ||
|
|
2187553625 | ||
|
|
984b2a5dea | ||
|
|
9098d9454f | ||
|
|
027dd93fb5 | ||
|
|
a005adc89a | ||
|
|
866ef04fbf | ||
|
|
00b6f97e3a | ||
|
|
a1d21b1a2a | ||
|
|
7358ea43d8 | ||
|
|
88c69311eb | ||
|
|
c1e45e5d0d | ||
|
|
fe78de2417 | ||
|
|
4c1fc201e6 | ||
|
|
3f5d270915 | ||
|
|
a452fbeb07 | ||
|
|
a6f02c245f | ||
|
|
cb4f9129d6 | ||
|
|
9c5d64c211 | ||
|
|
41dc0ecc60 | ||
|
|
6b9409b889 | ||
|
|
ea66eeed6c | ||
|
|
a419d28ef1 | ||
|
|
481dfc24fd | ||
|
|
ed686a7d52 | ||
|
|
b4c5a07800 | ||
|
|
6ae16f7fef | ||
|
|
4aae2ed3b8 | ||
|
|
81d4137b20 | ||
|
|
77ecb02a17 | ||
|
|
4a375f92ed | ||
|
|
7caf91460a | ||
|
|
0e015c8b97 | ||
|
|
7b69ddb14d | ||
|
|
2271eb270e | ||
|
|
7e5b2e4e79 | ||
|
|
124b9d9ea5 | ||
|
|
36076068ec | ||
|
|
c868354b5b | ||
|
|
db91f0b2a0 | ||
|
|
d7e83bb78e | ||
|
|
feb2a39e05 | ||
|
|
a6cf910d05 | ||
|
|
b891b44ac6 | ||
|
|
026a3ebb81 | ||
|
|
71ba246011 | ||
|
|
a391204fa6 | ||
|
|
9c773399a8 | ||
|
|
528b85352a | ||
|
|
249c369c14 | ||
|
|
9803fc1031 | ||
|
|
299fde1c98 | ||
|
|
7f55734fbb | ||
|
|
efe230865a | ||
|
|
6e52e684c8 | ||
|
|
99d880297a | ||
|
|
dec706ae72 | ||
|
|
2e60f0a0c2 | ||
|
|
ef612f86e5 | ||
|
|
9c16e03ea7 | ||
|
|
7780c0310e | ||
|
|
b0a23c0d1a | ||
|
|
05c85cea08 | ||
|
|
1ffae0a1de | ||
|
|
15cbccd15f | ||
|
|
266b2f2ac8 | ||
|
|
26f9fb4199 | ||
|
|
67887fb6ef | ||
|
|
3d102e39ff | ||
|
|
ddd9089130 | ||
|
|
d8ce88ab57 | ||
|
|
01794a47c6 | ||
|
|
17626dbbdb | ||
|
|
e5bd86658d | ||
|
|
e911dc1353 | ||
|
|
27e3e5aa6a | ||
|
|
5b65525bf1 | ||
|
|
277804f8b1 | ||
|
|
4c77802e3c | ||
|
|
aacfea6ea5 | ||
|
|
6d55040e43 | ||
|
|
290f785a47 | ||
|
|
39ef187f6b | ||
|
|
a7a475e763 | ||
|
|
6eb380ea38 | ||
|
|
4d150cb323 | ||
|
|
09d6d99b14 | ||
|
|
5e7fd8baff | ||
|
|
52c159e2e8 | ||
|
|
67e8feb879 | ||
|
|
a5b61d5244 | ||
|
|
decc3a16ed | ||
|
|
7f39e9f0cc | ||
|
|
95afa1a6ad | ||
|
|
0d0bb5f9e2 | ||
|
|
3dd5ce5035 | ||
|
|
549e56d551 | ||
|
|
50b6215d1e | ||
|
|
ff69bfdce7 | ||
|
|
c04cc8ec0f | ||
|
|
f324de2254 | ||
|
|
40af4e6f34 | ||
|
|
5d9b66b5cb | ||
|
|
d2a8277c13 | ||
|
|
ada85fc0f3 | ||
|
|
505345eff7 | ||
|
|
2911ade880 | ||
|
|
8980dc8f9c | ||
|
|
d94a1c47c0 | ||
|
|
99c3e5182d | ||
|
|
70e39fee40 | ||
|
|
26d6bec8a0 | ||
|
|
c9ac1a1402 | ||
|
|
6949c1092c | ||
|
|
aae8a54481 | ||
|
|
e1d93bf670 | ||
|
|
fea0533cb1 | ||
|
|
5cd991f02a | ||
|
|
50a8a605d5 | ||
|
|
9ce7d8f5d6 | ||
|
|
eae2587e4c | ||
|
|
323097f201 | ||
|
|
014499888a | ||
|
|
5662de21ae | ||
|
|
17c2eba455 | ||
|
|
1f2c986e8f | ||
|
|
12040b5f6d | ||
|
|
20a985848f | ||
|
|
c06c6169e5 | ||
|
|
917aeb79ef | ||
|
|
c4f36a39fe | ||
|
|
befedc30ad | ||
|
|
d3bc67daa2 | ||
|
|
5d7e211367 | ||
|
|
fa9daa01ec | ||
|
|
0ed9dc63b8 | ||
|
|
5dd6b33eb2 | ||
|
|
1210b823c7 | ||
|
|
04240b4b3d | ||
|
|
787f592a1a | ||
|
|
e7363fbd40 | ||
|
|
e2762825e5 | ||
|
|
bbbca70c71 | ||
|
|
8dde423866 | ||
|
|
fc4c1c2b7e | ||
|
|
bf02e2de3f | ||
|
|
a26ba4dc6e | ||
|
|
f187cc9314 | ||
|
|
c15c6374f9 | ||
|
|
acec382dfe | ||
|
|
fbc078c6b6 | ||
|
|
170b20185a | ||
|
|
3e8489c13b | ||
|
|
18dfc4c23e | ||
|
|
e6bae3dc77 | ||
|
|
6f9f27c030 | ||
|
|
874bef74c7 | ||
|
|
ad483e0916 | ||
|
|
5b4bbaec20 | ||
|
|
b8ae0db0bd | ||
|
|
f2c18fad52 | ||
|
|
9716655b94 | ||
|
|
efb317191c | ||
|
|
a47b5db40c | ||
|
|
ec94796b9c | ||
|
|
e3e0cd61a2 | ||
|
|
a438473279 | ||
|
|
12b5b8b509 | ||
|
|
22442b47a8 | ||
|
|
30c8b7d64f | ||
|
|
b643575c4f | ||
|
|
7dd7124fac | ||
|
|
4b1eebf5bb | ||
|
|
3257943926 | ||
|
|
24246c83e0 | ||
|
|
a26787f478 | ||
|
|
ec3b88f890 | ||
|
|
7f5f1dad92 | ||
|
|
b6db128214 | ||
|
|
8831635db2 | ||
|
|
e19198b720 | ||
|
|
f618d9dc1a | ||
|
|
66a667a0a3 | ||
|
|
8a4c67f712 | ||
|
|
fa6ef2e989 | ||
|
|
7450b99197 | ||
|
|
023fd272b1 | ||
|
|
84067cb027 | ||
|
|
3087ef70e7 | ||
|
|
387385bb1c | ||
|
|
6766d0d08c | ||
|
|
371d890793 | ||
|
|
57046c1b38 | ||
|
|
2a64144e94 | ||
|
|
9b0320ccf1 | ||
|
|
23f209131e | ||
|
|
d71f1c7f9a | ||
|
|
d21ea2c854 | ||
|
|
cd7f3ba820 | ||
|
|
e057d3ed9a | ||
|
|
5f04607a44 | ||
|
|
9440d13a08 | ||
|
|
85c4f1654e | ||
|
|
eed339cc64 | ||
|
|
3d1a23576a | ||
|
|
ed0e2e4bb5 | ||
|
|
954d0a0637 | ||
|
|
f2c8788602 | ||
|
|
49c63da27c | ||
|
|
24496d1856 | ||
|
|
991ebe09a2 | ||
|
|
85da4f6d85 | ||
|
|
5f065db991 | ||
|
|
ffb40586d7 | ||
|
|
fcfd87fd50 | ||
|
|
eb5b12aa7b | ||
|
|
f6e2438744 | ||
|
|
e3c7dc695d | ||
|
|
82d2025e6c | ||
|
|
91b82d78b8 | ||
|
|
b97e792893 | ||
|
|
13ac5ec7dc | ||
|
|
199f880936 | ||
|
|
ed86c207ba | ||
|
|
b4cf290f4d | ||
|
|
e526a6fd64 | ||
|
|
94cbbf169a | ||
|
|
2837ed16a7 | ||
|
|
68961deb6b | ||
|
|
ec54bfee98 | ||
|
|
385e97b76a | ||
|
|
cbd916877f | ||
|
|
38586034cd | ||
|
|
62b3ba2bff | ||
|
|
dd470b61b5 | ||
|
|
4fa92d2327 | ||
|
|
6f6c2db66d | ||
|
|
e6348cfa20 | ||
|
|
a006d1000a | ||
|
|
4a575e642f | ||
|
|
93525bc577 | ||
|
|
2cf0e9a723 | ||
|
|
c32164bfea | ||
|
|
714b0924e7 | ||
|
|
43079790a8 | ||
|
|
d03e61b625 | ||
|
|
2d760112a3 | ||
|
|
f46507ec72 | ||
|
|
e9e10bdc93 | ||
|
|
0386967a32 | ||
|
|
4900fc8b88 | ||
|
|
99294b5643 | ||
|
|
eb12bcb83c | ||
|
|
22a2e57642 | ||
|
|
5eaae06ceb | ||
|
|
ce7fc35349 | ||
|
|
8d4b5c83ae | ||
|
|
cbd3c56ca0 | ||
|
|
be6dad1424 | ||
|
|
298452fa7b | ||
|
|
4abbd7c35c | ||
|
|
c2f51c51ab | ||
|
|
255cff6664 | ||
|
|
8a9578bb0a | ||
|
|
8831f6cecc | ||
|
|
f3daa7e48b | ||
|
|
6163597958 | ||
|
|
f9e1222065 | ||
|
|
7d85de7c6c | ||
|
|
cf452c2300 | ||
|
|
72bd1d548d | ||
|
|
4556f4dee6 | ||
|
|
3dfbd3165a | ||
|
|
02b8e02131 | ||
|
|
087ded9f9e | ||
|
|
21f122ee82 | ||
|
|
d60a7e8c94 | ||
|
|
b8981c249f | ||
|
|
e71275a0dc | ||
|
|
4fb0db7a1e | ||
|
|
1e9beedc77 | ||
|
|
4a4a0653ef | ||
|
|
c80a900277 | ||
|
|
6fb0394d96 | ||
|
|
a6a7712039 | ||
|
|
dd0687ba29 | ||
|
|
9cb87a5333 | ||
|
|
8ec93d84a0 | ||
|
|
1d38715db9 | ||
|
|
6225c4eb35 | ||
|
|
e58ce2fbe6 | ||
|
|
8881d62e78 | ||
|
|
effb2a1265 | ||
|
|
ab387473b5 | ||
|
|
3cf6079b70 | ||
|
|
53c655bb53 | ||
|
|
87952463c2 | ||
|
|
3a8a63a49a | ||
|
|
debe115044 | ||
|
|
554d2808fd | ||
|
|
12b2c89a25 | ||
|
|
a66fc3a07e | ||
|
|
7b3705cab0 | ||
|
|
8e99e5f5e8 | ||
|
|
c5ba5370bb | ||
|
|
464dec9810 | ||
|
|
c2e2ec8803 | ||
|
|
37378e2360 | ||
|
|
678385d90c | ||
|
|
4c461f087f | ||
|
|
88a2b69980 | ||
|
|
1f57792da7 | ||
|
|
9bb4c45a23 | ||
|
|
75fd19f491 | ||
|
|
0ac16bdeb7 | ||
|
|
223ee41e10 | ||
|
|
c126ded82e | ||
|
|
0edf78b7fd | ||
|
|
5af3580987 | ||
|
|
343cb6f97a | ||
|
|
023c8ac13e | ||
|
|
c385eed795 | ||
|
|
ee5fdd789f | ||
|
|
df1e400f4e | ||
|
|
6c9c298478 | ||
|
|
7106ee150d | ||
|
|
03e2287f80 | ||
|
|
2edcd41e24 | ||
|
|
0fe043bd99 | ||
|
|
6686f5240d | ||
|
|
2936facf0f | ||
|
|
cc208f2c43 | ||
|
|
9a0fc231e5 | ||
|
|
bfc0ae62ec | ||
|
|
5e7d8d97f2 | ||
|
|
70ceb16ed6 | ||
|
|
f162fa639f | ||
|
|
f000c72546 | ||
|
|
32c01f931c | ||
|
|
d0121e2b9d | ||
|
|
1caab8ce1d | ||
|
|
878be435a1 | ||
|
|
6a68ae989e | ||
|
|
00993da781 | ||
|
|
e9ef67e402 | ||
|
|
83ebfececf | ||
|
|
ec8bf6251f | ||
|
|
1b2874b3a5 | ||
|
|
0ac1053a71 | ||
|
|
224d270952 | ||
|
|
c6137545cd | ||
|
|
335417f9f4 | ||
|
|
cb797223ed | ||
|
|
3a2a0313ac | ||
|
|
e221a5a73a | ||
|
|
2b7aaf095f | ||
|
|
6f01e7b8d8 | ||
|
|
d594419200 | ||
|
|
bf50e3f898 | ||
|
|
d434f1781f | ||
|
|
3f311a45ef | ||
|
|
9293b7796e | ||
|
|
b1e7883526 | ||
|
|
7fcf6a253d | ||
|
|
be8d985d15 | ||
|
|
974c90dddc | ||
|
|
4811d395d2 | ||
|
|
132423d577 | ||
|
|
10829e2f00 | ||
|
|
47b908c964 | ||
|
|
0f8e471d5d | ||
|
|
2537119250 | ||
|
|
977066d114 | ||
|
|
46e956dc74 | ||
|
|
7afdd8d44f | ||
|
|
6daf204b4f | ||
|
|
2f4a550a4a | ||
|
|
fe214f6b41 | ||
|
|
ca7de81888 | ||
|
|
17ce20355a | ||
|
|
34981b4765 | ||
|
|
21151a2e09 | ||
|
|
1abb7f5b8c | ||
|
|
05998549a4 | ||
|
|
96283a3629 | ||
|
|
2bfc7abe9c | ||
|
|
4f389eca8d | ||
|
|
1789479955 | ||
|
|
212800155b | ||
|
|
c241bf2104 | ||
|
|
bda61f32f3 | ||
|
|
59316dbaf9 | ||
|
|
b726073a5b | ||
|
|
adf897c812 | ||
|
|
1fc4c2442c | ||
|
|
414643ee90 | ||
|
|
1a1cbd6ea1 | ||
|
|
9ae809a62f | ||
|
|
eb9b1c905d | ||
|
|
fe9a8f49c1 | ||
|
|
f912c8fce3 | ||
|
|
1d1ca43c35 | ||
|
|
bce45f44e4 | ||
|
|
07583fb563 | ||
|
|
775aa23992 | ||
|
|
05ed6b7e73 | ||
|
|
d602694ea7 | ||
|
|
18d71bc0d4 | ||
|
|
3fa68643ba | ||
|
|
8904525c36 | ||
|
|
3ce09a98f3 | ||
|
|
b833768e71 | ||
|
|
b9a6afd993 | ||
|
|
b5a81ea927 | ||
|
|
750e92cdde | ||
|
|
da45f4c011 | ||
|
|
544bb5c11c | ||
|
|
1fc6f62f70 | ||
|
|
8751ad27ec | ||
|
|
159d3d5b87 | ||
|
|
34d6d99d8c | ||
|
|
6c46931b1a | ||
|
|
2c3e2d9d08 | ||
|
|
7be1562fa4 | ||
|
|
294389e7c7 | ||
|
|
2c8ce133f7 | ||
|
|
4f1d4d90d0 | ||
|
|
7b7484332b | ||
|
|
50e94b85aa | ||
|
|
9b820594ef | ||
|
|
ddcd45d56d | ||
|
|
c4a4c16516 | ||
|
|
5ae9ad0762 | ||
|
|
405f7d498e | ||
|
|
bcd6a5b1e7 | ||
|
|
e5e5ac994c | ||
|
|
e1f8d6ec49 | ||
|
|
6f50242f5a | ||
|
|
036f7ece05 | ||
|
|
622a2ff072 | ||
|
|
60334ca04a | ||
|
|
ade47b4e83 | ||
|
|
d7e550dfea | ||
|
|
c3cc0b9bf7 | ||
|
|
5ad89189c1 | ||
|
|
f1bbd4eb13 | ||
|
|
fba89dfacb | ||
|
|
b93ed41215 | ||
|
|
13ff6a7304 | ||
|
|
ad97405e55 | ||
|
|
376e231d7b | ||
|
|
e16d76f6dd | ||
|
|
ffd0fd92fc | ||
|
|
933613d730 | ||
|
|
9b0673bf92 | ||
|
|
7cba22aa28 | ||
|
|
af05b614a9 | ||
|
|
c0fa219a8b | ||
|
|
4e3a47b0f4 | ||
|
|
161276b58a | ||
|
|
47713019a5 | ||
|
|
010632a21d | ||
|
|
e77fe550af | ||
|
|
0a4233da14 | ||
|
|
56fb70ab1e | ||
|
|
4a1f2bc01d | ||
|
|
20292fbf16 | ||
|
|
1290cf8991 | ||
|
|
b8b8af7cf1 | ||
|
|
75f4f452d3 | ||
|
|
9de492384f | ||
|
|
14c4e161f2 | ||
|
|
c55efbc0ba | ||
|
|
f0610222d9 | ||
|
|
302ee4a50f | ||
|
|
2170819159 | ||
|
|
0d1fac321a | ||
|
|
dbbc1756dd | ||
|
|
d5b882d3f8 | ||
|
|
5dba4d1384 | ||
|
|
de240e40a5 | ||
|
|
3bbdc828d9 | ||
|
|
b2b92aea31 | ||
|
|
e0680d9612 | ||
|
|
d286df649e | ||
|
|
e0cbc247b2 | ||
|
|
a2c8a82559 | ||
|
|
8b91323705 | ||
|
|
89fdbf5660 | ||
|
|
7ed5dfdb8f | ||
|
|
824c65eb38 | ||
|
|
e43eeeba4a | ||
|
|
9e2c7cc145 | ||
|
|
989598b9cd | ||
|
|
6a41942de6 | ||
|
|
d263072aca | ||
|
|
78c8467bf6 | ||
|
|
084908bd04 | ||
|
|
039a775ce4 | ||
|
|
dd9e80f067 | ||
|
|
a3a2af948e | ||
|
|
0bedf3ea59 | ||
|
|
1775ac4803 | ||
|
|
ae1a615863 | ||
|
|
a6ef1b8902 | ||
|
|
94d17b81d4 | ||
|
|
44a63d9cec | ||
|
|
e2b4b5a57e | ||
|
|
ec30aa323e | ||
|
|
95e9087c6e | ||
|
|
db38099557 | ||
|
|
164d5cdec9 | ||
|
|
c6b1076092 | ||
|
|
6aebe856da | ||
|
|
4737551918 | ||
|
|
c2cb79f700 | ||
|
|
d7c05984be | ||
|
|
55429e2f03 | ||
|
|
25ffe8b0e4 | ||
|
|
303a9d1110 | ||
|
|
de8c907c51 | ||
|
|
0fd1cabd60 | ||
|
|
8dd480735c | ||
|
|
676f1a1f0e | ||
|
|
ce75422126 | ||
|
|
3a097d6b15 | ||
|
|
9de1bf1020 | ||
|
|
93e5cf1c25 | ||
|
|
6e2376570b | ||
|
|
b20c4bf197 | ||
|
|
6ae1d92033 |
2
.github/helper/install_dependencies.sh
vendored
2
.github/helper/install_dependencies.sh
vendored
@@ -5,7 +5,7 @@ echo "Setting Up System Dependencies..."
|
|||||||
|
|
||||||
sudo apt update
|
sudo apt update
|
||||||
sudo apt remove mysql-server mysql-client
|
sudo apt remove mysql-server mysql-client
|
||||||
sudo apt-get install libcups2-dev redis-server mariadb-client
|
sudo apt-get install libcups2-dev redis-server mariadb-client libmariadb-dev
|
||||||
|
|
||||||
install_wkhtmltopdf() {
|
install_wkhtmltopdf() {
|
||||||
wget -q https://github.com/wkhtmltopdf/packaging/releases/download/0.12.6-1/wkhtmltox_0.12.6-1.focal_amd64.deb
|
wget -q https://github.com/wkhtmltopdf/packaging/releases/download/0.12.6-1/wkhtmltox_0.12.6-1.focal_amd64.deb
|
||||||
|
|||||||
2
.github/workflows/make_release_pr.yml
vendored
2
.github/workflows/make_release_pr.yml
vendored
@@ -1,7 +1,7 @@
|
|||||||
name: Create weekly release
|
name: Create weekly release
|
||||||
on:
|
on:
|
||||||
schedule:
|
schedule:
|
||||||
- cron: '30 4 15 * *'
|
- cron: '30 3 * * 3'
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
|
|||||||
@@ -118,6 +118,10 @@ Replace the following parameters with your values:
|
|||||||
|
|
||||||
The script will set up a production-ready instance of Frappe Learning with all the necessary configurations in about 5 minutes.
|
The script will set up a production-ready instance of Frappe Learning with all the necessary configurations in about 5 minutes.
|
||||||
|
|
||||||
|
**Note:** To avoid a `404 Page Not Found` error:
|
||||||
|
- If hosting on a **public server**, make sure your DNS **A record** points to your server's IP.
|
||||||
|
- If hosting **locally**, map your domain to `127.0.0.1` in your `/etc/hosts` file:
|
||||||
|
|
||||||
## Development Setup
|
## Development Setup
|
||||||
|
|
||||||
### Docker
|
### Docker
|
||||||
|
|||||||
180
cypress/e2e/batch_creation.cy.js
Normal file
180
cypress/e2e/batch_creation.cy.js
Normal file
@@ -0,0 +1,180 @@
|
|||||||
|
describe("Batch Creation", () => {
|
||||||
|
it("creates a new batch", () => {
|
||||||
|
cy.login();
|
||||||
|
cy.wait(500);
|
||||||
|
cy.visit("/lms/batches");
|
||||||
|
cy.closeOnboardingModal();
|
||||||
|
|
||||||
|
// Open Settings
|
||||||
|
cy.get("span").contains("Learning").click();
|
||||||
|
cy.get("span").contains("Settings").click();
|
||||||
|
|
||||||
|
// Add a new member
|
||||||
|
cy.get('[id^="headlessui-dialog-panel-v-"]')
|
||||||
|
.find("span")
|
||||||
|
.contains(/^Members$/)
|
||||||
|
.click();
|
||||||
|
cy.get('[id^="headlessui-dialog-panel-v-"]')
|
||||||
|
.find("button")
|
||||||
|
.contains("New")
|
||||||
|
.click();
|
||||||
|
|
||||||
|
const dateNow = Date.now();
|
||||||
|
const randomEmail = `testuser_${dateNow}@example.com`;
|
||||||
|
const randomName = `Test User ${dateNow}`;
|
||||||
|
|
||||||
|
cy.get("input[placeholder='jane@doe.com']").type(randomEmail);
|
||||||
|
cy.get("input[placeholder='Jane']").type(randomName);
|
||||||
|
cy.get("button").contains("Add").click();
|
||||||
|
|
||||||
|
// Add evaluator
|
||||||
|
cy.get('[id^="headlessui-dialog-panel-v-"]')
|
||||||
|
.find("span")
|
||||||
|
.contains(/^Evaluators$/)
|
||||||
|
.click();
|
||||||
|
|
||||||
|
cy.get('[id^="headlessui-dialog-panel-v-"]')
|
||||||
|
.find("button")
|
||||||
|
.contains("New")
|
||||||
|
.click();
|
||||||
|
const randomEvaluator = `evaluator${dateNow}@example.com`;
|
||||||
|
|
||||||
|
cy.get("input[placeholder='jane@doe.com']").type(randomEvaluator);
|
||||||
|
cy.get("button").contains("Add").click();
|
||||||
|
cy.get("div").contains(randomEvaluator).should("be.visible").click();
|
||||||
|
|
||||||
|
cy.visit("/lms/batches");
|
||||||
|
cy.closeOnboardingModal();
|
||||||
|
|
||||||
|
// Create a batch
|
||||||
|
cy.get("button").contains("Create").click();
|
||||||
|
cy.wait(500);
|
||||||
|
cy.url().should("include", "/batches/new/edit");
|
||||||
|
cy.get("label").contains("Title").type("Test Batch");
|
||||||
|
|
||||||
|
cy.get("label").contains("Start Date").type("2030-10-01");
|
||||||
|
cy.get("label").contains("End Date").type("2030-10-31");
|
||||||
|
cy.get("label").contains("Start Time").type("10:00");
|
||||||
|
cy.get("label").contains("End Time").type("11:00");
|
||||||
|
cy.get("label").contains("Timezone").type("IST");
|
||||||
|
cy.get("label").contains("Seat Count").type("10");
|
||||||
|
cy.get("label").contains("Published").click();
|
||||||
|
|
||||||
|
cy.get("label")
|
||||||
|
.contains("Short Description")
|
||||||
|
.type("Test Batch Short Description to test the UI");
|
||||||
|
cy.get("div[contenteditable=true").invoke(
|
||||||
|
"text",
|
||||||
|
"Test Batch 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."
|
||||||
|
);
|
||||||
|
|
||||||
|
/* Instructor */
|
||||||
|
cy.get("label")
|
||||||
|
.contains("Instructors")
|
||||||
|
.parent()
|
||||||
|
.within(() => {
|
||||||
|
cy.get("input").click().type("evaluator");
|
||||||
|
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.button("Save").click();
|
||||||
|
cy.wait(1000);
|
||||||
|
let batchName;
|
||||||
|
cy.url().then((url) => {
|
||||||
|
console.log(url);
|
||||||
|
batchName = url.split("/").pop();
|
||||||
|
cy.wrap(batchName).as("batchName");
|
||||||
|
});
|
||||||
|
cy.wait(500);
|
||||||
|
|
||||||
|
// View Batch
|
||||||
|
cy.wait(1000);
|
||||||
|
cy.visit("/lms/batches");
|
||||||
|
cy.closeOnboardingModal();
|
||||||
|
|
||||||
|
cy.url().should("include", "/lms/batches");
|
||||||
|
|
||||||
|
cy.get('[id^="headlessui-radiogroup-v-"]')
|
||||||
|
.find("span")
|
||||||
|
.contains("Upcoming")
|
||||||
|
.should("be.visible")
|
||||||
|
.click();
|
||||||
|
|
||||||
|
cy.get("@batchName").then((batchName) => {
|
||||||
|
cy.get(`a[href='/lms/batches/details/${batchName}'`).within(() => {
|
||||||
|
cy.get("div").contains("Test Batch").should("be.visible");
|
||||||
|
cy.get("div")
|
||||||
|
.contains("Test Batch Short Description to test the UI")
|
||||||
|
.should("be.visible");
|
||||||
|
cy.get("span")
|
||||||
|
.contains("01 Oct 2030 - 31 Oct 2030")
|
||||||
|
.should("be.visible");
|
||||||
|
cy.get("span")
|
||||||
|
.contains("10:00 AM - 11:00 AM")
|
||||||
|
.should("be.visible");
|
||||||
|
cy.get("span").contains("IST").should("be.visible");
|
||||||
|
cy.get("a").contains("Evaluator").should("be.visible");
|
||||||
|
cy.get("div")
|
||||||
|
.contains("10")
|
||||||
|
.should("be.visible")
|
||||||
|
.get("span")
|
||||||
|
.contains("Seats Left")
|
||||||
|
.should("be.visible");
|
||||||
|
});
|
||||||
|
cy.get(`a[href='/lms/batches/details/${batchName}'`).click();
|
||||||
|
});
|
||||||
|
|
||||||
|
cy.get("div").contains("Test Batch").should("be.visible");
|
||||||
|
cy.get("div")
|
||||||
|
.contains("Test Batch Short Description to test the UI")
|
||||||
|
.should("be.visible");
|
||||||
|
cy.get("a").contains("Evaluator").should("be.visible");
|
||||||
|
cy.get("span")
|
||||||
|
.contains("01 Oct 2030 - 31 Oct 2030")
|
||||||
|
.should("be.visible");
|
||||||
|
cy.get("span").contains("10:00 AM - 11:00 AM").should("be.visible");
|
||||||
|
cy.get("span").contains("IST").should("be.visible");
|
||||||
|
cy.get("div")
|
||||||
|
.contains("10")
|
||||||
|
.should("be.visible")
|
||||||
|
.get("span")
|
||||||
|
.contains("Seats Left")
|
||||||
|
.should("be.visible");
|
||||||
|
|
||||||
|
cy.get("p")
|
||||||
|
.contains(
|
||||||
|
"Test Batch 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."
|
||||||
|
)
|
||||||
|
.should("be.visible");
|
||||||
|
cy.get("button").contains("Manage Batch").click();
|
||||||
|
|
||||||
|
/* Add student to batch */
|
||||||
|
cy.get("button").contains("Add").click();
|
||||||
|
cy.get('div[id^="headlessui-dialog-panel-v-"]')
|
||||||
|
.first()
|
||||||
|
.find("button")
|
||||||
|
.eq(1)
|
||||||
|
.click();
|
||||||
|
cy.get("input[id^='headlessui-combobox-input-v-']").type(randomEmail);
|
||||||
|
cy.get("div").contains(randomEmail).click();
|
||||||
|
cy.get("button").contains("Submit").click();
|
||||||
|
|
||||||
|
// Verify Seat Count
|
||||||
|
cy.get("span").contains("Details").click();
|
||||||
|
cy.get("div")
|
||||||
|
.contains("9")
|
||||||
|
.should("be.visible")
|
||||||
|
.get("span")
|
||||||
|
.contains("Seats Left")
|
||||||
|
.should("be.visible");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -8,7 +8,7 @@ describe("Course Creation", () => {
|
|||||||
cy.closeOnboardingModal();
|
cy.closeOnboardingModal();
|
||||||
|
|
||||||
// Create a course
|
// Create a course
|
||||||
cy.get("button").contains("New").click();
|
cy.get("button").contains("Create").click();
|
||||||
cy.wait(500);
|
cy.wait(500);
|
||||||
cy.url().should("include", "/courses/new/edit");
|
cy.url().should("include", "/courses/new/edit");
|
||||||
|
|
||||||
@@ -107,7 +107,7 @@ describe("Course Creation", () => {
|
|||||||
cy.get("div").contains(
|
cy.get("div").contains(
|
||||||
"Test Course Short Introduction to test the UI"
|
"Test Course Short Introduction to test the UI"
|
||||||
);
|
);
|
||||||
cy.get(".course-image")
|
cy.get(".bg-cover")
|
||||||
.invoke("css", "background-image")
|
.invoke("css", "background-image")
|
||||||
.should("include", "/files/profile");
|
.should("include", "/files/profile");
|
||||||
});
|
});
|
||||||
@@ -140,6 +140,7 @@ describe("Course Creation", () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
// Add Discussion
|
// Add Discussion
|
||||||
|
cy.get("span").contains("Community").click();
|
||||||
cy.button("New Question").click();
|
cy.button("New Question").click();
|
||||||
cy.wait(500);
|
cy.wait(500);
|
||||||
cy.get("[id^=headlessui-dialog-panel-").within(() => {
|
cy.get("[id^=headlessui-dialog-panel-").within(() => {
|
||||||
|
|||||||
@@ -72,8 +72,15 @@ Cypress.Commands.add("paste", { prevSubject: true }, (subject, text) => {
|
|||||||
|
|
||||||
Cypress.Commands.add("closeOnboardingModal", () => {
|
Cypress.Commands.add("closeOnboardingModal", () => {
|
||||||
cy.wait(500);
|
cy.wait(500);
|
||||||
|
cy.get("body").then(($body) => {
|
||||||
|
// Check if any element with class including 'z-50' exists
|
||||||
|
if ($body.find('[class*="z-50"]').length > 0) {
|
||||||
cy.get('[class*="z-50"]')
|
cy.get('[class*="z-50"]')
|
||||||
.find('button:has(svg[class*="feather-x"])')
|
.find('button:has(svg[class*="feather-x"])')
|
||||||
.realClick();
|
.realClick();
|
||||||
cy.wait(1000);
|
cy.wait(1000);
|
||||||
|
} else {
|
||||||
|
cy.log("Onboarding modal not found, skipping close.");
|
||||||
|
}
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
1
frappe-ui
Submodule
1
frappe-ui
Submodule
Submodule frappe-ui added at 333dce1a4d
10
frontend/auto-imports.d.ts
vendored
Normal file
10
frontend/auto-imports.d.ts
vendored
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
/* eslint-disable */
|
||||||
|
/* prettier-ignore */
|
||||||
|
// @ts-nocheck
|
||||||
|
// noinspection JSUnusedGlobalSymbols
|
||||||
|
// Generated by unplugin-auto-import
|
||||||
|
// biome-ignore lint: disable
|
||||||
|
export {}
|
||||||
|
declare global {
|
||||||
|
|
||||||
|
}
|
||||||
35
frontend/components.d.ts
vendored
35
frontend/components.d.ts
vendored
@@ -19,6 +19,10 @@ declare module 'vue' {
|
|||||||
AssignmentForm: typeof import('./src/components/Modals/AssignmentForm.vue')['default']
|
AssignmentForm: typeof import('./src/components/Modals/AssignmentForm.vue')['default']
|
||||||
AudioBlock: typeof import('./src/components/AudioBlock.vue')['default']
|
AudioBlock: typeof import('./src/components/AudioBlock.vue')['default']
|
||||||
Autocomplete: typeof import('./src/components/Controls/Autocomplete.vue')['default']
|
Autocomplete: typeof import('./src/components/Controls/Autocomplete.vue')['default']
|
||||||
|
BadgeAssignmentForm: typeof import('./src/components/Settings/BadgeAssignmentForm.vue')['default']
|
||||||
|
BadgeAssignments: typeof import('./src/components/Settings/BadgeAssignments.vue')['default']
|
||||||
|
BadgeForm: typeof import('./src/components/Settings/BadgeForm.vue')['default']
|
||||||
|
Badges: typeof import('./src/components/Settings/Badges.vue')['default']
|
||||||
BatchCard: typeof import('./src/components/BatchCard.vue')['default']
|
BatchCard: typeof import('./src/components/BatchCard.vue')['default']
|
||||||
BatchCourseModal: typeof import('./src/components/Modals/BatchCourseModal.vue')['default']
|
BatchCourseModal: typeof import('./src/components/Modals/BatchCourseModal.vue')['default']
|
||||||
BatchCourses: typeof import('./src/components/BatchCourses.vue')['default']
|
BatchCourses: typeof import('./src/components/BatchCourses.vue')['default']
|
||||||
@@ -27,17 +31,21 @@ declare module 'vue' {
|
|||||||
BatchOverlay: typeof import('./src/components/BatchOverlay.vue')['default']
|
BatchOverlay: typeof import('./src/components/BatchOverlay.vue')['default']
|
||||||
BatchStudentProgress: typeof import('./src/components/Modals/BatchStudentProgress.vue')['default']
|
BatchStudentProgress: typeof import('./src/components/Modals/BatchStudentProgress.vue')['default']
|
||||||
BatchStudents: typeof import('./src/components/BatchStudents.vue')['default']
|
BatchStudents: typeof import('./src/components/BatchStudents.vue')['default']
|
||||||
BrandSettings: typeof import('./src/components/BrandSettings.vue')['default']
|
BrandSettings: typeof import('./src/components/Settings/BrandSettings.vue')['default']
|
||||||
BulkCertificates: typeof import('./src/components/Modals/BulkCertificates.vue')['default']
|
BulkCertificates: typeof import('./src/components/Modals/BulkCertificates.vue')['default']
|
||||||
Categories: typeof import('./src/components/Categories.vue')['default']
|
Categories: typeof import('./src/components/Settings/Categories.vue')['default']
|
||||||
CertificationLinks: typeof import('./src/components/CertificationLinks.vue')['default']
|
CertificationLinks: typeof import('./src/components/CertificationLinks.vue')['default']
|
||||||
ChapterModal: typeof import('./src/components/Modals/ChapterModal.vue')['default']
|
ChapterModal: typeof import('./src/components/Modals/ChapterModal.vue')['default']
|
||||||
|
ChildTable: typeof import('./src/components/Controls/ChildTable.vue')['default']
|
||||||
|
Code: typeof import('./src/components/Controls/Code.vue')['default']
|
||||||
CodeEditor: typeof import('./src/components/Controls/CodeEditor.vue')['default']
|
CodeEditor: typeof import('./src/components/Controls/CodeEditor.vue')['default']
|
||||||
CollapseSidebar: typeof import('./src/components/Icons/CollapseSidebar.vue')['default']
|
CollapseSidebar: typeof import('./src/components/Icons/CollapseSidebar.vue')['default']
|
||||||
|
ColorSwatches: typeof import('./src/components/Controls/ColorSwatches.vue')['default']
|
||||||
CourseCard: typeof import('./src/components/CourseCard.vue')['default']
|
CourseCard: typeof import('./src/components/CourseCard.vue')['default']
|
||||||
CourseCardOverlay: typeof import('./src/components/CourseCardOverlay.vue')['default']
|
CourseCardOverlay: typeof import('./src/components/CourseCardOverlay.vue')['default']
|
||||||
CourseInstructors: typeof import('./src/components/CourseInstructors.vue')['default']
|
CourseInstructors: typeof import('./src/components/CourseInstructors.vue')['default']
|
||||||
CourseOutline: typeof import('./src/components/CourseOutline.vue')['default']
|
CourseOutline: typeof import('./src/components/CourseOutline.vue')['default']
|
||||||
|
CourseProgressSummary: typeof import('./src/components/Modals/CourseProgressSummary.vue')['default']
|
||||||
CourseReviews: typeof import('./src/components/CourseReviews.vue')['default']
|
CourseReviews: typeof import('./src/components/CourseReviews.vue')['default']
|
||||||
CreateOutline: typeof import('./src/components/CreateOutline.vue')['default']
|
CreateOutline: typeof import('./src/components/CreateOutline.vue')['default']
|
||||||
DateRange: typeof import('./src/components/Common/DateRange.vue')['default']
|
DateRange: typeof import('./src/components/Common/DateRange.vue')['default']
|
||||||
@@ -48,16 +56,17 @@ declare module 'vue' {
|
|||||||
EditCoverImage: typeof import('./src/components/Modals/EditCoverImage.vue')['default']
|
EditCoverImage: typeof import('./src/components/Modals/EditCoverImage.vue')['default']
|
||||||
EditProfile: typeof import('./src/components/Modals/EditProfile.vue')['default']
|
EditProfile: typeof import('./src/components/Modals/EditProfile.vue')['default']
|
||||||
EmailTemplateModal: typeof import('./src/components/Modals/EmailTemplateModal.vue')['default']
|
EmailTemplateModal: typeof import('./src/components/Modals/EmailTemplateModal.vue')['default']
|
||||||
EmailTemplates: typeof import('./src/components/EmailTemplates.vue')['default']
|
EmailTemplates: typeof import('./src/components/Settings/EmailTemplates.vue')['default']
|
||||||
EmptyState: typeof import('./src/components/EmptyState.vue')['default']
|
EmptyState: typeof import('./src/components/EmptyState.vue')['default']
|
||||||
EvaluationModal: typeof import('./src/components/Modals/EvaluationModal.vue')['default']
|
EvaluationModal: typeof import('./src/components/Modals/EvaluationModal.vue')['default']
|
||||||
Evaluators: typeof import('./src/components/Evaluators.vue')['default']
|
Evaluators: typeof import('./src/components/Settings/Evaluators.vue')['default']
|
||||||
Event: typeof import('./src/components/Modals/Event.vue')['default']
|
Event: typeof import('./src/components/Modals/Event.vue')['default']
|
||||||
ExplanationVideos: typeof import('./src/components/Modals/ExplanationVideos.vue')['default']
|
ExplanationVideos: typeof import('./src/components/Modals/ExplanationVideos.vue')['default']
|
||||||
FeedbackModal: typeof import('./src/components/Modals/FeedbackModal.vue')['default']
|
FeedbackModal: typeof import('./src/components/Modals/FeedbackModal.vue')['default']
|
||||||
FrappeCloudIcon: typeof import('./src/components/Icons/FrappeCloudIcon.vue')['default']
|
FrappeCloudIcon: typeof import('./src/components/Icons/FrappeCloudIcon.vue')['default']
|
||||||
IconPicker: typeof import('./src/components/Controls/IconPicker.vue')['default']
|
IconPicker: typeof import('./src/components/Controls/IconPicker.vue')['default']
|
||||||
IndicatorIcon: typeof import('./src/components/Icons/IndicatorIcon.vue')['default']
|
IndicatorIcon: typeof import('./src/components/Icons/IndicatorIcon.vue')['default']
|
||||||
|
InlineLessonMenu: typeof import('./src/components/Notes/InlineLessonMenu.vue')['default']
|
||||||
InviteIcon: typeof import('./src/components/Icons/InviteIcon.vue')['default']
|
InviteIcon: typeof import('./src/components/Icons/InviteIcon.vue')['default']
|
||||||
JobApplicationModal: typeof import('./src/components/Modals/JobApplicationModal.vue')['default']
|
JobApplicationModal: typeof import('./src/components/Modals/JobApplicationModal.vue')['default']
|
||||||
JobCard: typeof import('./src/components/JobCard.vue')['default']
|
JobCard: typeof import('./src/components/JobCard.vue')['default']
|
||||||
@@ -65,37 +74,45 @@ declare module 'vue' {
|
|||||||
LessonHelp: typeof import('./src/components/LessonHelp.vue')['default']
|
LessonHelp: typeof import('./src/components/LessonHelp.vue')['default']
|
||||||
Link: typeof import('./src/components/Controls/Link.vue')['default']
|
Link: typeof import('./src/components/Controls/Link.vue')['default']
|
||||||
LiveClass: typeof import('./src/components/LiveClass.vue')['default']
|
LiveClass: typeof import('./src/components/LiveClass.vue')['default']
|
||||||
|
LiveClassAttendance: typeof import('./src/components/Modals/LiveClassAttendance.vue')['default']
|
||||||
LiveClassModal: typeof import('./src/components/Modals/LiveClassModal.vue')['default']
|
LiveClassModal: typeof import('./src/components/Modals/LiveClassModal.vue')['default']
|
||||||
LMSLogo: typeof import('./src/components/Icons/LMSLogo.vue')['default']
|
LMSLogo: typeof import('./src/components/Icons/LMSLogo.vue')['default']
|
||||||
Members: typeof import('./src/components/Members.vue')['default']
|
Members: typeof import('./src/components/Settings/Members.vue')['default']
|
||||||
MobileLayout: typeof import('./src/components/MobileLayout.vue')['default']
|
MobileLayout: typeof import('./src/components/MobileLayout.vue')['default']
|
||||||
MultiSelect: typeof import('./src/components/Controls/MultiSelect.vue')['default']
|
MultiSelect: typeof import('./src/components/Controls/MultiSelect.vue')['default']
|
||||||
NoPermission: typeof import('./src/components/NoPermission.vue')['default']
|
NoPermission: typeof import('./src/components/NoPermission.vue')['default']
|
||||||
NoSidebarLayout: typeof import('./src/components/NoSidebarLayout.vue')['default']
|
NoSidebarLayout: typeof import('./src/components/NoSidebarLayout.vue')['default']
|
||||||
|
Notes: typeof import('./src/components/Notes/Notes.vue')['default']
|
||||||
NotPermitted: typeof import('./src/components/NotPermitted.vue')['default']
|
NotPermitted: typeof import('./src/components/NotPermitted.vue')['default']
|
||||||
PageModal: typeof import('./src/components/Modals/PageModal.vue')['default']
|
PageModal: typeof import('./src/components/Modals/PageModal.vue')['default']
|
||||||
PaymentSettings: typeof import('./src/components/PaymentSettings.vue')['default']
|
PaymentSettings: typeof import('./src/components/Settings/PaymentSettings.vue')['default']
|
||||||
Play: typeof import('./src/components/Icons/Play.vue')['default']
|
Play: typeof import('./src/components/Icons/Play.vue')['default']
|
||||||
ProgressBar: typeof import('./src/components/ProgressBar.vue')['default']
|
ProgressBar: typeof import('./src/components/ProgressBar.vue')['default']
|
||||||
Question: typeof import('./src/components/Modals/Question.vue')['default']
|
Question: typeof import('./src/components/Modals/Question.vue')['default']
|
||||||
Quiz: typeof import('./src/components/Quiz.vue')['default']
|
Quiz: typeof import('./src/components/Quiz.vue')['default']
|
||||||
QuizBlock: typeof import('./src/components/QuizBlock.vue')['default']
|
QuizBlock: typeof import('./src/components/QuizBlock.vue')['default']
|
||||||
|
QuizInVideo: typeof import('./src/components/Modals/QuizInVideo.vue')['default']
|
||||||
Rating: typeof import('./src/components/Controls/Rating.vue')['default']
|
Rating: typeof import('./src/components/Controls/Rating.vue')['default']
|
||||||
|
RelatedCourses: typeof import('./src/components/RelatedCourses.vue')['default']
|
||||||
ReviewModal: typeof import('./src/components/Modals/ReviewModal.vue')['default']
|
ReviewModal: typeof import('./src/components/Modals/ReviewModal.vue')['default']
|
||||||
RouterLink: typeof import('vue-router')['RouterLink']
|
RouterLink: typeof import('vue-router')['RouterLink']
|
||||||
RouterView: typeof import('vue-router')['RouterView']
|
RouterView: typeof import('vue-router')['RouterView']
|
||||||
SettingDetails: typeof import('./src/components/SettingDetails.vue')['default']
|
SettingDetails: typeof import('./src/components/Settings/SettingDetails.vue')['default']
|
||||||
SettingFields: typeof import('./src/components/SettingFields.vue')['default']
|
SettingFields: typeof import('./src/components/Settings/SettingFields.vue')['default']
|
||||||
Settings: typeof import('./src/components/Modals/Settings.vue')['default']
|
Settings: typeof import('./src/components/Settings/Settings.vue')['default']
|
||||||
SidebarLink: typeof import('./src/components/SidebarLink.vue')['default']
|
SidebarLink: typeof import('./src/components/SidebarLink.vue')['default']
|
||||||
StudentHeatmap: typeof import('./src/components/StudentHeatmap.vue')['default']
|
StudentHeatmap: typeof import('./src/components/StudentHeatmap.vue')['default']
|
||||||
StudentModal: typeof import('./src/components/Modals/StudentModal.vue')['default']
|
StudentModal: typeof import('./src/components/Modals/StudentModal.vue')['default']
|
||||||
Tags: typeof import('./src/components/Tags.vue')['default']
|
Tags: typeof import('./src/components/Tags.vue')['default']
|
||||||
UnsplashImageBrowser: typeof import('./src/components/UnsplashImageBrowser.vue')['default']
|
UnsplashImageBrowser: typeof import('./src/components/UnsplashImageBrowser.vue')['default']
|
||||||
UpcomingEvaluations: typeof import('./src/components/UpcomingEvaluations.vue')['default']
|
UpcomingEvaluations: typeof import('./src/components/UpcomingEvaluations.vue')['default']
|
||||||
|
Uploader: typeof import('./src/components/Controls/Uploader.vue')['default']
|
||||||
UploadPlugin: typeof import('./src/components/UploadPlugin.vue')['default']
|
UploadPlugin: typeof import('./src/components/UploadPlugin.vue')['default']
|
||||||
UserAvatar: typeof import('./src/components/UserAvatar.vue')['default']
|
UserAvatar: typeof import('./src/components/UserAvatar.vue')['default']
|
||||||
UserDropdown: typeof import('./src/components/UserDropdown.vue')['default']
|
UserDropdown: typeof import('./src/components/UserDropdown.vue')['default']
|
||||||
VideoBlock: typeof import('./src/components/VideoBlock.vue')['default']
|
VideoBlock: typeof import('./src/components/VideoBlock.vue')['default']
|
||||||
|
VideoStatistics: typeof import('./src/components/Modals/VideoStatistics.vue')['default']
|
||||||
|
ZoomAccountModal: typeof import('./src/components/Modals/ZoomAccountModal.vue')['default']
|
||||||
|
ZoomSettings: typeof import('./src/components/Settings/ZoomSettings.vue')['default']
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,10 @@
|
|||||||
"copy-html-entry": "cp ../lms/public/frontend/index.html ../lms/www/lms.html"
|
"copy-html-entry": "cp ../lms/public/frontend/index.html ../lms/www/lms.html"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@codemirror/lang-html": "^6.4.9",
|
||||||
|
"@codemirror/lang-javascript": "^6.2.4",
|
||||||
|
"@codemirror/lang-json": "^6.0.1",
|
||||||
|
"@codemirror/lang-python": "^6.2.1",
|
||||||
"@editorjs/checklist": "^1.6.0",
|
"@editorjs/checklist": "^1.6.0",
|
||||||
"@editorjs/code": "^2.9.0",
|
"@editorjs/code": "^2.9.0",
|
||||||
"@editorjs/editorjs": "^2.29.0",
|
"@editorjs/editorjs": "^2.29.0",
|
||||||
@@ -24,10 +28,10 @@
|
|||||||
"ace-builds": "^1.36.2",
|
"ace-builds": "^1.36.2",
|
||||||
"apexcharts": "^4.3.0",
|
"apexcharts": "^4.3.0",
|
||||||
"chart.js": "^4.4.1",
|
"chart.js": "^4.4.1",
|
||||||
"codemirror-editor-vue3": "^2.8.0",
|
"codemirror": "^6.0.1",
|
||||||
"dayjs": "^1.11.6",
|
"dayjs": "^1.11.6",
|
||||||
"feather-icons": "^4.28.0",
|
"feather-icons": "^4.28.0",
|
||||||
"frappe-ui": "^0.1.147",
|
"frappe-ui": "0.1.173",
|
||||||
"highlight.js": "^11.11.1",
|
"highlight.js": "^11.11.1",
|
||||||
"lucide-vue-next": "^0.383.0",
|
"lucide-vue-next": "^0.383.0",
|
||||||
"markdown-it": "^14.0.0",
|
"markdown-it": "^14.0.0",
|
||||||
@@ -35,9 +39,11 @@
|
|||||||
"plyr": "^3.7.8",
|
"plyr": "^3.7.8",
|
||||||
"socket.io-client": "^4.7.2",
|
"socket.io-client": "^4.7.2",
|
||||||
"tailwindcss": "3.4.15",
|
"tailwindcss": "3.4.15",
|
||||||
|
"thememirror": "^2.0.1",
|
||||||
"typescript": "^5.7.2",
|
"typescript": "^5.7.2",
|
||||||
"vue": "^3.4.23",
|
"vue": "^3.4.23",
|
||||||
"vue-chartjs": "^5.3.0",
|
"vue-chartjs": "^5.3.0",
|
||||||
|
"vue-codemirror": "^6.1.1",
|
||||||
"vue-draggable-next": "^2.2.1",
|
"vue-draggable-next": "^2.2.1",
|
||||||
"vue-router": "^4.0.12",
|
"vue-router": "^4.0.12",
|
||||||
"vue3-apexcharts": "^1.8.0",
|
"vue3-apexcharts": "^1.8.0",
|
||||||
|
|||||||
BIN
frontend/public/Remove.mp4
Normal file
BIN
frontend/public/Remove.mp4
Normal file
Binary file not shown.
@@ -1,7 +1,9 @@
|
|||||||
<template>
|
<template>
|
||||||
<FrappeUIProvider>
|
<FrappeUIProvider>
|
||||||
<Layout>
|
<Layout>
|
||||||
|
<div class="text-base">
|
||||||
<router-view />
|
<router-view />
|
||||||
|
</div>
|
||||||
</Layout>
|
</Layout>
|
||||||
<Dialogs />
|
<Dialogs />
|
||||||
</FrappeUIProvider>
|
</FrappeUIProvider>
|
||||||
@@ -9,16 +11,19 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { FrappeUIProvider } from 'frappe-ui'
|
import { FrappeUIProvider } from 'frappe-ui'
|
||||||
import { Dialogs } from '@/utils/dialogs'
|
import { Dialogs } from '@/utils/dialogs'
|
||||||
import { computed, onUnmounted, ref } from 'vue'
|
import { computed, onUnmounted, ref, watch } from 'vue'
|
||||||
import { useScreenSize } from './utils/composables'
|
import { useScreenSize } from './utils/composables'
|
||||||
import DesktopLayout from './components/DesktopLayout.vue'
|
import DesktopLayout from './components/DesktopLayout.vue'
|
||||||
import MobileLayout from './components/MobileLayout.vue'
|
import MobileLayout from './components/MobileLayout.vue'
|
||||||
import NoSidebarLayout from './components/NoSidebarLayout.vue'
|
import NoSidebarLayout from './components/NoSidebarLayout.vue'
|
||||||
|
import { usersStore } from '@/stores/user'
|
||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
|
import { posthogSettings } from '@/telemetry'
|
||||||
|
|
||||||
const screenSize = useScreenSize()
|
const screenSize = useScreenSize()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const noSidebar = ref(false)
|
const noSidebar = ref(false)
|
||||||
|
const { userResource } = usersStore()
|
||||||
|
|
||||||
router.beforeEach((to, from, next) => {
|
router.beforeEach((to, from, next) => {
|
||||||
if (to.query.fromLesson || to.path === '/persona') {
|
if (to.query.fromLesson || to.path === '/persona') {
|
||||||
@@ -42,6 +47,11 @@ const Layout = computed(() => {
|
|||||||
|
|
||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
noSidebar.value = false
|
noSidebar.value = false
|
||||||
stopSession()
|
})
|
||||||
|
|
||||||
|
watch(userResource, () => {
|
||||||
|
if (userResource.data) {
|
||||||
|
posthogSettings.reload()
|
||||||
|
}
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -181,8 +181,17 @@
|
|||||||
import UserDropdown from '@/components/UserDropdown.vue'
|
import UserDropdown from '@/components/UserDropdown.vue'
|
||||||
import CollapseSidebar from '@/components/Icons/CollapseSidebar.vue'
|
import CollapseSidebar from '@/components/Icons/CollapseSidebar.vue'
|
||||||
import SidebarLink from '@/components/SidebarLink.vue'
|
import SidebarLink from '@/components/SidebarLink.vue'
|
||||||
import { ref, onMounted, inject, watch, reactive, markRaw, h } from 'vue'
|
import {
|
||||||
import { getSidebarLinks } from '../utils'
|
ref,
|
||||||
|
onMounted,
|
||||||
|
inject,
|
||||||
|
watch,
|
||||||
|
reactive,
|
||||||
|
markRaw,
|
||||||
|
h,
|
||||||
|
onUnmounted,
|
||||||
|
} from 'vue'
|
||||||
|
import { getSidebarLinks } from '@/utils'
|
||||||
import { usersStore } from '@/stores/user'
|
import { usersStore } from '@/stores/user'
|
||||||
import { sessionStore } from '@/stores/session'
|
import { sessionStore } from '@/stores/session'
|
||||||
import { useSidebar } from '@/stores/sidebar'
|
import { useSidebar } from '@/stores/sidebar'
|
||||||
@@ -216,7 +225,7 @@ import {
|
|||||||
IntermediateStepModal,
|
IntermediateStepModal,
|
||||||
} from 'frappe-ui/frappe'
|
} from 'frappe-ui/frappe'
|
||||||
|
|
||||||
const { user, sidebarSettings } = sessionStore()
|
const { user } = sessionStore()
|
||||||
const { userResource } = usersStore()
|
const { userResource } = usersStore()
|
||||||
let sidebarStore = useSidebar()
|
let sidebarStore = useSidebar()
|
||||||
const socket = inject('$socket')
|
const socket = inject('$socket')
|
||||||
@@ -227,6 +236,7 @@ const isModerator = ref(false)
|
|||||||
const isInstructor = ref(false)
|
const isInstructor = ref(false)
|
||||||
const pageToEdit = ref(null)
|
const pageToEdit = ref(null)
|
||||||
const settingsStore = useSettings()
|
const settingsStore = useSettings()
|
||||||
|
const { sidebarSettings } = settingsStore
|
||||||
const showOnboarding = ref(false)
|
const showOnboarding = ref(false)
|
||||||
const showIntermediateModal = ref(false)
|
const showIntermediateModal = ref(false)
|
||||||
const currentStep = ref({})
|
const currentStep = ref({})
|
||||||
@@ -304,7 +314,7 @@ const addNotifications = () => {
|
|||||||
|
|
||||||
const addQuizzes = () => {
|
const addQuizzes = () => {
|
||||||
if (isInstructor.value || isModerator.value) {
|
if (isInstructor.value || isModerator.value) {
|
||||||
sidebarLinks.value.push({
|
sidebarLinks.value.splice(4, 0, {
|
||||||
label: 'Quizzes',
|
label: 'Quizzes',
|
||||||
icon: 'CircleHelp',
|
icon: 'CircleHelp',
|
||||||
to: 'Quizzes',
|
to: 'Quizzes',
|
||||||
@@ -320,7 +330,7 @@ const addQuizzes = () => {
|
|||||||
|
|
||||||
const addAssignments = () => {
|
const addAssignments = () => {
|
||||||
if (isInstructor.value || isModerator.value) {
|
if (isInstructor.value || isModerator.value) {
|
||||||
sidebarLinks.value.push({
|
sidebarLinks.value.splice(5, 0, {
|
||||||
label: 'Assignments',
|
label: 'Assignments',
|
||||||
icon: 'Pencil',
|
icon: 'Pencil',
|
||||||
to: 'Assignments',
|
to: 'Assignments',
|
||||||
@@ -334,6 +344,22 @@ const addAssignments = () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const addProgrammingExercises = () => {
|
||||||
|
if (isInstructor.value || isModerator.value) {
|
||||||
|
sidebarLinks.value.splice(3, 0, {
|
||||||
|
label: 'Programming Exercises',
|
||||||
|
icon: 'Code',
|
||||||
|
to: 'ProgrammingExercises',
|
||||||
|
activeFor: [
|
||||||
|
'ProgrammingExercises',
|
||||||
|
'ProgrammingExerciseForm',
|
||||||
|
'ProgrammingExerciseSubmissions',
|
||||||
|
'ProgrammingExerciseSubmission',
|
||||||
|
],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const addPrograms = () => {
|
const addPrograms = () => {
|
||||||
let activeFor = ['Programs', 'ProgramForm']
|
let activeFor = ['Programs', 'ProgramForm']
|
||||||
let index = 1
|
let index = 1
|
||||||
@@ -617,6 +643,7 @@ watch(userResource, () => {
|
|||||||
isModerator.value = userResource.data.is_moderator
|
isModerator.value = userResource.data.is_moderator
|
||||||
isInstructor.value = userResource.data.is_instructor
|
isInstructor.value = userResource.data.is_instructor
|
||||||
addPrograms()
|
addPrograms()
|
||||||
|
addProgrammingExercises()
|
||||||
addQuizzes()
|
addQuizzes()
|
||||||
addAssignments()
|
addAssignments()
|
||||||
setUpOnboarding()
|
setUpOnboarding()
|
||||||
@@ -626,4 +653,8 @@ watch(userResource, () => {
|
|||||||
const redirectToWebsite = () => {
|
const redirectToWebsite = () => {
|
||||||
window.open('https://frappe.io/learning', '_blank')
|
window.open('https://frappe.io/learning', '_blank')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
socket.off('publish_lms_notifications')
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -2,17 +2,24 @@
|
|||||||
<Dialog
|
<Dialog
|
||||||
v-model="show"
|
v-model="show"
|
||||||
:options="{
|
:options="{
|
||||||
|
title:
|
||||||
|
type == 'quiz'
|
||||||
|
? __('Add a quiz to your lesson')
|
||||||
|
: __('Add an assignment to your lesson'),
|
||||||
size: 'xl',
|
size: 'xl',
|
||||||
|
actions: [
|
||||||
|
{
|
||||||
|
label: __('Save'),
|
||||||
|
variant: 'solid',
|
||||||
|
onClick: () => {
|
||||||
|
addAssessment()
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
}"
|
}"
|
||||||
>
|
>
|
||||||
<template #body>
|
<template #body-content>
|
||||||
<div class="p-5 space-y-4">
|
<div class="">
|
||||||
<div v-if="type == 'quiz'" class="text-lg font-semibold">
|
|
||||||
{{ __('Add a quiz to your lesson') }}
|
|
||||||
</div>
|
|
||||||
<div v-else class="text-lg font-semibold">
|
|
||||||
{{ __('Add an assignment to your lesson') }}
|
|
||||||
</div>
|
|
||||||
<div>
|
<div>
|
||||||
<Link
|
<Link
|
||||||
v-if="type == 'quiz'"
|
v-if="type == 'quiz'"
|
||||||
@@ -29,17 +36,12 @@
|
|||||||
:onCreate="(value, close) => redirectToForm()"
|
:onCreate="(value, close) => redirectToForm()"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex justify-end space-x-2">
|
|
||||||
<Button variant="solid" @click="addAssessment()">
|
|
||||||
{{ __('Save') }}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
</template>
|
</template>
|
||||||
<script setup>
|
<script setup>
|
||||||
import { Dialog, Button } from 'frappe-ui'
|
import { Dialog } from 'frappe-ui'
|
||||||
import { onMounted, ref, nextTick } from 'vue'
|
import { onMounted, ref, nextTick } from 'vue'
|
||||||
import Link from '@/components/Controls/Link.vue'
|
import Link from '@/components/Controls/Link.vue'
|
||||||
|
|
||||||
|
|||||||
@@ -40,7 +40,7 @@
|
|||||||
<template #default="{ column, item }">
|
<template #default="{ column, item }">
|
||||||
<ListRowItem :item="row[column.key]" :align="column.align">
|
<ListRowItem :item="row[column.key]" :align="column.align">
|
||||||
<div v-if="column.key == 'assessment_type'">
|
<div v-if="column.key == 'assessment_type'">
|
||||||
{{ row[column.key] == 'LMS Quiz' ? 'Quiz' : 'Assignment' }}
|
{{ getAssessmentTypeLabel(row[column.key]) }}
|
||||||
</div>
|
</div>
|
||||||
<div v-else-if="column.key == 'title'">
|
<div v-else-if="column.key == 'title'">
|
||||||
{{ row[column.key] }}
|
{{ row[column.key] }}
|
||||||
@@ -172,6 +172,24 @@ const getRowRoute = (row) => {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
} else if (row.assessment_type == 'LMS Programming Exercise') {
|
||||||
|
if (row.submission) {
|
||||||
|
return {
|
||||||
|
name: 'ProgrammingExerciseSubmission',
|
||||||
|
params: {
|
||||||
|
exerciseID: row.assessment_name,
|
||||||
|
submissionID: row.submission.name,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
return {
|
||||||
|
name: 'ProgrammingExerciseSubmission',
|
||||||
|
params: {
|
||||||
|
exerciseID: row.assessment_name,
|
||||||
|
submissionID: 'new',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
return {
|
return {
|
||||||
name: 'QuizPage',
|
name: 'QuizPage',
|
||||||
@@ -213,7 +231,7 @@ const getAssessmentColumns = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const getStatusTheme = (status) => {
|
const getStatusTheme = (status) => {
|
||||||
if (status === 'Pass') {
|
if (status === 'Pass' || status === 'Passed') {
|
||||||
return 'green'
|
return 'green'
|
||||||
} else if (status === 'Not Graded') {
|
} else if (status === 'Not Graded') {
|
||||||
return 'orange'
|
return 'orange'
|
||||||
@@ -221,4 +239,14 @@ const getStatusTheme = (status) => {
|
|||||||
return 'red'
|
return 'red'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const getAssessmentTypeLabel = (type) => {
|
||||||
|
if (type == 'LMS Assignment') {
|
||||||
|
return __('Assignment')
|
||||||
|
} else if (type == 'LMS Quiz') {
|
||||||
|
return __('Quiz')
|
||||||
|
} else if (type == 'LMS Programming Exercise') {
|
||||||
|
return __('Programming Exercise')
|
||||||
|
}
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<template>
|
<template>
|
||||||
<div
|
<div
|
||||||
class="flex flex-col border hover:border-outline-gray-4 rounded-md p-4 h-full"
|
class="flex flex-col border hover:border-outline-gray-3 rounded-md p-4 h-full"
|
||||||
style="min-height: 150px"
|
style="min-height: 150px"
|
||||||
>
|
>
|
||||||
<div class="text-lg leading-5 font-semibold mb-2 text-ink-gray-9">
|
<div class="text-lg leading-5 font-semibold mb-2 text-ink-gray-9">
|
||||||
@@ -70,9 +70,8 @@
|
|||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<script setup>
|
<script setup>
|
||||||
import { Badge } from 'frappe-ui'
|
import { formatTime } from '@/utils'
|
||||||
import { formatTime } from '../utils'
|
import { Clock, Globe } from 'lucide-vue-next'
|
||||||
import { Clock, BookOpen, Globe } from 'lucide-vue-next'
|
|
||||||
import DateRange from '@/components/Common/DateRange.vue'
|
import DateRange from '@/components/Common/DateRange.vue'
|
||||||
import CourseInstructors from '@/components/CourseInstructors.vue'
|
import CourseInstructors from '@/components/CourseInstructors.vue'
|
||||||
import UserAvatar from '@/components/UserAvatar.vue'
|
import UserAvatar from '@/components/UserAvatar.vue'
|
||||||
|
|||||||
@@ -106,7 +106,6 @@ const courses = createResource({
|
|||||||
params: {
|
params: {
|
||||||
batch: props.batch,
|
batch: props.batch,
|
||||||
},
|
},
|
||||||
cache: ['batchCourses', props.batchName],
|
|
||||||
auto: true,
|
auto: true,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -6,13 +6,12 @@
|
|||||||
:courses="batch.data.courses"
|
:courses="batch.data.courses"
|
||||||
/>
|
/>
|
||||||
<Assessments :batch="batch.data.name" />
|
<Assessments :batch="batch.data.name" />
|
||||||
<StudentHeatmap />
|
<!-- <StudentHeatmap /> -->
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<script setup>
|
<script setup>
|
||||||
import UpcomingEvaluations from '@/components/UpcomingEvaluations.vue'
|
import UpcomingEvaluations from '@/components/UpcomingEvaluations.vue'
|
||||||
import Assessments from '@/components/Assessments.vue'
|
import Assessments from '@/components/Assessments.vue'
|
||||||
import StudentHeatmap from '@/components/StudentHeatmap.vue'
|
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
batch: {
|
batch: {
|
||||||
|
|||||||
@@ -65,6 +65,10 @@
|
|||||||
}"
|
}"
|
||||||
>
|
>
|
||||||
<Button variant="solid" class="w-full mt-4">
|
<Button variant="solid" class="w-full mt-4">
|
||||||
|
<template #prefix>
|
||||||
|
<Settings v-if="isModerator" class="size-4 stroke-1.5" />
|
||||||
|
<LogIn v-else class="size-4 stroke-1.5" />
|
||||||
|
</template>
|
||||||
<span>
|
<span>
|
||||||
{{ isModerator ? __('Manage Batch') : __('Visit Batch') }}
|
{{ isModerator ? __('Manage Batch') : __('Visit Batch') }}
|
||||||
</span>
|
</span>
|
||||||
@@ -85,6 +89,9 @@
|
|||||||
"
|
"
|
||||||
>
|
>
|
||||||
<Button v-if="!isStudent" class="w-full mt-4" variant="solid">
|
<Button v-if="!isStudent" class="w-full mt-4" variant="solid">
|
||||||
|
<template #prefix>
|
||||||
|
<CreditCard class="size-4 stroke-1.5" />
|
||||||
|
</template>
|
||||||
<span>
|
<span>
|
||||||
{{ __('Register Now') }}
|
{{ __('Register Now') }}
|
||||||
</span>
|
</span>
|
||||||
@@ -100,6 +107,9 @@
|
|||||||
"
|
"
|
||||||
@click="enrollInBatch()"
|
@click="enrollInBatch()"
|
||||||
>
|
>
|
||||||
|
<template #prefix>
|
||||||
|
<GraduationCap class="size-4 stroke-1.5" />
|
||||||
|
</template>
|
||||||
{{ __('Enroll Now') }}
|
{{ __('Enroll Now') }}
|
||||||
</Button>
|
</Button>
|
||||||
<router-link
|
<router-link
|
||||||
@@ -112,6 +122,9 @@
|
|||||||
}"
|
}"
|
||||||
>
|
>
|
||||||
<Button class="w-full mt-2">
|
<Button class="w-full mt-2">
|
||||||
|
<template #prefix>
|
||||||
|
<Pencil class="size-4 stroke-1.5" />
|
||||||
|
</template>
|
||||||
<span>
|
<span>
|
||||||
{{ __('Edit') }}
|
{{ __('Edit') }}
|
||||||
</span>
|
</span>
|
||||||
@@ -122,8 +135,17 @@
|
|||||||
</template>
|
</template>
|
||||||
<script setup>
|
<script setup>
|
||||||
import { inject, computed } from 'vue'
|
import { inject, computed } from 'vue'
|
||||||
import { Badge, Button, createResource, toast } from 'frappe-ui'
|
import { Button, createResource, toast } from 'frappe-ui'
|
||||||
import { BookOpen, Clock, Globe } from 'lucide-vue-next'
|
import {
|
||||||
|
BookOpen,
|
||||||
|
Clock,
|
||||||
|
CreditCard,
|
||||||
|
Globe,
|
||||||
|
GraduationCap,
|
||||||
|
LogIn,
|
||||||
|
Pencil,
|
||||||
|
Settings,
|
||||||
|
} from 'lucide-vue-next'
|
||||||
import { formatNumberIntoCurrency, formatTime } from '@/utils'
|
import { formatNumberIntoCurrency, formatTime } from '@/utils'
|
||||||
import DateRange from '@/components/Common/DateRange.vue'
|
import DateRange from '@/components/Common/DateRange.vue'
|
||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
|
|||||||
@@ -1,5 +1,14 @@
|
|||||||
<template>
|
<template>
|
||||||
<Combobox v-model="selectedValue" nullable v-slot="{ open: isComboboxOpen }">
|
<div>
|
||||||
|
<div v-if="label" class="text-xs text-ink-gray-5 mb-1">
|
||||||
|
{{ __(label) }}
|
||||||
|
<span class="text-ink-red-3" v-if="attrs.required">*</span>
|
||||||
|
</div>
|
||||||
|
<Combobox
|
||||||
|
v-model="selectedValue"
|
||||||
|
nullable
|
||||||
|
v-slot="{ open: isComboboxOpen }"
|
||||||
|
>
|
||||||
<Popover class="w-full" v-model:show="showOptions">
|
<Popover class="w-full" v-model:show="showOptions">
|
||||||
<template #target="{ open: openPopover, togglePopover }">
|
<template #target="{ open: openPopover, togglePopover }">
|
||||||
<slot name="target" v-bind="{ open: openPopover, togglePopover }">
|
<slot name="target" v-bind="{ open: openPopover, togglePopover }">
|
||||||
@@ -8,6 +17,7 @@
|
|||||||
class="flex w-full items-center justify-between focus:outline-none"
|
class="flex w-full items-center justify-between focus:outline-none"
|
||||||
:class="inputClasses"
|
:class="inputClasses"
|
||||||
@click="() => togglePopover()"
|
@click="() => togglePopover()"
|
||||||
|
:disabled="attrs.readonly"
|
||||||
>
|
>
|
||||||
<div class="flex items-center">
|
<div class="flex items-center">
|
||||||
<slot name="prefix" />
|
<slot name="prefix" />
|
||||||
@@ -28,7 +38,9 @@
|
|||||||
</template>
|
</template>
|
||||||
<template #body="{ isOpen }">
|
<template #body="{ isOpen }">
|
||||||
<div v-show="isOpen">
|
<div v-show="isOpen">
|
||||||
<div class="mt-1 rounded-lg bg-surface-white py-1 text-base border-2">
|
<div
|
||||||
|
class="mt-1 rounded-lg bg-surface-white py-1 text-base border-2"
|
||||||
|
>
|
||||||
<div class="relative px-1.5 pt-0.5">
|
<div class="relative px-1.5 pt-0.5">
|
||||||
<ComboboxInput
|
<ComboboxInput
|
||||||
ref="search"
|
ref="search"
|
||||||
@@ -122,6 +134,7 @@
|
|||||||
</template>
|
</template>
|
||||||
</Popover>
|
</Popover>
|
||||||
</Combobox>
|
</Combobox>
|
||||||
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
@@ -148,6 +161,10 @@ const props = defineProps({
|
|||||||
type: String,
|
type: String,
|
||||||
default: 'md',
|
default: 'md',
|
||||||
},
|
},
|
||||||
|
label: {
|
||||||
|
type: String,
|
||||||
|
default: '',
|
||||||
|
},
|
||||||
variant: {
|
variant: {
|
||||||
type: String,
|
type: String,
|
||||||
default: 'subtle',
|
default: 'subtle',
|
||||||
|
|||||||
149
frontend/src/components/Controls/ChildTable.vue
Normal file
149
frontend/src/components/Controls/ChildTable.vue
Normal file
@@ -0,0 +1,149 @@
|
|||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<div class="text-xs text-ink-gray-5 mb-2">
|
||||||
|
{{ label }}
|
||||||
|
</div>
|
||||||
|
<div class="overflow-x-auto border rounded-md">
|
||||||
|
<div
|
||||||
|
class="grid items-center space-x-4 p-2 border-b"
|
||||||
|
:style="{ gridTemplateColumns: getGridTemplateColumns() }"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
v-for="(column, index) in columns"
|
||||||
|
:key="index"
|
||||||
|
class="text-sm text-ink-gray-5"
|
||||||
|
>
|
||||||
|
{{ column }}
|
||||||
|
</div>
|
||||||
|
<div></div>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-for="(row, rowIndex) in rows"
|
||||||
|
:key="rowIndex"
|
||||||
|
class="grid items-center space-x-4 p-2"
|
||||||
|
:style="{ gridTemplateColumns: getGridTemplateColumns() }"
|
||||||
|
>
|
||||||
|
<template v-for="key in Object.keys(row)" :key="key">
|
||||||
|
<input
|
||||||
|
v-if="showKey(key)"
|
||||||
|
v-model="row[key]"
|
||||||
|
class="py-1.5 px-2 border-none focus:ring-0 focus:border focus:border-gray-300 focus:bg-surface-gray-2 rounded-sm text-sm focus:outline-none"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<div class="relative" ref="menuRef">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
@click="(event: MouseEvent) => toggleMenu(rowIndex, event)"
|
||||||
|
>
|
||||||
|
<template #icon>
|
||||||
|
<Ellipsis
|
||||||
|
class="size-4 text-ink-gray-7 stroke-1.5 cursor-pointer"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-if="menuOpenIndex === rowIndex"
|
||||||
|
class="absolute right-[30px] top-5 mt-1 w-32 bg-surface-white border border-outline-gray-1 rounded-md shadow-sm"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
@click="deleteRow(rowIndex)"
|
||||||
|
class="flex items-center space-x-2 w-full text-left px-3 py-2 text-sm text-ink-red-3"
|
||||||
|
>
|
||||||
|
<Trash2 class="size-4 stroke-1.5" />
|
||||||
|
<span>
|
||||||
|
{{ __('Delete') }}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-2">
|
||||||
|
<Button @click="addRow">
|
||||||
|
<template #prefix>
|
||||||
|
<Plus class="size-4 text-ink-gray-7" />
|
||||||
|
</template>
|
||||||
|
{{ __('Add Row') }}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, watch } from 'vue'
|
||||||
|
import { Button } from 'frappe-ui'
|
||||||
|
import { Ellipsis, Plus, Trash2 } from 'lucide-vue-next'
|
||||||
|
import { onClickOutside } from '@vueuse/core'
|
||||||
|
|
||||||
|
const rows = defineModel<Cell[][]>()
|
||||||
|
const menuRef = ref(null)
|
||||||
|
const menuOpenIndex = ref<number | null>(null)
|
||||||
|
const menuTopPosition = ref<string>('')
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(e: 'update:modelValue', value: Cell[][]): void
|
||||||
|
}>()
|
||||||
|
|
||||||
|
type Cell = {
|
||||||
|
value: string
|
||||||
|
editable?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = withDefaults(
|
||||||
|
defineProps<{
|
||||||
|
modelValue?: Cell[][]
|
||||||
|
columns?: string[]
|
||||||
|
label?: string
|
||||||
|
}>(),
|
||||||
|
{
|
||||||
|
columns: [],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
const columns = ref(props.columns)
|
||||||
|
|
||||||
|
watch(rows, () => {
|
||||||
|
if (rows.value?.length < 1) {
|
||||||
|
addRow()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const addRow = () => {
|
||||||
|
if (!rows.value) {
|
||||||
|
rows.value = []
|
||||||
|
}
|
||||||
|
let newRow: { [key: string]: string } = {}
|
||||||
|
columns.value.forEach((column: any) => {
|
||||||
|
newRow[column.toLowerCase().split(' ').join('_')] = ''
|
||||||
|
})
|
||||||
|
rows.value.push(newRow)
|
||||||
|
emit('update:modelValue', rows.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
const deleteRow = (index: number) => {
|
||||||
|
rows.value.splice(index, 1)
|
||||||
|
emit('update:modelValue', rows.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
const getGridTemplateColumns = () => {
|
||||||
|
return [...Array(columns.value.length).fill('1fr'), '0.25fr'].join(' ')
|
||||||
|
}
|
||||||
|
|
||||||
|
const toggleMenu = (index: number, event: MouseEvent) => {
|
||||||
|
menuOpenIndex.value = menuOpenIndex.value === index ? null : index
|
||||||
|
menuTopPosition.value = `${event.clientY + 10}px`
|
||||||
|
}
|
||||||
|
|
||||||
|
onClickOutside(menuRef, () => {
|
||||||
|
menuOpenIndex.value = null
|
||||||
|
})
|
||||||
|
|
||||||
|
const showKey = (key: string) => {
|
||||||
|
let columnsLower = columns.value.map((col) =>
|
||||||
|
col.toLowerCase().split(' ').join('_')
|
||||||
|
)
|
||||||
|
return columnsLower.includes(key)
|
||||||
|
}
|
||||||
|
</script>
|
||||||
162
frontend/src/components/Controls/Code.vue
Normal file
162
frontend/src/components/Controls/Code.vue
Normal file
@@ -0,0 +1,162 @@
|
|||||||
|
<template>
|
||||||
|
<div class="flex w-full flex-col gap-1.5">
|
||||||
|
<div v-if="label" class="text-xs text-ink-gray-5">
|
||||||
|
{{ __(label) }}
|
||||||
|
</div>
|
||||||
|
<codemirror
|
||||||
|
v-model="code"
|
||||||
|
:extensions="extensions"
|
||||||
|
:tab-size="2"
|
||||||
|
:autofocus="autofocus"
|
||||||
|
:indent-with-tab="true"
|
||||||
|
:style="{ height: height, maxHeight: maxHeight }"
|
||||||
|
:disabled="readonly"
|
||||||
|
@blur="emitEditorValue"
|
||||||
|
:class="{
|
||||||
|
'border border-outline-gray-1': showBorder,
|
||||||
|
}"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
v-if="showSaveButton"
|
||||||
|
@click="emit('save', code)"
|
||||||
|
class="mt-3 w-full text-base"
|
||||||
|
>
|
||||||
|
{{ __('Save') }}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { onMounted, ref, computed, watch } from 'vue'
|
||||||
|
import { Button } from 'frappe-ui'
|
||||||
|
import { Codemirror } from 'vue-codemirror'
|
||||||
|
import { autocompletion, closeBrackets } from '@codemirror/autocomplete'
|
||||||
|
import { LanguageSupport } from '@codemirror/language'
|
||||||
|
import { EditorView } from '@codemirror/view'
|
||||||
|
import { tomorrow } from 'thememirror'
|
||||||
|
|
||||||
|
const props = withDefaults(
|
||||||
|
defineProps<{
|
||||||
|
language: 'json' | 'javascript' | 'html' | 'css' | 'python'
|
||||||
|
modelValue: string | object | Array<string | object> | null
|
||||||
|
height?: string
|
||||||
|
maxHeight?: string
|
||||||
|
autofocus?: boolean
|
||||||
|
showSaveButton?: boolean
|
||||||
|
showLineNumbers?: boolean
|
||||||
|
completions?: Function | null
|
||||||
|
label?: string
|
||||||
|
showBorder?: boolean
|
||||||
|
required?: boolean
|
||||||
|
readonly?: boolean
|
||||||
|
}>(),
|
||||||
|
{
|
||||||
|
language: 'javascript',
|
||||||
|
modelValue: null,
|
||||||
|
height: 'auto',
|
||||||
|
maxHeight: '250px',
|
||||||
|
showLineNumbers: true,
|
||||||
|
completions: null,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
const emit = defineEmits(['update:modelValue', 'save'])
|
||||||
|
|
||||||
|
const code = ref<string>('')
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.modelValue,
|
||||||
|
(newVal) => {
|
||||||
|
code.value =
|
||||||
|
typeof newVal === 'string' ? newVal : JSON.stringify(newVal, null, 2)
|
||||||
|
},
|
||||||
|
{ immediate: true }
|
||||||
|
)
|
||||||
|
|
||||||
|
watch(code, (val) => {
|
||||||
|
emit('update:modelValue', val)
|
||||||
|
})
|
||||||
|
|
||||||
|
const errorMessage = ref('')
|
||||||
|
const emitEditorValue = () => {
|
||||||
|
try {
|
||||||
|
errorMessage.value = ''
|
||||||
|
let value = code.value || ''
|
||||||
|
|
||||||
|
if (!props.showSaveButton && !props.readonly) {
|
||||||
|
emit('update:modelValue', value)
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Error while parsing JSON for editor', e)
|
||||||
|
errorMessage.value = `Invalid object/JSON: ${e.message}`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const languageExtension = ref<LanguageSupport>()
|
||||||
|
const autocompleteExtension = ref()
|
||||||
|
|
||||||
|
async function setLanguageExtension() {
|
||||||
|
const importMap = {
|
||||||
|
json: () => import('@codemirror/lang-json'),
|
||||||
|
javascript: () => import('@codemirror/lang-javascript'),
|
||||||
|
html: () => import('@codemirror/lang-html'),
|
||||||
|
css: () => import('@codemirror/lang-css'),
|
||||||
|
python: () => import('@codemirror/lang-python'),
|
||||||
|
}
|
||||||
|
|
||||||
|
const languageImport = importMap[props.language]
|
||||||
|
if (!languageImport) return
|
||||||
|
|
||||||
|
const module = await languageImport()
|
||||||
|
languageExtension.value = (module as any)[props.language]()
|
||||||
|
|
||||||
|
if (props.completions) {
|
||||||
|
const languageData = (module as any)[`${props.language}Language`]
|
||||||
|
autocompleteExtension.value = languageData.data.of({
|
||||||
|
autocomplete: props.completions,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
await setLanguageExtension()
|
||||||
|
})
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.language,
|
||||||
|
async () => {
|
||||||
|
await setLanguageExtension()
|
||||||
|
},
|
||||||
|
{ immediate: true }
|
||||||
|
)
|
||||||
|
|
||||||
|
const extensions = computed(() => {
|
||||||
|
const baseExtensions = [
|
||||||
|
closeBrackets(),
|
||||||
|
tomorrow,
|
||||||
|
EditorView.theme({
|
||||||
|
'&': {
|
||||||
|
fontFamily: 'monospace',
|
||||||
|
fontSize: '12px',
|
||||||
|
},
|
||||||
|
'.cm-gutters': {
|
||||||
|
display: props.showLineNumbers ? 'flex' : 'none',
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
]
|
||||||
|
if (languageExtension.value) {
|
||||||
|
baseExtensions.push(languageExtension.value)
|
||||||
|
}
|
||||||
|
if (autocompleteExtension.value) {
|
||||||
|
baseExtensions.push(autocompleteExtension.value)
|
||||||
|
}
|
||||||
|
const autocompletionOptions = {
|
||||||
|
activateOnTyping: true,
|
||||||
|
maxRenderedOptions: 10,
|
||||||
|
closeOnBlur: false,
|
||||||
|
icons: false,
|
||||||
|
optionClass: () => 'flex h-7 !px-2 items-center rounded !text-gray-600',
|
||||||
|
}
|
||||||
|
baseExtensions.push(autocompletion(autocompletionOptions))
|
||||||
|
return baseExtensions
|
||||||
|
})
|
||||||
|
</script>
|
||||||
@@ -5,7 +5,7 @@
|
|||||||
height: height,
|
height: height,
|
||||||
}"
|
}"
|
||||||
>
|
>
|
||||||
<span class="text-xs text-ink-gray-7" v-if="label">
|
<span class="text-xs text-ink-gray-7 mb-1" v-if="label">
|
||||||
{{ label }}
|
{{ label }}
|
||||||
</span>
|
</span>
|
||||||
<div
|
<div
|
||||||
|
|||||||
108
frontend/src/components/Controls/ColorSwatches.vue
Normal file
108
frontend/src/components/Controls/ColorSwatches.vue
Normal file
@@ -0,0 +1,108 @@
|
|||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<div class="text-xs text-ink-gray-5 mb-1">
|
||||||
|
{{ __(label) }}
|
||||||
|
</div>
|
||||||
|
<Popover placement="bottom" class="!block">
|
||||||
|
<template #target="{ togglePopover, isOpen }">
|
||||||
|
<div class="space-y-2">
|
||||||
|
<FormControl
|
||||||
|
type="text"
|
||||||
|
autocomplete="off"
|
||||||
|
class="w-full"
|
||||||
|
:placeholder="__('Set Color')"
|
||||||
|
@focus="togglePopover"
|
||||||
|
:modelValue="modelValue"
|
||||||
|
@update:modelValue="(val: string) => emit('update:modelValue', val)"
|
||||||
|
>
|
||||||
|
<template #prefix>
|
||||||
|
<div
|
||||||
|
class="size-4 rounded-full"
|
||||||
|
:style="
|
||||||
|
modelValue
|
||||||
|
? {
|
||||||
|
backgroundColor:
|
||||||
|
theme.backgroundColor[modelValue.toLowerCase()][400],
|
||||||
|
}
|
||||||
|
: {}
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<Palette
|
||||||
|
v-if="!modelValue"
|
||||||
|
class="size-4 stroke-1.5 text-ink-gray-5"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<template #suffix>
|
||||||
|
<Button variant="ghost">
|
||||||
|
<X
|
||||||
|
class="size-3 text-ink-gray-5"
|
||||||
|
@click="emit('update:modelValue', null)"
|
||||||
|
/>
|
||||||
|
</Button>
|
||||||
|
</template>
|
||||||
|
</FormControl>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<template #body="{ close }">
|
||||||
|
<div class="rounded-lg bg-surface-white p-3 border w-fit mt-2">
|
||||||
|
<div class="text-xs text-ink-gray-5 mb-1.5">
|
||||||
|
{{ __('Swatches') }}
|
||||||
|
</div>
|
||||||
|
<div class="grid grid-cols-7 gap-2">
|
||||||
|
<div
|
||||||
|
v-for="color in colors"
|
||||||
|
:key="color"
|
||||||
|
class="size-5 rounded-full cursor-pointer"
|
||||||
|
:style="{
|
||||||
|
backgroundColor:
|
||||||
|
theme.backgroundColor[color.toLowerCase()][400],
|
||||||
|
}"
|
||||||
|
@click="
|
||||||
|
(e) => {
|
||||||
|
emit('update:modelValue', color)
|
||||||
|
close()
|
||||||
|
emit('change', color)
|
||||||
|
}
|
||||||
|
"
|
||||||
|
></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</Popover>
|
||||||
|
<div class="text-sm text-ink-gray-5 mt-2">
|
||||||
|
{{ description }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { Button, FormControl, Popover } from 'frappe-ui'
|
||||||
|
import { computed } from 'vue'
|
||||||
|
import { Palette, X } from 'lucide-vue-next'
|
||||||
|
import { theme } from '@/utils/theme'
|
||||||
|
|
||||||
|
const emit = defineEmits(['update:modelValue', 'change'])
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
modelValue: string
|
||||||
|
label: string
|
||||||
|
description?: string
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const colors = computed(() => {
|
||||||
|
return [
|
||||||
|
'Red',
|
||||||
|
'Blue',
|
||||||
|
'Green',
|
||||||
|
'Amber',
|
||||||
|
'Purple',
|
||||||
|
'Cyan',
|
||||||
|
'Orange',
|
||||||
|
'Violet',
|
||||||
|
'Pink',
|
||||||
|
'Teal',
|
||||||
|
'Gray',
|
||||||
|
'Yellow',
|
||||||
|
]
|
||||||
|
})
|
||||||
|
</script>
|
||||||
@@ -12,6 +12,7 @@
|
|||||||
:variant="attrs.variant"
|
:variant="attrs.variant"
|
||||||
:placeholder="attrs.placeholder"
|
:placeholder="attrs.placeholder"
|
||||||
:filterable="false"
|
:filterable="false"
|
||||||
|
:readonly="attrs.readonly"
|
||||||
>
|
>
|
||||||
<template #target="{ open, togglePopover }">
|
<template #target="{ open, togglePopover }">
|
||||||
<slot name="target" v-bind="{ open, togglePopover }" />
|
<slot name="target" v-bind="{ open, togglePopover }" />
|
||||||
|
|||||||
@@ -55,9 +55,10 @@
|
|||||||
</div>
|
</div>
|
||||||
</li>
|
</li>
|
||||||
</ComboboxOption>
|
</ComboboxOption>
|
||||||
|
<div class="h-10"></div>
|
||||||
<div
|
<div
|
||||||
v-if="attrs.onCreate"
|
v-if="attrs.onCreate"
|
||||||
class="absolute bottom-2 left-1 w-[98%] pt-2 bg-white border-t"
|
class="absolute bottom-2 left-1 w-[99%] pt-2 bg-white border-t"
|
||||||
>
|
>
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
|
|||||||
76
frontend/src/components/Controls/Uploader.vue
Normal file
76
frontend/src/components/Controls/Uploader.vue
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
<template>
|
||||||
|
<div class="mb-4">
|
||||||
|
<div v-if="label" class="text-xs text-ink-gray-5 mb-2">
|
||||||
|
{{ __(label) }}
|
||||||
|
<span class="text-ink-red-3">*</span>
|
||||||
|
</div>
|
||||||
|
<FileUploader
|
||||||
|
v-if="!modelValue"
|
||||||
|
:fileTypes="['image/*']"
|
||||||
|
:validateFile="validateFile"
|
||||||
|
@success="(file: File) => saveImage(file)"
|
||||||
|
>
|
||||||
|
<template v-slot="{ file, progress, uploading, openFileSelector }">
|
||||||
|
<div class="flex items-center">
|
||||||
|
<div class="border rounded-md w-fit py-7 px-20">
|
||||||
|
<Image class="size-5 stroke-1 text-ink-gray-7" />
|
||||||
|
</div>
|
||||||
|
<div class="ml-4">
|
||||||
|
<Button @click="openFileSelector">
|
||||||
|
{{ __('Upload') }}
|
||||||
|
</Button>
|
||||||
|
<div class="mt-1 text-ink-gray-5 text-sm leading-5">
|
||||||
|
{{ __(description) }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</FileUploader>
|
||||||
|
<div v-else class="mb-4">
|
||||||
|
<div class="flex items-center">
|
||||||
|
<img :src="modelValue" class="border rounded-md w-44 h-auto" />
|
||||||
|
<div class="ml-4">
|
||||||
|
<Button @click="removeImage()">
|
||||||
|
{{ __('Remove') }}
|
||||||
|
</Button>
|
||||||
|
<div
|
||||||
|
v-if="description"
|
||||||
|
class="mt-2 text-ink-gray-5 text-sm leading-5"
|
||||||
|
>
|
||||||
|
{{ __(description) }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { validateFile } from '@/utils'
|
||||||
|
import { Button, FileUploader } from 'frappe-ui'
|
||||||
|
import { Image } from 'lucide-vue-next'
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(e: 'update:modelValue', value: string): void
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const props = withDefaults(
|
||||||
|
defineProps<{
|
||||||
|
modelValue: string
|
||||||
|
label?: string
|
||||||
|
description?: string
|
||||||
|
}>(),
|
||||||
|
{
|
||||||
|
modelValue: '',
|
||||||
|
label: '',
|
||||||
|
description: '',
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
const saveImage = (file: any) => {
|
||||||
|
emit('update:modelValue', file.file_url)
|
||||||
|
}
|
||||||
|
|
||||||
|
const removeImage = () => {
|
||||||
|
emit('update:modelValue', '')
|
||||||
|
}
|
||||||
|
</script>
|
||||||
@@ -1,41 +1,51 @@
|
|||||||
<template>
|
<template>
|
||||||
<div
|
<div
|
||||||
v-if="course.title"
|
v-if="course.title"
|
||||||
class="flex flex-col h-full rounded-md border-2 overflow-auto"
|
class="flex flex-col h-full rounded-md border-2 overflow-auto text-ink-gray-9"
|
||||||
style="min-height: 350px"
|
style="min-height: 350px"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
class="course-image"
|
class="w-[100%] h-[168px] bg-cover bg-center bg-no-repeat"
|
||||||
:class="{ 'default-image': !course.image }"
|
:style="
|
||||||
:style="{ backgroundImage: 'url(\'' + encodeURI(course.image) + '\')' }"
|
course.image
|
||||||
|
? { backgroundImage: `url('${encodeURI(course.image)}')` }
|
||||||
|
: {
|
||||||
|
backgroundImage: getGradientColor(),
|
||||||
|
backgroundBlendMode: 'screen',
|
||||||
|
}
|
||||||
|
"
|
||||||
>
|
>
|
||||||
<div class="flex items-center flex-wrap relative top-4 px-2 w-fit">
|
<div class="flex items-center flex-wrap relative top-4 px-2 w-fit">
|
||||||
<Badge
|
<div
|
||||||
v-if="course.featured"
|
v-if="course.featured"
|
||||||
variant="subtle"
|
class="flex items-center space-x-1 text-xs text-ink-amber-3 bg-surface-white border border-outline-amber-1 px-2 py-0.5 rounded-md mr-1 mb-1"
|
||||||
theme="green"
|
|
||||||
size="md"
|
|
||||||
class="mb-1 mr-1"
|
|
||||||
>
|
>
|
||||||
|
<Star class="size-3 stroke-2" />
|
||||||
|
<span>
|
||||||
{{ __('Featured') }}
|
{{ __('Featured') }}
|
||||||
</Badge>
|
</span>
|
||||||
|
</div>
|
||||||
<div
|
<div
|
||||||
v-if="course.tags"
|
v-if="course.tags"
|
||||||
v-for="tag in course.tags?.split(', ')"
|
v-for="tag in course.tags?.split(', ')"
|
||||||
class="text-xs bg-white text-gray-800 px-2 py-0.5 rounded-md mb-1 mr-1"
|
class="text-xs border bg-surface-white text-ink-gray-9 px-2 py-0.5 rounded-md mb-1 mr-1"
|
||||||
>
|
>
|
||||||
{{ tag }}
|
{{ tag }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="!course.image" class="image-placeholder">
|
<!-- <div
|
||||||
{{ course.title[0] }}
|
v-if="!course.image"
|
||||||
</div>
|
class="flex items-center justify-center text-white flex-1 font-extrabold text-2xl my-auto px-5 text-center leading-6"
|
||||||
|
:class="course.tags ? 'h-[80%]' : 'h-full'"
|
||||||
|
>
|
||||||
|
{{ course.title }}
|
||||||
|
</div> -->
|
||||||
</div>
|
</div>
|
||||||
<div class="flex flex-col flex-auto p-4">
|
<div class="flex flex-col flex-auto p-4">
|
||||||
<div class="flex items-center justify-between mb-2">
|
<div class="flex items-center justify-between mb-2">
|
||||||
<div v-if="course.lessons">
|
<div v-if="course.lessons">
|
||||||
<Tooltip :text="__('Lessons')">
|
<Tooltip :text="__('Lessons')">
|
||||||
<span class="flex items-center text-ink-gray-7">
|
<span class="flex items-center">
|
||||||
<BookOpen class="h-4 w-4 stroke-1.5 mr-1" />
|
<BookOpen class="h-4 w-4 stroke-1.5 mr-1" />
|
||||||
{{ course.lessons }}
|
{{ course.lessons }}
|
||||||
</span>
|
</span>
|
||||||
@@ -44,8 +54,8 @@
|
|||||||
|
|
||||||
<div v-if="course.enrollments">
|
<div v-if="course.enrollments">
|
||||||
<Tooltip :text="__('Enrolled Students')">
|
<Tooltip :text="__('Enrolled Students')">
|
||||||
<span class="flex items-center text-ink-gray-7">
|
<span class="flex items-center">
|
||||||
<Users class="h-4 w-4 stroke-1. mr-1" />
|
<Users class="h-4 w-4 stroke-1.5 mr-1" />
|
||||||
{{ course.enrollments }}
|
{{ course.enrollments }}
|
||||||
</span>
|
</span>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
@@ -53,29 +63,22 @@
|
|||||||
|
|
||||||
<div v-if="course.rating">
|
<div v-if="course.rating">
|
||||||
<Tooltip :text="__('Average Rating')">
|
<Tooltip :text="__('Average Rating')">
|
||||||
<span class="flex items-center text-ink-gray-7">
|
<span class="flex items-center">
|
||||||
<Star class="h-4 w-4 stroke-1.5 mr-1" />
|
<Star class="h-4 w-4 stroke-1.5 mr-1" />
|
||||||
{{ course.rating }}
|
{{ course.rating }}
|
||||||
</span>
|
</span>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div v-if="course.status != 'Approved'">
|
<div
|
||||||
<Badge
|
class="font-semibold leading-6"
|
||||||
variant="subtle"
|
:class="course.title.length > 32 ? 'text-lg' : 'text-xl'"
|
||||||
:theme="course.status === 'Under Review' ? 'orange' : 'blue'"
|
|
||||||
size="sm"
|
|
||||||
>
|
>
|
||||||
{{ course.status }}
|
|
||||||
</Badge>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="text-xl font-semibold leading-6 text-ink-gray-9">
|
|
||||||
{{ course.title }}
|
{{ course.title }}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="short-introduction text-ink-gray-7 text-sm">
|
<div class="short-introduction text-sm">
|
||||||
{{ course.short_introduction }}
|
{{ course.short_introduction }}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -84,11 +87,8 @@
|
|||||||
:progress="course.membership.progress"
|
:progress="course.membership.progress"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div
|
<div v-if="user && course.membership" class="text-sm mt-2 mb-4">
|
||||||
v-if="user && course.membership"
|
{{ Math.ceil(course.membership.progress) }}% {{ __('completed') }}
|
||||||
class="text-sm text-ink-gray-7 mt-2 mb-4"
|
|
||||||
>
|
|
||||||
{{ Math.ceil(course.membership.progress) }}% completed
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex items-center justify-between mt-auto">
|
<div class="flex items-center justify-between mt-auto">
|
||||||
@@ -108,21 +108,23 @@
|
|||||||
<div v-if="course.paid_course" class="font-semibold">
|
<div v-if="course.paid_course" class="font-semibold">
|
||||||
{{ course.price }}
|
{{ course.price }}
|
||||||
</div>
|
</div>
|
||||||
<div
|
|
||||||
|
<Tooltip
|
||||||
v-if="course.paid_certificate || course.enable_certification"
|
v-if="course.paid_certificate || course.enable_certification"
|
||||||
class="text-xs text-ink-blue-3 bg-surface-blue-1 py-0.5 px-1 rounded-md"
|
:text="__('Get Certified')"
|
||||||
>
|
>
|
||||||
{{ __('Certification') }}
|
<GraduationCap class="size-5 stroke-1.5 text-ink-gray-7" />
|
||||||
</div>
|
</Tooltip>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<script setup>
|
<script setup>
|
||||||
import { BookOpen, Users, Star } from 'lucide-vue-next'
|
import { BookOpen, GraduationCap, Star, Users } from 'lucide-vue-next'
|
||||||
import UserAvatar from '@/components/UserAvatar.vue'
|
import UserAvatar from '@/components/UserAvatar.vue'
|
||||||
import { sessionStore } from '@/stores/session'
|
import { sessionStore } from '@/stores/session'
|
||||||
import { Badge, Tooltip } from 'frappe-ui'
|
import { Tooltip } from 'frappe-ui'
|
||||||
|
import { theme } from '@/utils/theme'
|
||||||
import CourseInstructors from '@/components/CourseInstructors.vue'
|
import CourseInstructors from '@/components/CourseInstructors.vue'
|
||||||
import ProgressBar from '@/components/ProgressBar.vue'
|
import ProgressBar from '@/components/ProgressBar.vue'
|
||||||
|
|
||||||
@@ -134,16 +136,24 @@ const props = defineProps({
|
|||||||
default: null,
|
default: null,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const getGradientColor = () => {
|
||||||
|
let color = props.course.card_gradient?.toLowerCase() || 'blue'
|
||||||
|
let colorMap = theme.backgroundColor[color]
|
||||||
|
return `linear-gradient(to top right, black, ${colorMap[400]})`
|
||||||
|
/* return `bg-gradient-to-br from-${color}-100 via-${color}-200 to-${color}-400` */
|
||||||
|
/* return `linear-gradient(to bottom right, ${colorMap[100]}, ${colorMap[400]})` */
|
||||||
|
/* return `radial-gradient(ellipse at 80% 20%, black 20%, ${colorMap[500]} 100%)` */
|
||||||
|
/* return `radial-gradient(ellipse at 30% 70%, black 50%, ${colorMap[500]} 100%)` */
|
||||||
|
/* return `radial-gradient(ellipse at 80% 20%, ${colorMap[100]} 0%, ${colorMap[300]} 50%, ${colorMap[500]} 100%)` */
|
||||||
|
/* return `conic-gradient(from 180deg at 50% 50%, ${colorMap[100]} 0%, ${colorMap[200]} 50%, ${colorMap[400]} 100%)` */
|
||||||
|
/* return `linear-gradient(135deg, ${colorMap[100]}, ${colorMap[300]}), linear-gradient(120deg, rgba(255,255,255,0.4) 0%, transparent 60%) ` */
|
||||||
|
/* return `radial-gradient(circle at 20% 30%, ${colorMap[100]} 0%, transparent 40%),
|
||||||
|
radial-gradient(circle at 80% 40%, ${colorMap[200]} 0%, transparent 50%),
|
||||||
|
linear-gradient(135deg, ${colorMap[300]} 0%, ${colorMap[400]} 100%);` */
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
<style>
|
<style>
|
||||||
.course-image {
|
|
||||||
height: 168px;
|
|
||||||
width: 100%;
|
|
||||||
background-size: cover;
|
|
||||||
background-position: center;
|
|
||||||
background-repeat: no-repeat;
|
|
||||||
}
|
|
||||||
|
|
||||||
.course-card-pills {
|
.course-card-pills {
|
||||||
background: #ffffff;
|
background: #ffffff;
|
||||||
margin-left: 0;
|
margin-left: 0;
|
||||||
@@ -157,14 +167,6 @@ const props = defineProps({
|
|||||||
width: fit-content;
|
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 {
|
.avatar-group {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -173,14 +175,7 @@ const props = defineProps({
|
|||||||
.avatar-group .avatar {
|
.avatar-group .avatar {
|
||||||
transition: margin 0.1s ease-in-out;
|
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 {
|
.avatar-group.overlap .avatar + .avatar {
|
||||||
margin-left: calc(-8px);
|
margin-left: calc(-8px);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="border-2 rounded-md min-w-80">
|
<div class="border-2 rounded-md min-w-80 max-w-sm">
|
||||||
<iframe
|
<iframe
|
||||||
v-if="course.data.video_link"
|
v-if="course.data.video_link"
|
||||||
:src="video_link"
|
:src="video_link"
|
||||||
@@ -26,6 +26,9 @@
|
|||||||
}"
|
}"
|
||||||
>
|
>
|
||||||
<Button variant="solid" size="md" class="w-full">
|
<Button variant="solid" size="md" class="w-full">
|
||||||
|
<template #prefix>
|
||||||
|
<BookText class="size-4 stroke-1.5" />
|
||||||
|
</template>
|
||||||
<span>
|
<span>
|
||||||
{{ __('Continue Learning') }}
|
{{ __('Continue Learning') }}
|
||||||
</span>
|
</span>
|
||||||
@@ -44,6 +47,9 @@
|
|||||||
}"
|
}"
|
||||||
>
|
>
|
||||||
<Button variant="solid" size="md" class="w-full">
|
<Button variant="solid" size="md" class="w-full">
|
||||||
|
<template #prefix>
|
||||||
|
<CreditCard class="size-4 stroke-1.5" />
|
||||||
|
</template>
|
||||||
<span>
|
<span>
|
||||||
{{ __('Buy this course') }}
|
{{ __('Buy this course') }}
|
||||||
</span>
|
</span>
|
||||||
@@ -57,12 +63,15 @@
|
|||||||
{{ __('Contact the Administrator to enroll for this course.') }}
|
{{ __('Contact the Administrator to enroll for this course.') }}
|
||||||
</Badge>
|
</Badge>
|
||||||
<Button
|
<Button
|
||||||
v-else
|
v-else-if="!user.data?.is_moderator && !is_instructor()"
|
||||||
@click="enrollStudent()"
|
@click="enrollStudent()"
|
||||||
variant="solid"
|
variant="solid"
|
||||||
class="w-full"
|
class="w-full"
|
||||||
size="md"
|
size="md"
|
||||||
>
|
>
|
||||||
|
<template #prefix>
|
||||||
|
<BookText class="size-4 stroke-1.5" />
|
||||||
|
</template>
|
||||||
<span>
|
<span>
|
||||||
{{ __('Start Learning') }}
|
{{ __('Start Learning') }}
|
||||||
</span>
|
</span>
|
||||||
@@ -74,8 +83,22 @@
|
|||||||
class="w-full mt-2"
|
class="w-full mt-2"
|
||||||
size="md"
|
size="md"
|
||||||
>
|
>
|
||||||
|
<template #prefix>
|
||||||
|
<GraduationCap class="size-4 stroke-1.5" />
|
||||||
|
</template>
|
||||||
{{ __('Get Certificate') }}
|
{{ __('Get Certificate') }}
|
||||||
</Button>
|
</Button>
|
||||||
|
<Button
|
||||||
|
v-if="user.data?.is_moderator || is_instructor()"
|
||||||
|
class="w-full mt-2"
|
||||||
|
size="md"
|
||||||
|
@click="showProgressSummary"
|
||||||
|
>
|
||||||
|
<template #prefix>
|
||||||
|
<TrendingUp class="size-4 stroke-1.5" />
|
||||||
|
{{ __('Progress Summary') }}
|
||||||
|
</template>
|
||||||
|
</Button>
|
||||||
<router-link
|
<router-link
|
||||||
v-if="user?.data?.is_moderator || is_instructor()"
|
v-if="user?.data?.is_moderator || is_instructor()"
|
||||||
:to="{
|
:to="{
|
||||||
@@ -86,6 +109,9 @@
|
|||||||
}"
|
}"
|
||||||
>
|
>
|
||||||
<Button variant="subtle" class="w-full mt-2" size="md">
|
<Button variant="subtle" class="w-full mt-2" size="md">
|
||||||
|
<template #prefix>
|
||||||
|
<Pencil class="size-4 stroke-1.5" />
|
||||||
|
</template>
|
||||||
<span>
|
<span>
|
||||||
{{ __('Edit') }}
|
{{ __('Edit') }}
|
||||||
</span>
|
</span>
|
||||||
@@ -142,18 +168,34 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<CourseProgressSummary
|
||||||
|
v-model="showProgressModal"
|
||||||
|
:courseName="course.data.name"
|
||||||
|
:enrollments="course.data.enrollments"
|
||||||
|
/>
|
||||||
</template>
|
</template>
|
||||||
<script setup>
|
<script setup>
|
||||||
import { BookOpen, Users, Star, GraduationCap } from 'lucide-vue-next'
|
import {
|
||||||
import { computed, inject } from 'vue'
|
BookOpen,
|
||||||
import { Badge, Button, createResource, toast } from 'frappe-ui'
|
BookText,
|
||||||
|
CreditCard,
|
||||||
|
GraduationCap,
|
||||||
|
Pencil,
|
||||||
|
Star,
|
||||||
|
TrendingUp,
|
||||||
|
Users,
|
||||||
|
} from 'lucide-vue-next'
|
||||||
|
import { computed, inject, ref } from 'vue'
|
||||||
|
import { Badge, Button, call, createResource, toast } from 'frappe-ui'
|
||||||
import { formatAmount } from '@/utils/'
|
import { formatAmount } from '@/utils/'
|
||||||
import { capture } from '@/telemetry'
|
import { capture } from '@/telemetry'
|
||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
import CertificationLinks from '@/components/CertificationLinks.vue'
|
import CertificationLinks from '@/components/CertificationLinks.vue'
|
||||||
|
import CourseProgressSummary from '@/components/Modals/CourseProgressSummary.vue'
|
||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const user = inject('$user')
|
const user = inject('$user')
|
||||||
|
const showProgressModal = ref(false)
|
||||||
const readOnlyMode = window.read_only_mode
|
const readOnlyMode = window.read_only_mode
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
@@ -175,13 +217,9 @@ function enrollStudent() {
|
|||||||
toast.success(__('You need to login first to enroll for this course'))
|
toast.success(__('You need to login first to enroll for this course'))
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
window.location.href = `/login?redirect-to=${window.location.pathname}`
|
window.location.href = `/login?redirect-to=${window.location.pathname}`
|
||||||
}, 1000)
|
}, 500)
|
||||||
} else {
|
} else {
|
||||||
const enrollStudentResource = createResource({
|
call('lms.lms.doctype.lms_enrollment.lms_enrollment.create_membership', {
|
||||||
url: 'lms.lms.doctype.lms_enrollment.lms_enrollment.create_membership',
|
|
||||||
})
|
|
||||||
enrollStudentResource
|
|
||||||
.submit({
|
|
||||||
course: props.course.data.name,
|
course: props.course.data.name,
|
||||||
})
|
})
|
||||||
.then(() => {
|
.then(() => {
|
||||||
@@ -198,7 +236,11 @@ function enrollStudent() {
|
|||||||
lessonNumber: 1,
|
lessonNumber: 1,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
}, 2000)
|
}, 1000)
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
toast.warning(__(err.messages?.[0] || err))
|
||||||
|
console.error(err)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -246,4 +288,8 @@ const fetchCertificate = () => {
|
|||||||
member: user.data?.name,
|
member: user.data?.name,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const showProgressSummary = () => {
|
||||||
|
showProgressModal.value = true
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="text-ink-gray-7">
|
<div class="">
|
||||||
<span v-if="instructors?.length == 1">
|
<span v-if="instructors?.length == 1">
|
||||||
<router-link
|
<router-link
|
||||||
:to="{
|
:to="{
|
||||||
@@ -19,7 +19,7 @@
|
|||||||
>
|
>
|
||||||
{{ instructors[0].first_name }}
|
{{ instructors[0].first_name }}
|
||||||
</router-link>
|
</router-link>
|
||||||
and
|
{{ __('and') }}
|
||||||
<router-link
|
<router-link
|
||||||
:to="{
|
:to="{
|
||||||
name: 'Profile',
|
name: 'Profile',
|
||||||
@@ -38,7 +38,7 @@
|
|||||||
>
|
>
|
||||||
{{ instructors[0].first_name }}
|
{{ instructors[0].first_name }}
|
||||||
</router-link>
|
</router-link>
|
||||||
and {{ instructors?.length - 1 }} others
|
{{ __('and') }} {{ instructors?.length - 1 }} {{ __('others') }}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -23,13 +23,24 @@
|
|||||||
'border-2 rounded-md py-2 px-2': showOutline && outline.data?.length,
|
'border-2 rounded-md py-2 px-2': showOutline && outline.data?.length,
|
||||||
}"
|
}"
|
||||||
>
|
>
|
||||||
|
<Draggable
|
||||||
|
:list="outline.data"
|
||||||
|
:disabled="!allowEdit"
|
||||||
|
item-key="name"
|
||||||
|
group="chapters"
|
||||||
|
@end="updateChapterOrder"
|
||||||
|
>
|
||||||
|
<template #item="{ element: chapter, index }">
|
||||||
|
<div class="chapter-item">
|
||||||
<Disclosure
|
<Disclosure
|
||||||
v-slot="{ open }"
|
v-slot="{ open }"
|
||||||
v-for="(chapter, index) in outline.data"
|
|
||||||
:key="chapter.name"
|
:key="chapter.name"
|
||||||
:defaultOpen="openChapterDetail(chapter.idx)"
|
:defaultOpen="openChapterDetail(chapter.idx)"
|
||||||
>
|
>
|
||||||
<DisclosureButton ref="" class="flex items-center w-full p-2 group">
|
<DisclosureButton
|
||||||
|
ref=""
|
||||||
|
class="flex items-center w-full p-2 group"
|
||||||
|
>
|
||||||
<ChevronRight
|
<ChevronRight
|
||||||
:class="{
|
:class="{
|
||||||
'rotate-90 transform duration-200': open,
|
'rotate-90 transform duration-200': open,
|
||||||
@@ -105,7 +116,9 @@
|
|||||||
{{ lesson.title }}
|
{{ lesson.title }}
|
||||||
<Trash2
|
<Trash2
|
||||||
v-if="allowEdit"
|
v-if="allowEdit"
|
||||||
@click.prevent="trashLesson(lesson.name, chapter.name)"
|
@click.prevent="
|
||||||
|
trashLesson(lesson.name, chapter.name)
|
||||||
|
"
|
||||||
class="h-4 w-4 text-ink-red-3 ml-auto invisible group-hover:visible"
|
class="h-4 w-4 text-ink-red-3 ml-auto invisible group-hover:visible"
|
||||||
/>
|
/>
|
||||||
<Check
|
<Check
|
||||||
@@ -137,6 +150,9 @@
|
|||||||
</DisclosurePanel>
|
</DisclosurePanel>
|
||||||
</Disclosure>
|
</Disclosure>
|
||||||
</div>
|
</div>
|
||||||
|
</template>
|
||||||
|
</Draggable>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<ChapterModal
|
<ChapterModal
|
||||||
v-if="user.data"
|
v-if="user.data"
|
||||||
@@ -148,7 +164,7 @@
|
|||||||
</template>
|
</template>
|
||||||
<script setup>
|
<script setup>
|
||||||
import { Button, createResource, Tooltip, toast } from 'frappe-ui'
|
import { Button, createResource, Tooltip, toast } from 'frappe-ui'
|
||||||
import { getCurrentInstance, inject, ref } from 'vue'
|
import { getCurrentInstance, inject, ref, watch } from 'vue'
|
||||||
import Draggable from 'vuedraggable'
|
import Draggable from 'vuedraggable'
|
||||||
import { Disclosure, DisclosureButton, DisclosurePanel } from '@headlessui/vue'
|
import { Disclosure, DisclosureButton, DisclosurePanel } from '@headlessui/vue'
|
||||||
import {
|
import {
|
||||||
@@ -197,13 +213,22 @@ const props = defineProps({
|
|||||||
const outline = createResource({
|
const outline = createResource({
|
||||||
url: 'lms.lms.utils.get_course_outline',
|
url: 'lms.lms.utils.get_course_outline',
|
||||||
cache: ['course_outline', props.courseName],
|
cache: ['course_outline', props.courseName],
|
||||||
params: {
|
makeParams() {
|
||||||
|
return {
|
||||||
course: props.courseName,
|
course: props.courseName,
|
||||||
progress: props.getProgress,
|
progress: props.getProgress,
|
||||||
|
}
|
||||||
},
|
},
|
||||||
auto: true,
|
auto: true,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.courseName,
|
||||||
|
() => {
|
||||||
|
outline.reload()
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
const deleteLesson = createResource({
|
const deleteLesson = createResource({
|
||||||
url: 'lms.lms.api.delete_lesson',
|
url: 'lms.lms.api.delete_lesson',
|
||||||
makeParams(values) {
|
makeParams(values) {
|
||||||
@@ -233,6 +258,20 @@ const updateLessonIndex = createResource({
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const updateChapterIndex = createResource({
|
||||||
|
url: 'lms.lms.api.update_chapter_index',
|
||||||
|
makeParams(values) {
|
||||||
|
return {
|
||||||
|
chapter: values.chapter,
|
||||||
|
course: values.course,
|
||||||
|
idx: values.idx,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onSuccess() {
|
||||||
|
toast.success(__('Chapter moved successfully'))
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
const trashLesson = (lessonName, chapterName) => {
|
const trashLesson = (lessonName, chapterName) => {
|
||||||
$dialog({
|
$dialog({
|
||||||
title: __('Delete this lesson?'),
|
title: __('Delete this lesson?'),
|
||||||
@@ -278,6 +317,14 @@ const updateOutline = (e) => {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const updateChapterOrder = (e) => {
|
||||||
|
updateChapterIndex.submit({
|
||||||
|
chapter: e.item.__draggable_context.element.name,
|
||||||
|
course: props.courseName,
|
||||||
|
idx: e.newIndex,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
const deleteChapter = createResource({
|
const deleteChapter = createResource({
|
||||||
url: 'lms.lms.api.delete_chapter',
|
url: 'lms.lms.api.delete_chapter',
|
||||||
makeParams(values) {
|
makeParams(values) {
|
||||||
|
|||||||
@@ -64,7 +64,7 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { Star } from 'lucide-vue-next'
|
import { Star } from 'lucide-vue-next'
|
||||||
import { createResource, Button } from 'frappe-ui'
|
import { createResource, Button } from 'frappe-ui'
|
||||||
import { computed, ref, inject } from 'vue'
|
import { watch, ref, inject } from 'vue'
|
||||||
import UserAvatar from '@/components/UserAvatar.vue'
|
import UserAvatar from '@/components/UserAvatar.vue'
|
||||||
import ReviewModal from '@/components/Modals/ReviewModal.vue'
|
import ReviewModal from '@/components/Modals/ReviewModal.vue'
|
||||||
|
|
||||||
@@ -101,12 +101,21 @@ const hasReviewed = createResource({
|
|||||||
const reviews = createResource({
|
const reviews = createResource({
|
||||||
url: 'lms.lms.utils.get_reviews',
|
url: 'lms.lms.utils.get_reviews',
|
||||||
cache: ['course_reviews', props.courseName],
|
cache: ['course_reviews', props.courseName],
|
||||||
params: {
|
makeParams() {
|
||||||
|
return {
|
||||||
course: props.courseName,
|
course: props.courseName,
|
||||||
|
}
|
||||||
},
|
},
|
||||||
auto: true,
|
auto: true,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.courseName,
|
||||||
|
() => {
|
||||||
|
reviews.reload()
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
const showReviewModal = ref(false)
|
const showReviewModal = ref(false)
|
||||||
|
|
||||||
function openReviewModal() {
|
function openReviewModal() {
|
||||||
|
|||||||
@@ -32,13 +32,13 @@
|
|||||||
"
|
"
|
||||||
:options="[
|
:options="[
|
||||||
{
|
{
|
||||||
label: 'Edit',
|
label: __('Edit'),
|
||||||
onClick() {
|
onClick() {
|
||||||
reply.editable = true
|
reply.editable = true
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'Delete',
|
label: __('Delete'),
|
||||||
onClick() {
|
onClick() {
|
||||||
deleteReply(reply)
|
deleteReply(reply)
|
||||||
},
|
},
|
||||||
@@ -94,10 +94,10 @@
|
|||||||
</template>
|
</template>
|
||||||
<script setup>
|
<script setup>
|
||||||
import { createResource, TextEditor, Button, Dropdown, toast } from 'frappe-ui'
|
import { createResource, TextEditor, Button, Dropdown, toast } from 'frappe-ui'
|
||||||
import { timeAgo } from '../utils'
|
import { timeAgo } from '@/utils'
|
||||||
import UserAvatar from '@/components/UserAvatar.vue'
|
import UserAvatar from '@/components/UserAvatar.vue'
|
||||||
import { ChevronLeft, MoreHorizontal } from 'lucide-vue-next'
|
import { ChevronLeft, MoreHorizontal } from 'lucide-vue-next'
|
||||||
import { ref, inject, onMounted } from 'vue'
|
import { ref, inject, onMounted, onUnmounted } from 'vue'
|
||||||
|
|
||||||
const showTopics = defineModel('showTopics')
|
const showTopics = defineModel('showTopics')
|
||||||
const newReply = ref('')
|
const newReply = ref('')
|
||||||
@@ -251,4 +251,10 @@ const deleteReply = (reply) => {
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
socket.off('publish_message')
|
||||||
|
socket.off('update_message')
|
||||||
|
socket.off('delete_message')
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -5,6 +5,9 @@
|
|||||||
class="float-right"
|
class="float-right"
|
||||||
@click="openTopicModal()"
|
@click="openTopicModal()"
|
||||||
>
|
>
|
||||||
|
<template #prefix>
|
||||||
|
<Plus class="size-4" />
|
||||||
|
</template>
|
||||||
{{ __('New {0}').format(singularize(title)) }}
|
{{ __('New {0}').format(singularize(title)) }}
|
||||||
</Button>
|
</Button>
|
||||||
<div class="text-xl font-semibold text-ink-gray-9">
|
<div class="text-xl font-semibold text-ink-gray-9">
|
||||||
@@ -49,7 +52,7 @@
|
|||||||
class="flex flex-col items-center justify-center border-2 border-dashed mt-5 py-8 rounded-md"
|
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-ink-gray-4 stroke-1.5 mr-2" />
|
<MessageSquareText class="w-7 h-7 text-ink-gray-4 stroke-1.5 mr-2" />
|
||||||
<div class="">
|
<div class="mt-2">
|
||||||
<div v-if="emptyStateTitle" class="font-medium mb-2">
|
<div v-if="emptyStateTitle" class="font-medium mb-2">
|
||||||
{{ __(emptyStateTitle) }}
|
{{ __(emptyStateTitle) }}
|
||||||
</div>
|
</div>
|
||||||
@@ -69,11 +72,11 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { createResource, Button } from 'frappe-ui'
|
import { createResource, Button } from 'frappe-ui'
|
||||||
import UserAvatar from '@/components/UserAvatar.vue'
|
import UserAvatar from '@/components/UserAvatar.vue'
|
||||||
import { singularize, timeAgo } from '../utils'
|
import { singularize, timeAgo } from '@/utils'
|
||||||
import { ref, onMounted, inject } from 'vue'
|
import { ref, onMounted, inject, onUnmounted } from 'vue'
|
||||||
import DiscussionReplies from '@/components/DiscussionReplies.vue'
|
import DiscussionReplies from '@/components/DiscussionReplies.vue'
|
||||||
import DiscussionModal from '@/components/Modals/DiscussionModal.vue'
|
import DiscussionModal from '@/components/Modals/DiscussionModal.vue'
|
||||||
import { MessageSquareText } from 'lucide-vue-next'
|
import { MessageSquareText, Plus } from 'lucide-vue-next'
|
||||||
import { getScrollContainer } from '@/utils/scrollContainer'
|
import { getScrollContainer } from '@/utils/scrollContainer'
|
||||||
|
|
||||||
const showTopics = ref(true)
|
const showTopics = ref(true)
|
||||||
@@ -102,7 +105,7 @@ const props = defineProps({
|
|||||||
},
|
},
|
||||||
emptyStateText: {
|
emptyStateText: {
|
||||||
type: String,
|
type: String,
|
||||||
default: 'Start a discussion',
|
default: 'Start a Discussion',
|
||||||
},
|
},
|
||||||
singleThread: {
|
singleThread: {
|
||||||
type: Boolean,
|
type: Boolean,
|
||||||
@@ -153,4 +156,8 @@ const showReplies = (topic) => {
|
|||||||
const openTopicModal = () => {
|
const openTopicModal = () => {
|
||||||
showTopicModal.value = true
|
showTopicModal.value = true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
socket.off('new_discussion_topic')
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -1,132 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div class="flex min-h-0 flex-col text-base">
|
|
||||||
<div class="flex items-center justify-between mb-4">
|
|
||||||
<div>
|
|
||||||
<div class="text-xl font-semibold mb-1 text-ink-gray-9">
|
|
||||||
{{ __(label) }}
|
|
||||||
</div>
|
|
||||||
<!-- <div class="text-xs text-ink-gray-5">
|
|
||||||
{{ __(description) }}
|
|
||||||
</div> -->
|
|
||||||
</div>
|
|
||||||
<div class="flex item-center space-x-2">
|
|
||||||
<FormControl
|
|
||||||
v-model="search"
|
|
||||||
:placeholder="__('Search')"
|
|
||||||
type="text"
|
|
||||||
:debounce="300"
|
|
||||||
/>
|
|
||||||
<Button @click="() => (showForm = !showForm)">
|
|
||||||
<template #prefix>
|
|
||||||
<Plus v-if="!showForm" class="size-4 stroke-1.5" />
|
|
||||||
<X v-else class="size-4 stroke-1.5" />
|
|
||||||
</template>
|
|
||||||
{{ showForm ? __('Close') : __('New') }}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Form to add new member -->
|
|
||||||
<div v-if="showForm" class="flex items-center space-x-2 my-4">
|
|
||||||
<FormControl
|
|
||||||
v-model="email"
|
|
||||||
:placeholder="__('Email')"
|
|
||||||
type="email"
|
|
||||||
class="w-full"
|
|
||||||
/>
|
|
||||||
<Button @click="addEvaluator()" variant="subtle">
|
|
||||||
{{ __('Add') }}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="overflow-y-scroll">
|
|
||||||
<div class="divide-y">
|
|
||||||
<div
|
|
||||||
v-for="evaluator in evaluators.data"
|
|
||||||
@click="openProfile(evaluator.username)"
|
|
||||||
class="cursor-pointer"
|
|
||||||
>
|
|
||||||
<div class="flex items-center justify-between py-3">
|
|
||||||
<div class="flex items-center space-x-3">
|
|
||||||
<Avatar
|
|
||||||
:image="evaluator.user_image"
|
|
||||||
:label="evaluator.full_name"
|
|
||||||
size="lg"
|
|
||||||
/>
|
|
||||||
<div>
|
|
||||||
<div class="text-base font-semibold text-ink-gray-9">
|
|
||||||
{{ evaluator.full_name }}
|
|
||||||
</div>
|
|
||||||
<div class="text-xs text-ink-gray-5">
|
|
||||||
{{ evaluator.evaluator }}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
<script setup lang="ts">
|
|
||||||
import { createResource, Button, FormControl, call, Avatar } from 'frappe-ui'
|
|
||||||
import { ref, watch } from 'vue'
|
|
||||||
import { Plus, X } from 'lucide-vue-next'
|
|
||||||
import { useRouter } from 'vue-router'
|
|
||||||
|
|
||||||
const show = defineModel('show')
|
|
||||||
const search = ref('')
|
|
||||||
const showForm = ref(false)
|
|
||||||
const email = ref('')
|
|
||||||
const router = useRouter()
|
|
||||||
|
|
||||||
const props = defineProps({
|
|
||||||
label: {
|
|
||||||
type: String,
|
|
||||||
required: true,
|
|
||||||
},
|
|
||||||
description: {
|
|
||||||
type: String,
|
|
||||||
default: '',
|
|
||||||
},
|
|
||||||
show: {
|
|
||||||
type: Boolean,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
const evaluators = createResource({
|
|
||||||
url: 'frappe.client.get_list',
|
|
||||||
makeParams: () => {
|
|
||||||
return {
|
|
||||||
doctype: 'Course Evaluator',
|
|
||||||
fields: ['evaluator', 'full_name', 'user_image', 'username'],
|
|
||||||
filters: search.value ? { evaluator: ['like', `%${search.value}%`] } : {},
|
|
||||||
}
|
|
||||||
},
|
|
||||||
auto: true,
|
|
||||||
})
|
|
||||||
|
|
||||||
const addEvaluator = () => {
|
|
||||||
call('lms.lms.api.add_an_evaluator', {
|
|
||||||
email: email.value,
|
|
||||||
}).then((data) => {
|
|
||||||
showForm.value = false
|
|
||||||
email.value = ''
|
|
||||||
evaluators.reload()
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
watch(search, () => {
|
|
||||||
evaluators.reload()
|
|
||||||
})
|
|
||||||
|
|
||||||
const openProfile = (username) => {
|
|
||||||
show.value = false
|
|
||||||
router.push({
|
|
||||||
name: 'Profile',
|
|
||||||
params: {
|
|
||||||
username: username,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
<template>
|
<template>
|
||||||
<div
|
<div
|
||||||
class="flex flex-col border rounded-md p-3 h-full hover:border-outline-gray-4"
|
class="flex flex-col border rounded-md p-3 h-full hover:border-outline-gray-3"
|
||||||
>
|
>
|
||||||
<div class="flex space-x-4 mb-4">
|
<div class="flex space-x-4 mb-4">
|
||||||
<div class="flex flex-col space-y-2 flex-1">
|
<div class="flex flex-col space-y-2 flex-1">
|
||||||
|
|||||||
@@ -15,60 +15,18 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="space-y-2">
|
<div class="space-y-2" v-for="(item, key) in contentMap" :key="key">
|
||||||
<div
|
<div
|
||||||
class="flex items-center text-sm font-medium space-x-2 cursor-pointer"
|
class="flex items-center text-sm font-medium space-x-2 cursor-pointer"
|
||||||
@click="openHelpDialog('quiz')"
|
@click="openHelpDialog(key)"
|
||||||
>
|
>
|
||||||
<span>
|
<span>
|
||||||
{{ __('How to add a Quiz?') }}
|
{{ __(item.title) }}
|
||||||
</span>
|
</span>
|
||||||
<Info class="w-3 h-3 text-ink-gray-7" />
|
<Info class="w-3 h-3 text-ink-gray-7" />
|
||||||
</div>
|
</div>
|
||||||
<div class="text-xs text-ink-gray-5 mb-1 leading-5">
|
<div class="text-xs text-ink-gray-5 mb-1 leading-5">
|
||||||
{{
|
{{ __(item.description) }}
|
||||||
__(
|
|
||||||
'Click on the add icon in the editor and select Quiz from the menu. It opens up a dialog, where you can either select a quiz from the list or create a new quiz. When you select the Create New option it redirects you to the quiz creation page.'
|
|
||||||
)
|
|
||||||
}}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="space-y-2">
|
|
||||||
<div
|
|
||||||
class="flex text-sm font-medium space-x-2 cursor-pointer"
|
|
||||||
@click="openHelpDialog('upload')"
|
|
||||||
>
|
|
||||||
<span class="leading-5">
|
|
||||||
{{ __(contentMap['upload']) }}
|
|
||||||
</span>
|
|
||||||
<Info class="w-3 h-3 text-ink-gray-7" />
|
|
||||||
</div>
|
|
||||||
<div class="text-xs text-ink-gray-5 mb-1 leading-5">
|
|
||||||
{{
|
|
||||||
__(
|
|
||||||
'To upload Image, Video, Audio or PDF from your system, click on the add icon and select upload from the menu. Then choose the file you want to add to the lesson and it gets added to your lesson.'
|
|
||||||
)
|
|
||||||
}}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="space-y-2">
|
|
||||||
<div
|
|
||||||
class="flex items-center text-sm font-medium space-x-2 cursor-pointer"
|
|
||||||
@click="openHelpDialog('youtube')"
|
|
||||||
>
|
|
||||||
<span>
|
|
||||||
{{ __(contentMap['youtube']) }}
|
|
||||||
</span>
|
|
||||||
<Info class="w-3 h-3 text-ink-gray-7" />
|
|
||||||
</div>
|
|
||||||
<div class="text-xs text-ink-gray-5 mb-1 leading-5">
|
|
||||||
{{
|
|
||||||
__(
|
|
||||||
'Copy the URL of the video from YouTube and paste it in the editor.'
|
|
||||||
)
|
|
||||||
}}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -83,14 +41,31 @@ const showExplanation = ref(false)
|
|||||||
const type = ref(null)
|
const type = ref(null)
|
||||||
const title = ref(null)
|
const title = ref(null)
|
||||||
const contentMap = {
|
const contentMap = {
|
||||||
quiz: 'How to add a Quiz?',
|
quiz: {
|
||||||
upload: 'How to upload content from your system?',
|
title: 'How to add a Quiz?',
|
||||||
youtube: 'How to add a YouTube Video?',
|
description:
|
||||||
|
'Click on the add icon in the editor and select Quiz from the menu. It opens up a dialog, where you can either select a quiz from the list or create a new quiz. When you select the Create New option it redirects you to the quiz creation page.',
|
||||||
|
},
|
||||||
|
upload: {
|
||||||
|
title: 'How to upload content from your system?',
|
||||||
|
description:
|
||||||
|
'To upload Image, Video, Audio or PDF from your system, click on the add icon and select upload from the menu. Then choose the file you want to add to the lesson and it gets added to your lesson.',
|
||||||
|
},
|
||||||
|
youtube: {
|
||||||
|
title: 'How to add a YouTube Video?',
|
||||||
|
description:
|
||||||
|
'Copy the URL of the video from YouTube and paste it in the editor.',
|
||||||
|
},
|
||||||
|
remove: {
|
||||||
|
title: 'How to remove an embed?',
|
||||||
|
description:
|
||||||
|
'To remove an embed like YouTube or Vimeo, put your cursor on the line below the embed, then drag your mouse cursor upwards to select the embed. Once the embed is selected press BackSpace.',
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
const openHelpDialog = (contentType) => {
|
const openHelpDialog = (contentType) => {
|
||||||
type.value = contentType
|
type.value = contentType
|
||||||
title.value = contentMap[contentType]
|
title.value = contentMap[contentType].title
|
||||||
showExplanation.value = true
|
showExplanation.value = true
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -1,5 +1,15 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="flex items-center justify-between mb-5">
|
<div
|
||||||
|
v-if="hasPermission() && !props.zoomAccount"
|
||||||
|
class="flex items-center space-x-2 mb-5 bg-surface-amber-1 py-1 px-2 rounded-md text-ink-amber-3"
|
||||||
|
>
|
||||||
|
<AlertCircle class="size-4 stroke-1.5" />
|
||||||
|
<span>
|
||||||
|
{{ __('Please add a zoom account to the batch to create live classes.') }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
<div class="text-lg font-semibold text-ink-gray-9">
|
<div class="text-lg font-semibold text-ink-gray-9">
|
||||||
{{ __('Live Class') }}
|
{{ __('Live Class') }}
|
||||||
</div>
|
</div>
|
||||||
@@ -12,10 +22,18 @@
|
|||||||
</span>
|
</span>
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="liveClasses.data?.length" class="grid grid-cols-2 gap-5">
|
<div v-if="liveClasses.data?.length" class="grid grid-cols-3 gap-5 mt-5">
|
||||||
<div
|
<div
|
||||||
v-for="cls in liveClasses.data"
|
v-for="cls in liveClasses.data"
|
||||||
class="flex flex-col border rounded-md h-full text-ink-gray-7 p-3"
|
class="flex flex-col border rounded-md h-full text-ink-gray-7 hover:border-outline-gray-3 p-3"
|
||||||
|
:class="{
|
||||||
|
'cursor-pointer': hasPermission() && cls.attendees > 0,
|
||||||
|
}"
|
||||||
|
@click="
|
||||||
|
() => {
|
||||||
|
openAttendanceModal(cls)
|
||||||
|
}
|
||||||
|
"
|
||||||
>
|
>
|
||||||
<div class="font-semibold text-ink-gray-9 text-lg mb-1">
|
<div class="font-semibold text-ink-gray-9 text-lg mb-1">
|
||||||
{{ cls.title }}
|
{{ cls.title }}
|
||||||
@@ -23,7 +41,7 @@
|
|||||||
<div class="short-introduction">
|
<div class="short-introduction">
|
||||||
{{ cls.description }}
|
{{ cls.description }}
|
||||||
</div>
|
</div>
|
||||||
<div class="space-y-3">
|
<div class="mt-auto space-y-3">
|
||||||
<div class="flex items-center space-x-2">
|
<div class="flex items-center space-x-2">
|
||||||
<Calendar class="w-4 h-4 stroke-1.5" />
|
<Calendar class="w-4 h-4 stroke-1.5" />
|
||||||
<span>
|
<span>
|
||||||
@@ -33,18 +51,20 @@
|
|||||||
<div class="flex items-center space-x-2">
|
<div class="flex items-center space-x-2">
|
||||||
<Clock class="w-4 h-4 stroke-1.5" />
|
<Clock class="w-4 h-4 stroke-1.5" />
|
||||||
<span>
|
<span>
|
||||||
{{ formatTime(cls.time) }}
|
{{ formatTime(cls.time) }} -
|
||||||
|
{{ dayjs(getClassEnd(cls)).format('HH:mm') }}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
v-if="cls.date >= dayjs().format('YYYY-MM-DD')"
|
v-if="canAccessClass(cls)"
|
||||||
class="flex items-center space-x-2 text-ink-gray-9 mt-auto"
|
class="flex items-center space-x-2 text-ink-gray-9 mt-auto"
|
||||||
>
|
>
|
||||||
<a
|
<a
|
||||||
v-if="user.data?.is_moderator || user.data?.is_evaluator"
|
v-if="user.data?.is_moderator || user.data?.is_evaluator"
|
||||||
:href="cls.start_url"
|
:href="cls.start_url"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
class="w-1/2 cursor-pointer inline-flex items-center justify-center gap-2 transition-colors focus:outline-none text-ink-gray-8 bg-surface-gray-2 hover:bg-surface-gray-3 active:bg-surface-gray-4 focus-visible:ring focus-visible:ring-outline-gray-3 h-7 text-base px-2 rounded"
|
class="cursor-pointer inline-flex items-center justify-center gap-2 transition-colors focus:outline-none text-ink-gray-8 bg-surface-gray-2 hover:bg-surface-gray-3 active:bg-surface-gray-4 focus-visible:ring focus-visible:ring-outline-gray-3 h-7 text-base px-2 rounded"
|
||||||
|
:class="cls.join_url ? 'w-full' : 'w-1/2'"
|
||||||
>
|
>
|
||||||
<Monitor class="h-4 w-4 stroke-1.5" />
|
<Monitor class="h-4 w-4 stroke-1.5" />
|
||||||
{{ __('Start') }}
|
{{ __('Start') }}
|
||||||
@@ -58,42 +78,63 @@
|
|||||||
{{ __('Join') }}
|
{{ __('Join') }}
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
<div v-else class="flex items-center space-x-2 text-yellow-700">
|
<Tooltip
|
||||||
|
v-else-if="hasClassEnded(cls)"
|
||||||
|
:text="__('This class has ended')"
|
||||||
|
placement="right"
|
||||||
|
>
|
||||||
|
<div class="flex items-center space-x-2 text-ink-amber-3 w-fit">
|
||||||
<Info class="w-4 h-4 stroke-1.5" />
|
<Info class="w-4 h-4 stroke-1.5" />
|
||||||
<span>
|
<span>
|
||||||
{{ __('This class has ended') }}
|
{{ __('Ended') }}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
</Tooltip>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div v-else class="text-sm italic text-ink-gray-5">
|
<div v-else class="text-sm italic text-ink-gray-5 mt-2">
|
||||||
{{ __('No live classes scheduled') }}
|
{{ __('No live classes scheduled') }}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<LiveClassModal
|
<LiveClassModal
|
||||||
:batch="props.batch"
|
:batch="props.batch"
|
||||||
|
:zoomAccount="props.zoomAccount"
|
||||||
v-model="showLiveClassModal"
|
v-model="showLiveClassModal"
|
||||||
v-model:reloadLiveClasses="liveClasses"
|
v-model:reloadLiveClasses="liveClasses"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<LiveClassAttendance v-model="showAttendance" :live_class="attendanceFor" />
|
||||||
</template>
|
</template>
|
||||||
<script setup>
|
<script setup>
|
||||||
import { createListResource, Button } from 'frappe-ui'
|
import { createListResource, Button, Tooltip } from 'frappe-ui'
|
||||||
import { Plus, Clock, Calendar, Video, Monitor, Info } from 'lucide-vue-next'
|
import {
|
||||||
import { inject } from 'vue'
|
Plus,
|
||||||
import LiveClassModal from '@/components/Modals/LiveClassModal.vue'
|
Clock,
|
||||||
import { ref } from 'vue'
|
Calendar,
|
||||||
|
Video,
|
||||||
|
Monitor,
|
||||||
|
Info,
|
||||||
|
AlertCircle,
|
||||||
|
} from 'lucide-vue-next'
|
||||||
|
import { inject, ref } from 'vue'
|
||||||
import { formatTime } from '@/utils/'
|
import { formatTime } from '@/utils/'
|
||||||
|
import LiveClassModal from '@/components/Modals/LiveClassModal.vue'
|
||||||
|
import LiveClassAttendance from '@/components/Modals/LiveClassAttendance.vue'
|
||||||
|
|
||||||
const user = inject('$user')
|
const user = inject('$user')
|
||||||
const showLiveClassModal = ref(false)
|
const showLiveClassModal = ref(false)
|
||||||
const dayjs = inject('$dayjs')
|
const dayjs = inject('$dayjs')
|
||||||
const readOnlyMode = window.read_only_mode
|
const readOnlyMode = window.read_only_mode
|
||||||
|
const showAttendance = ref(false)
|
||||||
|
const attendanceFor = ref(null)
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
batch: {
|
batch: {
|
||||||
type: String,
|
type: String,
|
||||||
required: true,
|
required: true,
|
||||||
},
|
},
|
||||||
|
zoomAccount: String,
|
||||||
})
|
})
|
||||||
|
|
||||||
const liveClasses = createListResource({
|
const liveClasses = createListResource({
|
||||||
@@ -106,6 +147,8 @@ const liveClasses = createListResource({
|
|||||||
'description',
|
'description',
|
||||||
'time',
|
'time',
|
||||||
'date',
|
'date',
|
||||||
|
'duration',
|
||||||
|
'attendees',
|
||||||
'start_url',
|
'start_url',
|
||||||
'join_url',
|
'join_url',
|
||||||
'owner',
|
'owner',
|
||||||
@@ -120,8 +163,38 @@ const openLiveClassModal = () => {
|
|||||||
|
|
||||||
const canCreateClass = () => {
|
const canCreateClass = () => {
|
||||||
if (readOnlyMode) return false
|
if (readOnlyMode) return false
|
||||||
|
if (!props.zoomAccount) return false
|
||||||
|
return hasPermission()
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasPermission = () => {
|
||||||
return user.data?.is_moderator || user.data?.is_evaluator
|
return user.data?.is_moderator || user.data?.is_evaluator
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const canAccessClass = (cls) => {
|
||||||
|
if (cls.date < dayjs().format('YYYY-MM-DD')) return false
|
||||||
|
if (cls.date > dayjs().format('YYYY-MM-DD')) return false
|
||||||
|
if (hasClassEnded(cls)) return false
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
const getClassEnd = (cls) => {
|
||||||
|
const classStart = new Date(`${cls.date}T${cls.time}`)
|
||||||
|
return new Date(classStart.getTime() + cls.duration * 60000)
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasClassEnded = (cls) => {
|
||||||
|
const classEnd = getClassEnd(cls)
|
||||||
|
const now = new Date()
|
||||||
|
return now > classEnd
|
||||||
|
}
|
||||||
|
|
||||||
|
const openAttendanceModal = (cls) => {
|
||||||
|
if (!hasPermission()) return
|
||||||
|
if (cls.attendees <= 0) return
|
||||||
|
showAttendance.value = true
|
||||||
|
attendanceFor.value = cls
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
<style>
|
<style>
|
||||||
.short-introduction {
|
.short-introduction {
|
||||||
|
|||||||
@@ -54,15 +54,17 @@
|
|||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<script setup>
|
<script setup>
|
||||||
import { getSidebarLinks } from '../utils'
|
import { getSidebarLinks } from '@/utils'
|
||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
import { watch, ref, onMounted } from 'vue'
|
import { watch, ref, onMounted } from 'vue'
|
||||||
import { sessionStore } from '@/stores/session'
|
import { sessionStore } from '@/stores/session'
|
||||||
|
import { useSettings } from '@/stores/settings'
|
||||||
import { usersStore } from '@/stores/user'
|
import { usersStore } from '@/stores/user'
|
||||||
import * as icons from 'lucide-vue-next'
|
import * as icons from 'lucide-vue-next'
|
||||||
|
|
||||||
const { logout, user, sidebarSettings } = sessionStore()
|
const { logout, user } = sessionStore()
|
||||||
let { isLoggedIn } = sessionStore()
|
let { isLoggedIn } = sessionStore()
|
||||||
|
const { sidebarSettings } = useSettings()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
let { userResource } = usersStore()
|
let { userResource } = usersStore()
|
||||||
const sidebarLinks = ref(getSidebarLinks())
|
const sidebarLinks = ref(getSidebarLinks())
|
||||||
|
|||||||
@@ -25,6 +25,7 @@
|
|||||||
<div class="">
|
<div class="">
|
||||||
<div class="mb-1.5 text-sm text-ink-gray-5">
|
<div class="mb-1.5 text-sm text-ink-gray-5">
|
||||||
{{ __('Reply To') }}
|
{{ __('Reply To') }}
|
||||||
|
<span class="text-ink-red-3">*</span>
|
||||||
</div>
|
</div>
|
||||||
<Input type="text" v-model="announcement.replyTo" />
|
<Input type="text" v-model="announcement.replyTo" />
|
||||||
</div>
|
</div>
|
||||||
@@ -70,8 +71,8 @@ const announcementResource = createResource({
|
|||||||
url: 'frappe.core.doctype.communication.email.make',
|
url: 'frappe.core.doctype.communication.email.make',
|
||||||
makeParams(values) {
|
makeParams(values) {
|
||||||
return {
|
return {
|
||||||
recipients: props.students.join(', '),
|
recipients: announcement.replyTo,
|
||||||
cc: announcement.replyTo,
|
bcc: props.students.join(', '),
|
||||||
subject: announcement.subject,
|
subject: announcement.subject,
|
||||||
content: announcement.announcement,
|
content: announcement.announcement,
|
||||||
doctype: 'LMS Batch',
|
doctype: 'LMS Batch',
|
||||||
@@ -95,6 +96,9 @@ const makeAnnouncement = (close) => {
|
|||||||
if (!announcement.announcement) {
|
if (!announcement.announcement) {
|
||||||
return __('Announcement is required')
|
return __('Announcement is required')
|
||||||
}
|
}
|
||||||
|
if (!announcement.replyTo) {
|
||||||
|
return __('Reply To is required')
|
||||||
|
}
|
||||||
},
|
},
|
||||||
onSuccess() {
|
onSuccess() {
|
||||||
close()
|
close()
|
||||||
|
|||||||
@@ -99,6 +99,7 @@ const assessmentTypes = computed(() => {
|
|||||||
return [
|
return [
|
||||||
{ label: 'Quiz', value: 'LMS Quiz' },
|
{ label: 'Quiz', value: 'LMS Quiz' },
|
||||||
{ label: 'Assignment', value: 'LMS Assignment' },
|
{ label: 'Assignment', value: 'LMS Assignment' },
|
||||||
|
{ label: 'Programming Exercise', value: 'LMS Programming Exercise' },
|
||||||
]
|
]
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
230
frontend/src/components/Modals/CourseProgressSummary.vue
Normal file
230
frontend/src/components/Modals/CourseProgressSummary.vue
Normal file
@@ -0,0 +1,230 @@
|
|||||||
|
<template>
|
||||||
|
<Dialog
|
||||||
|
v-model="show"
|
||||||
|
:options="{
|
||||||
|
title: __('Course Progress Summary'),
|
||||||
|
size: '5xl',
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
<template #body-content>
|
||||||
|
<div class="flex justify-between space-x-10 text-base mt-10">
|
||||||
|
<div class="w-full">
|
||||||
|
<div class="flex items-center justify-between space-x-5 mb-4">
|
||||||
|
<!-- <div class="text-xl font-semibold text-ink-gray-6">
|
||||||
|
{{ __('{0} Members').format(memberCount) }}
|
||||||
|
</div> -->
|
||||||
|
<FormControl
|
||||||
|
v-model="searchFilter"
|
||||||
|
:placeholder="__('Search by Member')"
|
||||||
|
type="text"
|
||||||
|
class="w-full"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="max-h-[70vh] overflow-y-auto">
|
||||||
|
<ListView
|
||||||
|
v-if="progressList.loading || progressList.data?.length"
|
||||||
|
:columns="progressColumns"
|
||||||
|
:rows="progressList.data"
|
||||||
|
rowKey="name"
|
||||||
|
:options="{
|
||||||
|
selectable: false,
|
||||||
|
showTooltip: false,
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
<ListHeader
|
||||||
|
class="mb-2 grid items-center space-x-4 rounded bg-surface-gray-2 p-2"
|
||||||
|
>
|
||||||
|
<ListHeaderItem
|
||||||
|
:item="item"
|
||||||
|
v-for="item in progressColumns"
|
||||||
|
:key="item.key"
|
||||||
|
>
|
||||||
|
<template #prefix="{ item }">
|
||||||
|
<FeatherIcon
|
||||||
|
:name="item.icon?.toString()"
|
||||||
|
class="h-4 w-4"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
</ListHeaderItem>
|
||||||
|
</ListHeader>
|
||||||
|
<ListRows v-for="row in progressList.data">
|
||||||
|
<router-link
|
||||||
|
:to="{
|
||||||
|
name: 'Profile',
|
||||||
|
params: { username: row.member_username },
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
<ListRow :row="row">
|
||||||
|
<template #default="{ column, item }">
|
||||||
|
<ListRowItem
|
||||||
|
:item="row[column.key]"
|
||||||
|
:align="column.align"
|
||||||
|
>
|
||||||
|
<template #prefix>
|
||||||
|
<div v-if="column.key == 'member_name'">
|
||||||
|
<Avatar
|
||||||
|
class="flex items-center"
|
||||||
|
:image="row['member_image']"
|
||||||
|
:label="item"
|
||||||
|
size="sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<div>
|
||||||
|
{{ row[column.key].toString() }}
|
||||||
|
</div>
|
||||||
|
</ListRowItem>
|
||||||
|
</template>
|
||||||
|
</ListRow>
|
||||||
|
</router-link>
|
||||||
|
</ListRows>
|
||||||
|
</ListView>
|
||||||
|
<div
|
||||||
|
v-if="progressList.data && progressList.hasNextPage"
|
||||||
|
class="flex justify-center my-5"
|
||||||
|
>
|
||||||
|
<Button @click="progressList.next()">
|
||||||
|
{{ __('Load More') }}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="mb-4 self-start w-full space-y-5">
|
||||||
|
<div class="flex items-center space-x-4">
|
||||||
|
<NumberChart
|
||||||
|
class="border rounded-md w-full"
|
||||||
|
:config="{
|
||||||
|
title: __('Enrollments'),
|
||||||
|
value: memberCount || 0,
|
||||||
|
}"
|
||||||
|
/>
|
||||||
|
<NumberChart
|
||||||
|
class="border rounded-md w-full"
|
||||||
|
:config="{
|
||||||
|
title: __('Average Progress %'),
|
||||||
|
value: chartDetails.data?.average_progress || 0,
|
||||||
|
}"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<DonutChart
|
||||||
|
:config="{
|
||||||
|
data: chartDetails.data?.progress_distribution || [],
|
||||||
|
title: __('Progress Distribution'),
|
||||||
|
categoryColumn: 'category',
|
||||||
|
valueColumn: 'count',
|
||||||
|
colors: [
|
||||||
|
theme.colors.red['400'],
|
||||||
|
theme.colors.amber['400'],
|
||||||
|
theme.colors.pink['400'],
|
||||||
|
theme.colors.blue['400'],
|
||||||
|
theme.colors.green['400'],
|
||||||
|
],
|
||||||
|
}"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</Dialog>
|
||||||
|
</template>
|
||||||
|
<script setup lang="ts">
|
||||||
|
import {
|
||||||
|
Avatar,
|
||||||
|
Button,
|
||||||
|
createListResource,
|
||||||
|
createResource,
|
||||||
|
Dialog,
|
||||||
|
DonutChart,
|
||||||
|
FeatherIcon,
|
||||||
|
FormControl,
|
||||||
|
ListView,
|
||||||
|
ListHeader,
|
||||||
|
ListHeaderItem,
|
||||||
|
ListRows,
|
||||||
|
ListRow,
|
||||||
|
ListRowItem,
|
||||||
|
NumberChart,
|
||||||
|
} from 'frappe-ui'
|
||||||
|
import { computed, ref, watch } from 'vue'
|
||||||
|
import { theme } from '@/utils/theme'
|
||||||
|
|
||||||
|
const show = defineModel<boolean | undefined>()
|
||||||
|
const searchFilter = ref<string | null>(null)
|
||||||
|
type Filters = {
|
||||||
|
course: string | undefined
|
||||||
|
member_name?: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
courseName?: string
|
||||||
|
enrollments?: number
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const memberCount = ref<number>(props.enrollments || 0)
|
||||||
|
|
||||||
|
const chartDetails = createResource({
|
||||||
|
url: 'lms.lms.api.get_course_progress_distribution',
|
||||||
|
params: {
|
||||||
|
course: props.courseName,
|
||||||
|
},
|
||||||
|
auto: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
const progressList = createListResource({
|
||||||
|
doctype: 'LMS Enrollment',
|
||||||
|
filters: {
|
||||||
|
course: props.courseName,
|
||||||
|
},
|
||||||
|
fields: [
|
||||||
|
'name',
|
||||||
|
'member',
|
||||||
|
'member_name',
|
||||||
|
'member_image',
|
||||||
|
'member_username',
|
||||||
|
'progress',
|
||||||
|
],
|
||||||
|
pageLength: 50,
|
||||||
|
auto: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
watch([searchFilter], () => {
|
||||||
|
let filterApplied = false
|
||||||
|
let filters: Filters = {
|
||||||
|
course: props.courseName,
|
||||||
|
}
|
||||||
|
|
||||||
|
if (searchFilter.value) {
|
||||||
|
filters.member_name = ['like', `%${searchFilter.value}%`]
|
||||||
|
filterApplied = true
|
||||||
|
}
|
||||||
|
|
||||||
|
progressList.update({
|
||||||
|
filters: filters,
|
||||||
|
})
|
||||||
|
progressList.reload(
|
||||||
|
{},
|
||||||
|
{
|
||||||
|
onSuccess(data: any[]) {
|
||||||
|
memberCount.value = filterApplied ? data.length : props.enrollments || 0
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
const progressColumns = computed(() => {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
label: __('Member'),
|
||||||
|
key: 'member_name',
|
||||||
|
width: '60%',
|
||||||
|
icon: 'user',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: __('Progress'),
|
||||||
|
key: 'progress',
|
||||||
|
width: '30%',
|
||||||
|
align: 'right',
|
||||||
|
icon: 'trending-up',
|
||||||
|
},
|
||||||
|
]
|
||||||
|
})
|
||||||
|
</script>
|
||||||
@@ -97,7 +97,7 @@ import {
|
|||||||
} from 'frappe-ui'
|
} from 'frappe-ui'
|
||||||
import { reactive, watch } from 'vue'
|
import { reactive, watch } from 'vue'
|
||||||
import { FileText, X } from 'lucide-vue-next'
|
import { FileText, X } from 'lucide-vue-next'
|
||||||
import { getFileSize, escapeHTML } from '@/utils'
|
import { getFileSize } from '@/utils'
|
||||||
|
|
||||||
const reloadProfile = defineModel('reloadProfile')
|
const reloadProfile = defineModel('reloadProfile')
|
||||||
|
|
||||||
@@ -132,7 +132,6 @@ const imageResource = createResource({
|
|||||||
const updateProfile = createResource({
|
const updateProfile = createResource({
|
||||||
url: 'frappe.client.set_value',
|
url: 'frappe.client.set_value',
|
||||||
makeParams(values) {
|
makeParams(values) {
|
||||||
profile.bio = escapeHTML(profile.bio)
|
|
||||||
return {
|
return {
|
||||||
doctype: 'User',
|
doctype: 'User',
|
||||||
name: props.profile.data.name,
|
name: props.profile.data.name,
|
||||||
|
|||||||
@@ -139,16 +139,7 @@ function submitEvaluation(close) {
|
|||||||
close()
|
close()
|
||||||
},
|
},
|
||||||
onError(err) {
|
onError(err) {
|
||||||
const message = err.messages?.[0] || err
|
toast.warning(__(err.messages?.[0] || err))
|
||||||
let unavailabilityMessage
|
|
||||||
|
|
||||||
if (typeof message === 'string') {
|
|
||||||
unavailabilityMessage = message?.includes('unavailable')
|
|
||||||
} else {
|
|
||||||
unavailabilityMessage = false
|
|
||||||
}
|
|
||||||
|
|
||||||
toast.warning(__(unavailabilityMessage || 'Evaluator is unavailable'))
|
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -76,8 +76,8 @@
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<Tabs :tabs="tabs" v-model="tabIndex" class="border-l w-1/2">
|
<Tabs :tabs="tabs" as="div" v-model="tabIndex" class="border-l w-1/2">
|
||||||
<template #default="{ tab }">
|
<template #tab-panel="{ tab }">
|
||||||
<div
|
<div
|
||||||
v-if="tab.label == 'Evaluation'"
|
v-if="tab.label == 'Evaluation'"
|
||||||
class="flex flex-col space-y-4 p-5"
|
class="flex flex-col space-y-4 p-5"
|
||||||
@@ -255,6 +255,9 @@ const saveEvaluation = () => {
|
|||||||
}
|
}
|
||||||
toast.success(__('Evaluation saved successfully'))
|
toast.success(__('Evaluation saved successfully'))
|
||||||
},
|
},
|
||||||
|
onError(err) {
|
||||||
|
toast.warning(__(err.messages?.[0] || err))
|
||||||
|
},
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -277,6 +280,9 @@ const certificateResource = createResource({
|
|||||||
onSuccess(data) {
|
onSuccess(data) {
|
||||||
certificate.name = data
|
certificate.name = data
|
||||||
},
|
},
|
||||||
|
onError(err) {
|
||||||
|
toast.warning(__(err.messages?.[0] || err))
|
||||||
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
const certificateDetails = createResource({
|
const certificateDetails = createResource({
|
||||||
@@ -310,6 +316,9 @@ const saveCertificate = () => {
|
|||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
toast.success(__('Certificate saved successfully'))
|
toast.success(__('Certificate saved successfully'))
|
||||||
},
|
},
|
||||||
|
onError(err) {
|
||||||
|
toast.error(__(err.messages?.[0] || err))
|
||||||
|
},
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -35,5 +35,6 @@ const file = computed(() => {
|
|||||||
if (props.type == 'youtube') return '/assets/lms/frontend/Youtube.mp4'
|
if (props.type == 'youtube') return '/assets/lms/frontend/Youtube.mp4'
|
||||||
if (props.type == 'quiz') return '/assets/lms/frontend/Quiz.mp4'
|
if (props.type == 'quiz') return '/assets/lms/frontend/Quiz.mp4'
|
||||||
if (props.type == 'upload') return '/assets/lms/frontend/Upload.mp4'
|
if (props.type == 'upload') return '/assets/lms/frontend/Upload.mp4'
|
||||||
|
if (props.type == 'remove') return '/assets/lms/frontend/Remove.mp4'
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
106
frontend/src/components/Modals/LiveClassAttendance.vue
Normal file
106
frontend/src/components/Modals/LiveClassAttendance.vue
Normal file
@@ -0,0 +1,106 @@
|
|||||||
|
<template>
|
||||||
|
<Dialog
|
||||||
|
v-model="show"
|
||||||
|
:options="{
|
||||||
|
title: __('Attendance for Class - {0}').format(live_class?.title),
|
||||||
|
size: '4xl',
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
<template #body-content>
|
||||||
|
<div
|
||||||
|
class="grid grid-cols-2 gap-12 text-sm font-semibold text-ink-gray-5 pb-2"
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
{{ __('Member') }}
|
||||||
|
</div>
|
||||||
|
<div class="grid grid-cols-3 gap-20">
|
||||||
|
<div>
|
||||||
|
{{ __('Joined at') }}
|
||||||
|
</div>
|
||||||
|
<div class="text-center">
|
||||||
|
{{ __('Left at') }}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
{{ __('Attended for') }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="divide-y text-base">
|
||||||
|
<div
|
||||||
|
v-for="participant in participants.data"
|
||||||
|
@click="redirectToProfile(participant.member_username)"
|
||||||
|
class="grid grid-cols-2 items-center w-full text-base w-fit py-2"
|
||||||
|
>
|
||||||
|
<div class="flex items-center space-x-2">
|
||||||
|
<Avatar
|
||||||
|
:image="participant.member_image"
|
||||||
|
:label="participant.member_name"
|
||||||
|
size="xl"
|
||||||
|
/>
|
||||||
|
<div class="space-y-1">
|
||||||
|
<div class="font-medium">
|
||||||
|
{{ participant.member_name }}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
{{ participant.member }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-3 gap-20 text-right">
|
||||||
|
<div>
|
||||||
|
{{ dayjs(participant.joined_at).format('HH:mm a') }}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
{{ dayjs(participant.left_at).format('HH:mm a') }}
|
||||||
|
</div>
|
||||||
|
<div>{{ participant.duration }} {{ __('minutes') }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</Dialog>
|
||||||
|
</template>
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { Avatar, createListResource, Dialog, Tooltip } from 'frappe-ui'
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
|
import { inject } from 'vue'
|
||||||
|
|
||||||
|
const show = defineModel()
|
||||||
|
const router = useRouter()
|
||||||
|
const dayjs = inject('$dayjs')
|
||||||
|
|
||||||
|
interface LiveClass {
|
||||||
|
name: String
|
||||||
|
title: String
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
live_class: LiveClass | null
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const participants = createListResource({
|
||||||
|
doctype: 'LMS Live Class Participant',
|
||||||
|
filter: {
|
||||||
|
live_class: props.live_class?.name,
|
||||||
|
},
|
||||||
|
fields: [
|
||||||
|
'name',
|
||||||
|
'member',
|
||||||
|
'member_name',
|
||||||
|
'member_image',
|
||||||
|
'member_username',
|
||||||
|
'joined_at',
|
||||||
|
'left_at',
|
||||||
|
'duration',
|
||||||
|
],
|
||||||
|
auto: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
const redirectToProfile = (username: string) => {
|
||||||
|
router.push({
|
||||||
|
name: 'Profile',
|
||||||
|
params: { username },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
</script>
|
||||||
@@ -8,7 +8,7 @@
|
|||||||
{
|
{
|
||||||
label: 'Submit',
|
label: 'Submit',
|
||||||
variant: 'solid',
|
variant: 'solid',
|
||||||
onClick: (close) => submitLiveClass(close),
|
onClick: ({ close }) => submitLiveClass(close),
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
}"
|
}"
|
||||||
@@ -16,14 +16,29 @@
|
|||||||
<template #body-content>
|
<template #body-content>
|
||||||
<div class="flex flex-col gap-4">
|
<div class="flex flex-col gap-4">
|
||||||
<div class="grid grid-cols-2 gap-4">
|
<div class="grid grid-cols-2 gap-4">
|
||||||
<div>
|
<div class="space-y-4">
|
||||||
<FormControl
|
<FormControl
|
||||||
type="text"
|
type="text"
|
||||||
v-model="liveClass.title"
|
v-model="liveClass.title"
|
||||||
:label="__('Title')"
|
:label="__('Title')"
|
||||||
class="mb-4"
|
|
||||||
:required="true"
|
:required="true"
|
||||||
/>
|
/>
|
||||||
|
<FormControl
|
||||||
|
v-model="liveClass.date"
|
||||||
|
type="date"
|
||||||
|
:label="__('Date')"
|
||||||
|
:required="true"
|
||||||
|
/>
|
||||||
|
<Tooltip :text="__('Duration of the live class in minutes')">
|
||||||
|
<FormControl
|
||||||
|
type="number"
|
||||||
|
v-model="liveClass.duration"
|
||||||
|
:label="__('Duration')"
|
||||||
|
:required="true"
|
||||||
|
/>
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
|
<div class="space-y-4">
|
||||||
<Tooltip
|
<Tooltip
|
||||||
:text="
|
:text="
|
||||||
__(
|
__(
|
||||||
@@ -35,7 +50,6 @@
|
|||||||
v-model="liveClass.time"
|
v-model="liveClass.time"
|
||||||
type="time"
|
type="time"
|
||||||
:label="__('Time')"
|
:label="__('Time')"
|
||||||
class="mb-4"
|
|
||||||
:required="true"
|
:required="true"
|
||||||
/>
|
/>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
@@ -52,24 +66,6 @@
|
|||||||
:required="true"
|
:required="true"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<FormControl
|
|
||||||
v-model="liveClass.date"
|
|
||||||
type="date"
|
|
||||||
class="mb-4"
|
|
||||||
:label="__('Date')"
|
|
||||||
:required="true"
|
|
||||||
/>
|
|
||||||
<Tooltip :text="__('Duration of the live class in minutes')">
|
|
||||||
<FormControl
|
|
||||||
type="number"
|
|
||||||
v-model="liveClass.duration"
|
|
||||||
:label="__('Duration')"
|
|
||||||
class="mb-4"
|
|
||||||
:required="true"
|
|
||||||
/>
|
|
||||||
</Tooltip>
|
|
||||||
<FormControl
|
<FormControl
|
||||||
v-model="liveClass.auto_recording"
|
v-model="liveClass.auto_recording"
|
||||||
type="select"
|
type="select"
|
||||||
@@ -107,7 +103,11 @@ const dayjs = inject('$dayjs')
|
|||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
batch: {
|
batch: {
|
||||||
type: String,
|
type: String,
|
||||||
default: null,
|
required: true,
|
||||||
|
},
|
||||||
|
zoomAccount: {
|
||||||
|
type: String,
|
||||||
|
required: true,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -159,6 +159,7 @@ const createLiveClass = createResource({
|
|||||||
return {
|
return {
|
||||||
doctype: 'LMS Live Class',
|
doctype: 'LMS Live Class',
|
||||||
batch_name: values.batch,
|
batch_name: values.batch,
|
||||||
|
zoom_account: props.zoomAccount,
|
||||||
...values,
|
...values,
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -167,6 +168,20 @@ const createLiveClass = createResource({
|
|||||||
const submitLiveClass = (close) => {
|
const submitLiveClass = (close) => {
|
||||||
return createLiveClass.submit(liveClass, {
|
return createLiveClass.submit(liveClass, {
|
||||||
validate() {
|
validate() {
|
||||||
|
validateFormFields()
|
||||||
|
},
|
||||||
|
onSuccess() {
|
||||||
|
liveClasses.value.reload()
|
||||||
|
refreshForm()
|
||||||
|
close()
|
||||||
|
},
|
||||||
|
onError(err) {
|
||||||
|
toast.error(err.messages?.[0] || err)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const validateFormFields = () => {
|
||||||
if (!liveClass.title) {
|
if (!liveClass.title) {
|
||||||
return __('Please enter a title.')
|
return __('Please enter a title.')
|
||||||
}
|
}
|
||||||
@@ -197,15 +212,6 @@ const submitLiveClass = (close) => {
|
|||||||
if (!liveClass.duration) {
|
if (!liveClass.duration) {
|
||||||
return __('Please select a duration.')
|
return __('Please select a duration.')
|
||||||
}
|
}
|
||||||
},
|
|
||||||
onSuccess() {
|
|
||||||
liveClasses.value.reload()
|
|
||||||
close()
|
|
||||||
},
|
|
||||||
onError(err) {
|
|
||||||
toast.error(err.messages?.[0] || err)
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const valideTime = () => {
|
const valideTime = () => {
|
||||||
@@ -221,4 +227,14 @@ const valideTime = () => {
|
|||||||
}
|
}
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const refreshForm = () => {
|
||||||
|
liveClass.title = ''
|
||||||
|
liveClass.description = ''
|
||||||
|
liveClass.date = ''
|
||||||
|
liveClass.time = ''
|
||||||
|
liveClass.duration = ''
|
||||||
|
liveClass.timezone = getUserTimezone()
|
||||||
|
liveClass.auto_recording = 'No Recording'
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
<Dialog
|
<Dialog
|
||||||
v-model="show"
|
v-model="show"
|
||||||
:options="{
|
:options="{
|
||||||
size: '3xl',
|
size: '5xl',
|
||||||
}"
|
}"
|
||||||
>
|
>
|
||||||
<template #body>
|
<template #body>
|
||||||
@@ -21,7 +21,7 @@
|
|||||||
class="!p-0"
|
class="!p-0"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="!chooseFromExisting || editMode" class="space-y-2">
|
<div v-if="!chooseFromExisting || editMode">
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-xs text-ink-gray-5 mb-1">
|
<label class="block text-xs text-ink-gray-5 mb-1">
|
||||||
{{ __('Question') }}
|
{{ __('Question') }}
|
||||||
@@ -34,7 +34,7 @@
|
|||||||
editorClass="prose-sm max-w-none border-b border-x bg-surface-gray-2 rounded-b-md py-1 px-2 min-h-[7rem]"
|
editorClass="prose-sm max-w-none border-b border-x bg-surface-gray-2 rounded-b-md py-1 px-2 min-h-[7rem]"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div class="grid grid-cols-2 gap-4">
|
<div class="grid grid-cols-2 gap-8 mt-4">
|
||||||
<FormControl
|
<FormControl
|
||||||
v-model="question.marks"
|
v-model="question.marks"
|
||||||
:label="__('Marks')"
|
:label="__('Marks')"
|
||||||
@@ -51,7 +51,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
v-if="question.type == 'Choices'"
|
v-if="question.type == 'Choices'"
|
||||||
class="text-base font-semibold text-ink-gray-9 mb-5 mt-5"
|
class="text-base font-semibold text-ink-gray-9 mb-5 mt-10"
|
||||||
>
|
>
|
||||||
{{ __('Options') }}
|
{{ __('Options') }}
|
||||||
</div>
|
</div>
|
||||||
@@ -61,7 +61,10 @@
|
|||||||
>
|
>
|
||||||
{{ __('Possibilities') }}
|
{{ __('Possibilities') }}
|
||||||
</div>
|
</div>
|
||||||
<div v-if="question.type == 'Choices'" class="grid grid-cols-2 gap-4">
|
<div
|
||||||
|
v-if="question.type == 'Choices'"
|
||||||
|
class="grid grid-cols-2 gap-x-8 gap-y-4"
|
||||||
|
>
|
||||||
<div v-for="n in 4" class="space-y-4 py-2">
|
<div v-for="n in 4" class="space-y-4 py-2">
|
||||||
<FormControl
|
<FormControl
|
||||||
:label="__('Option') + ' ' + n"
|
:label="__('Option') + ' ' + n"
|
||||||
@@ -81,7 +84,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
v-else-if="question.type == 'User Input'"
|
v-else-if="question.type == 'User Input'"
|
||||||
class="grid grid-cols-2 gap-4 py-2"
|
class="grid grid-cols-2 gap-x-8 gap-y-4 py-2"
|
||||||
>
|
>
|
||||||
<div v-for="n in 4">
|
<div v-for="n in 4">
|
||||||
<FormControl
|
<FormControl
|
||||||
@@ -106,7 +109,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="flex items-center justify-end space-x-2 mt-5">
|
<div class="flex items-center justify-end space-x-2 mt-5">
|
||||||
<Button variant="solid" @click="submitQuestion()">
|
<Button variant="solid" @click="submitQuestion()">
|
||||||
{{ __('Submit') }}
|
{{ __('Save') }}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -217,7 +220,7 @@ const questionRow = createResource({
|
|||||||
return {
|
return {
|
||||||
doc: {
|
doc: {
|
||||||
doctype: 'LMS Quiz Question',
|
doctype: 'LMS Quiz Question',
|
||||||
parent: quiz.value.data.name,
|
parent: quiz.value.doc.name,
|
||||||
parentfield: 'questions',
|
parentfield: 'questions',
|
||||||
parenttype: 'LMS Quiz',
|
parenttype: 'LMS Quiz',
|
||||||
...values,
|
...values,
|
||||||
|
|||||||
225
frontend/src/components/Modals/QuizInVideo.vue
Normal file
225
frontend/src/components/Modals/QuizInVideo.vue
Normal file
@@ -0,0 +1,225 @@
|
|||||||
|
<template>
|
||||||
|
<Dialog
|
||||||
|
v-model="show"
|
||||||
|
:options="{
|
||||||
|
title: __('Add quiz to this video'),
|
||||||
|
size: '2xl',
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
<template #body-content>
|
||||||
|
<div class="text-base">
|
||||||
|
<div class="flex items-end gap-4">
|
||||||
|
<FormControl
|
||||||
|
:label="__('Time in Video')"
|
||||||
|
v-model="quiz.time"
|
||||||
|
type="text"
|
||||||
|
placeholder="2:15"
|
||||||
|
class="flex-1"
|
||||||
|
/>
|
||||||
|
<Link
|
||||||
|
v-model="quiz.quiz"
|
||||||
|
:label="__('Quiz')"
|
||||||
|
doctype="LMS Quiz"
|
||||||
|
class="flex-1"
|
||||||
|
/>
|
||||||
|
<Button @click="addQuiz()" variant="solid">
|
||||||
|
<template #prefix>
|
||||||
|
<Plus class="w-4 h-4 stroke-1.5" />
|
||||||
|
</template>
|
||||||
|
{{ __('Add') }}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-10 mb-5">
|
||||||
|
<div class="font-medium mb-4">
|
||||||
|
{{ __('Quizzes in this video') }}
|
||||||
|
</div>
|
||||||
|
<ListView
|
||||||
|
v-if="allQuizzes.length"
|
||||||
|
:columns="columns"
|
||||||
|
:rows="allQuizzes"
|
||||||
|
row-key="quiz"
|
||||||
|
:options="{
|
||||||
|
showTooltip: false,
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
<ListHeader
|
||||||
|
class="mb-2 grid items-center space-x-4 rounded bg-surface-gray-2 p-2"
|
||||||
|
>
|
||||||
|
<ListHeaderItem :item="item" v-for="item in columns">
|
||||||
|
<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 allQuizzes">
|
||||||
|
<template #default="{ column, item }">
|
||||||
|
<ListRowItem
|
||||||
|
:item="row[column.key as keyof Quiz]"
|
||||||
|
:align="column.align"
|
||||||
|
>
|
||||||
|
<div v-if="column.key == 'time'" class="leading-5 text-sm">
|
||||||
|
{{ formatTimestamp(row[column.key as keyof Quiz]) }}
|
||||||
|
</div>
|
||||||
|
<div v-else class="leading-5 text-sm">
|
||||||
|
{{ row[column.key as keyof Quiz] }}
|
||||||
|
</div>
|
||||||
|
</ListRowItem>
|
||||||
|
</template>
|
||||||
|
</ListRow>
|
||||||
|
</ListRows>
|
||||||
|
|
||||||
|
<ListSelectBanner>
|
||||||
|
<template #actions="{ unselectAll, selections }">
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
@click="removeQuiz(selections, unselectAll)"
|
||||||
|
>
|
||||||
|
<Trash2 class="h-4 w-4 stroke-1.5" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</ListSelectBanner>
|
||||||
|
</ListView>
|
||||||
|
|
||||||
|
<div v-else class="text-ink-gray-5 italic text-xs">
|
||||||
|
{{ __('No quizzes added yet.') }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</Dialog>
|
||||||
|
</template>
|
||||||
|
<script setup lang="ts">
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
Button,
|
||||||
|
FormControl,
|
||||||
|
ListView,
|
||||||
|
ListHeader,
|
||||||
|
ListHeaderItem,
|
||||||
|
ListRows,
|
||||||
|
ListRow,
|
||||||
|
ListRowItem,
|
||||||
|
ListSelectBanner,
|
||||||
|
toast,
|
||||||
|
} from 'frappe-ui'
|
||||||
|
import { computed, reactive, ref, watch } from 'vue'
|
||||||
|
import { Plus, Trash2 } from 'lucide-vue-next'
|
||||||
|
import { formatTimestamp } from '@/utils'
|
||||||
|
import Link from '@/components/Controls/Link.vue'
|
||||||
|
|
||||||
|
type Quiz = {
|
||||||
|
time: string
|
||||||
|
quiz: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const show = defineModel()
|
||||||
|
const allQuizzes = ref<Quiz[]>([])
|
||||||
|
const quiz = reactive<Quiz>({
|
||||||
|
time: '',
|
||||||
|
quiz: '',
|
||||||
|
})
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
quizzes: {
|
||||||
|
type: Array as () => Quiz[],
|
||||||
|
default: () => [],
|
||||||
|
},
|
||||||
|
saveQuizzes: {
|
||||||
|
type: Function,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
duration: {
|
||||||
|
type: Number,
|
||||||
|
default: 0,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const addQuiz = () => {
|
||||||
|
quiz.time = `${getTimeInSeconds()}`
|
||||||
|
if (!isTimeValid() || !isFormComplete()) return
|
||||||
|
|
||||||
|
allQuizzes.value.push({
|
||||||
|
time: quiz.time,
|
||||||
|
quiz: quiz.quiz,
|
||||||
|
})
|
||||||
|
|
||||||
|
props.saveQuizzes(allQuizzes.value)
|
||||||
|
|
||||||
|
quiz.time = ''
|
||||||
|
quiz.quiz = ''
|
||||||
|
}
|
||||||
|
|
||||||
|
const getTimeInSeconds = () => {
|
||||||
|
if (quiz.time && !quiz.time.includes(':')) {
|
||||||
|
quiz.time = `${quiz.time}:00`
|
||||||
|
}
|
||||||
|
const timeParts = quiz.time.split(':')
|
||||||
|
const timeInSeconds = parseInt(timeParts[0]) * 60 + parseInt(timeParts[1])
|
||||||
|
|
||||||
|
return timeInSeconds
|
||||||
|
}
|
||||||
|
|
||||||
|
const isTimeValid = () => {
|
||||||
|
if (parseInt(quiz.time) > props.duration) {
|
||||||
|
toast.error(__('Time in video exceeds the total duration of the video.'))
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
const isFormComplete = () => {
|
||||||
|
if (!quiz.time) {
|
||||||
|
toast.error(__('Please enter a valid timestamp'))
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!quiz.quiz) {
|
||||||
|
toast.error(__('Please select a quiz'))
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
const removeQuiz = (selections: string, unselectAll: () => void) => {
|
||||||
|
Array.from(selections).forEach((selection) => {
|
||||||
|
const index = allQuizzes.value.findIndex((q) => q.quiz === selection)
|
||||||
|
if (index !== -1) {
|
||||||
|
allQuizzes.value.splice(index, 1)
|
||||||
|
}
|
||||||
|
unselectAll()
|
||||||
|
})
|
||||||
|
props.saveQuizzes(allQuizzes.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.quizzes,
|
||||||
|
(newQuizzes) => {
|
||||||
|
allQuizzes.value = newQuizzes
|
||||||
|
},
|
||||||
|
{ immediate: true }
|
||||||
|
)
|
||||||
|
|
||||||
|
const columns = computed(() => {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
key: 'quiz',
|
||||||
|
label: __('Quiz'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'time',
|
||||||
|
label: __('Time in Video (minutes)'),
|
||||||
|
align: 'center',
|
||||||
|
},
|
||||||
|
]
|
||||||
|
})
|
||||||
|
</script>
|
||||||
252
frontend/src/components/Modals/VideoStatistics.vue
Normal file
252
frontend/src/components/Modals/VideoStatistics.vue
Normal file
@@ -0,0 +1,252 @@
|
|||||||
|
<template>
|
||||||
|
<Dialog
|
||||||
|
v-model="show"
|
||||||
|
:options="{
|
||||||
|
size: '4xl',
|
||||||
|
title: __('Video Statistics for {0}').format(lessonTitle),
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
<template #body-content>
|
||||||
|
<div class="text-base">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<TabButtons
|
||||||
|
v-if="tabs.length > 1"
|
||||||
|
:buttons="tabs"
|
||||||
|
v-model="currentTab"
|
||||||
|
class="w-fit"
|
||||||
|
/>
|
||||||
|
<!-- <FormControl
|
||||||
|
v-model="searchText"
|
||||||
|
:placeholder="__('Search by Member')"
|
||||||
|
class="mt-2 mr-5 w-[25%]"
|
||||||
|
/> -->
|
||||||
|
</div>
|
||||||
|
<div v-if="currentTab" class="mt-4">
|
||||||
|
<div class="grid grid-cols-[55%,40%] gap-5">
|
||||||
|
<div
|
||||||
|
class="space-y-5 border rounded-md p-2 pt-4 h-[70vh] overflow-y-auto"
|
||||||
|
>
|
||||||
|
<div class="grid grid-cols-[70%,30%] text-sm text-ink-gray-5">
|
||||||
|
<div class="px-4">
|
||||||
|
{{ __('Member') }}
|
||||||
|
</div>
|
||||||
|
<div class="text-center">
|
||||||
|
{{ __('Watch Time') }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-for="row in currentTabData"
|
||||||
|
class="hover:bg-surface-gray-1 cursor-pointer rounded-md py-1 px-2"
|
||||||
|
>
|
||||||
|
<router-link
|
||||||
|
:to="{
|
||||||
|
name: 'Profile',
|
||||||
|
params: { username: row.member_username },
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
<div class="grid grid-cols-[70%,30%] items-center">
|
||||||
|
<div class="flex items-center space-x-2">
|
||||||
|
<Avatar
|
||||||
|
:image="row.member_image"
|
||||||
|
:label="row.member_name"
|
||||||
|
size="xl"
|
||||||
|
/>
|
||||||
|
<div class="space-y-1">
|
||||||
|
<div class="font-medium">
|
||||||
|
{{ row.member_name }}
|
||||||
|
</div>
|
||||||
|
<div class="text-sm text-ink-gray-6">
|
||||||
|
{{ row.member }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="text-center text-sm">
|
||||||
|
{{ formatTimestamp(row.watch_time) }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</router-link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="space-y-5">
|
||||||
|
<NumberChart
|
||||||
|
class="border rounded-md"
|
||||||
|
:config="{
|
||||||
|
title: __('Average Watch Time'),
|
||||||
|
value: averageWatchTime,
|
||||||
|
}"
|
||||||
|
/>
|
||||||
|
<div v-if="isPlyrSource">
|
||||||
|
<div class="video-player" :src="currentTab"></div>
|
||||||
|
</div>
|
||||||
|
<VideoBlock v-else :file="currentTab" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-else class="text-sm text-ink-gray-5">
|
||||||
|
{{ __('No statistics available for this video.') }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</Dialog>
|
||||||
|
</template>
|
||||||
|
<script setup lang="ts">
|
||||||
|
import {
|
||||||
|
Avatar,
|
||||||
|
createListResource,
|
||||||
|
Dialog,
|
||||||
|
FormControl,
|
||||||
|
NumberChart,
|
||||||
|
TabButtons,
|
||||||
|
} from 'frappe-ui'
|
||||||
|
import { computed, ref, watch } from 'vue'
|
||||||
|
import { enablePlyr, formatTimestamp } from '@/utils'
|
||||||
|
import VideoBlock from '@/components/VideoBlock.vue'
|
||||||
|
|
||||||
|
const show = defineModel<boolean | undefined>()
|
||||||
|
const currentTab = ref<string>('')
|
||||||
|
const searchText = ref<string>('')
|
||||||
|
type Filters = {
|
||||||
|
lesson: string | undefined
|
||||||
|
member_name?: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
lessonName?: string
|
||||||
|
lessonTitle?: string
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const statistics = createListResource({
|
||||||
|
doctype: 'LMS Video Watch Duration',
|
||||||
|
filters: {
|
||||||
|
lesson: props.lessonName,
|
||||||
|
},
|
||||||
|
fields: [
|
||||||
|
'name',
|
||||||
|
'member',
|
||||||
|
'member_name',
|
||||||
|
'member_image',
|
||||||
|
'member_username',
|
||||||
|
'source',
|
||||||
|
'watch_time',
|
||||||
|
],
|
||||||
|
cache: ['videoStatistics', props.lessonName],
|
||||||
|
onSuccess() {
|
||||||
|
currentTab.value = Object.keys(statisticsData.value)[0]
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.lessonName,
|
||||||
|
() => {
|
||||||
|
if (props.lessonName) {
|
||||||
|
statistics.filters.lesson = props.lessonName
|
||||||
|
statistics.reload()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
watch(searchText, () => {
|
||||||
|
let filterApplied = false
|
||||||
|
let filters: Filters = {
|
||||||
|
lesson: props.lessonName,
|
||||||
|
}
|
||||||
|
|
||||||
|
if (searchText.value) {
|
||||||
|
filters.member_name = ['like', `%${searchText.value}%`]
|
||||||
|
filterApplied = true
|
||||||
|
}
|
||||||
|
|
||||||
|
statistics.update({
|
||||||
|
filters: filters,
|
||||||
|
})
|
||||||
|
|
||||||
|
statistics.reload({})
|
||||||
|
})
|
||||||
|
|
||||||
|
watch(show, () => {
|
||||||
|
if (show.value) {
|
||||||
|
enablePlyr()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const statisticsData = computed(() => {
|
||||||
|
const grouped = <Record<string, any[]>>{}
|
||||||
|
statistics.data.forEach((item: { source: string }) => {
|
||||||
|
if (!grouped[item.source]) {
|
||||||
|
grouped[item.source] = []
|
||||||
|
}
|
||||||
|
grouped[item.source].push(item)
|
||||||
|
})
|
||||||
|
return grouped
|
||||||
|
})
|
||||||
|
|
||||||
|
const averageWatchTime = computed(() => {
|
||||||
|
let totalWatchTime = 0
|
||||||
|
|
||||||
|
currentTabData.value.forEach((item: { watch_time: string }) => {
|
||||||
|
totalWatchTime += parseFloat(item.watch_time)
|
||||||
|
})
|
||||||
|
|
||||||
|
return formatTimestamp(totalWatchTime / currentTabData.value.length)
|
||||||
|
})
|
||||||
|
|
||||||
|
const currentTabData = computed(() => {
|
||||||
|
return statisticsData.value[currentTab.value] || []
|
||||||
|
})
|
||||||
|
|
||||||
|
const isPlyrSource = computed(() => {
|
||||||
|
return (
|
||||||
|
currentTab.value.includes('youtube') || currentTab.value.includes('vimeo')
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
const provider = computed(() => {
|
||||||
|
if (currentTab.value.includes('youtube')) {
|
||||||
|
return 'youtube'
|
||||||
|
} else if (currentTab.value.includes('vimeo')) {
|
||||||
|
return 'vimeo'
|
||||||
|
}
|
||||||
|
return ''
|
||||||
|
})
|
||||||
|
|
||||||
|
const embedURL = computed(() => {
|
||||||
|
if (isPlyrSource.value) {
|
||||||
|
return currentTab.value.replace('watch?v=', 'embed/')
|
||||||
|
}
|
||||||
|
return ''
|
||||||
|
})
|
||||||
|
|
||||||
|
const tabs = computed(() => {
|
||||||
|
return Object.keys(statisticsData.value).map((source, index) => ({
|
||||||
|
label: __(`Video ${index + 1}`),
|
||||||
|
value: source,
|
||||||
|
}))
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
<style>
|
||||||
|
.plyr__volume input[type='range'] {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.plyr__control--overlaid {
|
||||||
|
background: radial-gradient(
|
||||||
|
circle,
|
||||||
|
rgba(0, 0, 0, 0.4) 0%,
|
||||||
|
rgba(0, 0, 0, 0.5) 50%
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
.plyr__control:hover {
|
||||||
|
background: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.plyr--video {
|
||||||
|
border: 1px solid theme('colors.gray.200');
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
:root {
|
||||||
|
--plyr-range-fill-background: white;
|
||||||
|
--plyr-video-control-background-hover: transparent;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
213
frontend/src/components/Modals/ZoomAccountModal.vue
Normal file
213
frontend/src/components/Modals/ZoomAccountModal.vue
Normal file
@@ -0,0 +1,213 @@
|
|||||||
|
<template>
|
||||||
|
<Dialog
|
||||||
|
v-model="show"
|
||||||
|
:options="{
|
||||||
|
title:
|
||||||
|
accountID === 'new' ? __('New Zoom Account') : __('Edit Zoom Account'),
|
||||||
|
size: 'xl',
|
||||||
|
actions: [
|
||||||
|
{
|
||||||
|
label: __('Save'),
|
||||||
|
variant: 'solid',
|
||||||
|
onClick: ({ close }) => {
|
||||||
|
saveAccount(close)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
<template #body-content>
|
||||||
|
<div class="mb-4">
|
||||||
|
<FormControl
|
||||||
|
v-model="account.enabled"
|
||||||
|
:label="__('Enabled')"
|
||||||
|
type="checkbox"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="grid grid-cols-2 gap-5">
|
||||||
|
<FormControl
|
||||||
|
v-model="account.name"
|
||||||
|
:label="__('Account Name')"
|
||||||
|
type="text"
|
||||||
|
:required="true"
|
||||||
|
/>
|
||||||
|
<FormControl
|
||||||
|
v-model="account.client_id"
|
||||||
|
:label="__('Client ID')"
|
||||||
|
type="text"
|
||||||
|
:required="true"
|
||||||
|
/>
|
||||||
|
<Link
|
||||||
|
v-model="account.member"
|
||||||
|
:label="__('Member')"
|
||||||
|
doctype="Course Evaluator"
|
||||||
|
:onCreate="(value: string, close: () => void) => openSettings('Members', close)"
|
||||||
|
:required="true"
|
||||||
|
/>
|
||||||
|
<FormControl
|
||||||
|
v-model="account.client_secret"
|
||||||
|
:label="__('Client Secret')"
|
||||||
|
type="password"
|
||||||
|
:required="true"
|
||||||
|
/>
|
||||||
|
<FormControl
|
||||||
|
v-model="account.account_id"
|
||||||
|
:label="__('Account ID')"
|
||||||
|
type="text"
|
||||||
|
:required="true"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</Dialog>
|
||||||
|
</template>
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { call, Dialog, FormControl, toast } from 'frappe-ui'
|
||||||
|
import { inject, reactive, watch } from 'vue'
|
||||||
|
import { User } from '@/components/Settings/types'
|
||||||
|
import { openSettings, cleanError } from '@/utils'
|
||||||
|
import Link from '@/components/Controls/Link.vue'
|
||||||
|
|
||||||
|
interface ZoomAccount {
|
||||||
|
name: string
|
||||||
|
account_name: string
|
||||||
|
enabled: boolean
|
||||||
|
member: string
|
||||||
|
account_id: string
|
||||||
|
client_id: string
|
||||||
|
client_secret: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ZoomAccounts {
|
||||||
|
data: ZoomAccount[]
|
||||||
|
reload: () => void
|
||||||
|
insert: {
|
||||||
|
submit: (
|
||||||
|
data: ZoomAccount,
|
||||||
|
options: { onSuccess: () => void; onError: (err: any) => void }
|
||||||
|
) => void
|
||||||
|
}
|
||||||
|
setValue: {
|
||||||
|
submit: (
|
||||||
|
data: ZoomAccount,
|
||||||
|
options: { onSuccess: () => void; onError: (err: any) => void }
|
||||||
|
) => void
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const show = defineModel('show')
|
||||||
|
const user = inject<User | null>('$user')
|
||||||
|
const zoomAccounts = defineModel<ZoomAccounts>('zoomAccounts')
|
||||||
|
|
||||||
|
const account = reactive({
|
||||||
|
name: '',
|
||||||
|
enabled: false,
|
||||||
|
member: user?.data?.name || '',
|
||||||
|
account_id: '',
|
||||||
|
client_id: '',
|
||||||
|
client_secret: '',
|
||||||
|
})
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
accountID: {
|
||||||
|
type: String,
|
||||||
|
default: 'new',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.accountID,
|
||||||
|
(val) => {
|
||||||
|
if (val != 'new') {
|
||||||
|
zoomAccounts.value?.data.forEach((acc) => {
|
||||||
|
if (acc.name === val) {
|
||||||
|
account.name = acc.name
|
||||||
|
account.enabled = acc.enabled || false
|
||||||
|
account.member = acc.member
|
||||||
|
account.account_id = acc.account_id
|
||||||
|
account.client_id = acc.client_id
|
||||||
|
account.client_secret = acc.client_secret
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
watch(show, (val) => {
|
||||||
|
if (!val) {
|
||||||
|
account.name = ''
|
||||||
|
account.enabled = false
|
||||||
|
account.member = user?.data?.name || ''
|
||||||
|
account.account_id = ''
|
||||||
|
account.client_id = ''
|
||||||
|
account.client_secret = ''
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const saveAccount = (close: () => void) => {
|
||||||
|
if (props.accountID == 'new') {
|
||||||
|
createAccount(close)
|
||||||
|
} else {
|
||||||
|
updateAccount(close)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const createAccount = (close: () => void) => {
|
||||||
|
zoomAccounts.value?.insert.submit(
|
||||||
|
{
|
||||||
|
account_name: account.name,
|
||||||
|
...account,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
onSuccess() {
|
||||||
|
zoomAccounts.value?.reload()
|
||||||
|
close()
|
||||||
|
toast.success(__('Zoom Account created successfully'))
|
||||||
|
},
|
||||||
|
onError(err) {
|
||||||
|
close()
|
||||||
|
toast.error(
|
||||||
|
cleanError(err.messages[0]) || __('Error creating Zoom Account')
|
||||||
|
)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateAccount = async (close: () => void) => {
|
||||||
|
if (props.accountID != account.name) {
|
||||||
|
await renameDoc()
|
||||||
|
}
|
||||||
|
setValue(close)
|
||||||
|
}
|
||||||
|
|
||||||
|
const renameDoc = async () => {
|
||||||
|
await call('frappe.client.rename_doc', {
|
||||||
|
doctype: 'LMS Zoom Settings',
|
||||||
|
old_name: props.accountID,
|
||||||
|
new_name: account.name,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const setValue = (close: () => void) => {
|
||||||
|
zoomAccounts.value?.setValue.submit(
|
||||||
|
{
|
||||||
|
...account,
|
||||||
|
name: account.name,
|
||||||
|
account_name: props.accountID,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
onSuccess() {
|
||||||
|
zoomAccounts.value?.reload()
|
||||||
|
close()
|
||||||
|
toast.success(__('Zoom Account updated successfully'))
|
||||||
|
},
|
||||||
|
onError(err: any) {
|
||||||
|
close()
|
||||||
|
toast.error(
|
||||||
|
cleanError(err.messages[0]) || __('Error updating Zoom Account')
|
||||||
|
)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
</script>
|
||||||
241
frontend/src/components/Notes/InlineLessonMenu.vue
Normal file
241
frontend/src/components/Notes/InlineLessonMenu.vue
Normal file
@@ -0,0 +1,241 @@
|
|||||||
|
<template>
|
||||||
|
<div
|
||||||
|
class="text-sm absolute bg-white border rounded-md z-10 w-44"
|
||||||
|
:style="{
|
||||||
|
display: top > 0 ? 'block' : 'none',
|
||||||
|
top: top + 'px',
|
||||||
|
left: left + 'px',
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
<div class="space-y-2 py-2">
|
||||||
|
<div class="text-xs text-ink-gray-5 font-medium px-3">
|
||||||
|
{{ __('Highlight') }}
|
||||||
|
</div>
|
||||||
|
<div class="">
|
||||||
|
<div
|
||||||
|
v-for="color in colors"
|
||||||
|
class="flex items-center space-x-2 px-3 py-2 cursor-pointer hover:bg-surface-gray-2"
|
||||||
|
@click="saveHighLight(color)"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
class="size-3 rounded-full"
|
||||||
|
:style="{
|
||||||
|
backgroundColor: theme.backgroundColor[color.toLowerCase()][400],
|
||||||
|
}"
|
||||||
|
></span>
|
||||||
|
<span>
|
||||||
|
{{ __(color) }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="border-t">
|
||||||
|
<div
|
||||||
|
@click="addToNotes()"
|
||||||
|
class="flex items-center space-x-2 hover:bg-surface-gray-2 cursor-pointer rounded-b-md py-2 px-3"
|
||||||
|
>
|
||||||
|
<NotepadText class="size-3 stroke-1.5" />
|
||||||
|
<span>
|
||||||
|
{{ __('Add to Notes') }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-if="highlightExists()"
|
||||||
|
@click="deleteHighlight"
|
||||||
|
class="flex items-center space-x-2 hover:bg-surface-gray-2 cursor-pointer rounded-b-md py-2 px-3"
|
||||||
|
>
|
||||||
|
<Trash2 class="size-3 stroke-1.5" />
|
||||||
|
<span>
|
||||||
|
{{ __('Remove Highlight') }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, inject, ref, watch } from 'vue'
|
||||||
|
import { NotepadText, Trash2 } from 'lucide-vue-next'
|
||||||
|
import { theme } from '@/utils/theme'
|
||||||
|
import type { Note, Notes } from '@/components/Notes/types'
|
||||||
|
import { blockQuotesClick, highlightText } from '@/utils'
|
||||||
|
|
||||||
|
const user = inject<any>('$user')
|
||||||
|
const show = defineModel()
|
||||||
|
const notes = defineModel<Notes>('notes')
|
||||||
|
const top = ref(0)
|
||||||
|
const left = ref(0)
|
||||||
|
const currentSelection = ref<Selection | null>(null)
|
||||||
|
const selectedText = ref('')
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(e: 'updateNotes'): void
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
lesson: string
|
||||||
|
}>()
|
||||||
|
|
||||||
|
watch(show, () => {
|
||||||
|
if (!show.value) {
|
||||||
|
return resetMenuPosition()
|
||||||
|
}
|
||||||
|
|
||||||
|
currentSelection.value = window.getSelection()
|
||||||
|
if (!currentSelection.value?.toString()) {
|
||||||
|
return resetMenuPosition()
|
||||||
|
}
|
||||||
|
|
||||||
|
updateMenuPosition()
|
||||||
|
})
|
||||||
|
|
||||||
|
const updateMenuPosition = () => {
|
||||||
|
selectedText.value = currentSelection.value?.toString() || ''
|
||||||
|
const range = currentSelection.value?.getRangeAt(0)
|
||||||
|
const rect = range?.getBoundingClientRect()
|
||||||
|
if (!rect) return
|
||||||
|
|
||||||
|
const offsetY = window.scrollY
|
||||||
|
const offsetX = window.scrollX
|
||||||
|
|
||||||
|
top.value = Math.floor(rect.top + offsetY - 40)
|
||||||
|
left.value = Math.floor(rect.right + offsetX + 10)
|
||||||
|
}
|
||||||
|
|
||||||
|
const resetMenuPosition = () => {
|
||||||
|
top.value = 0
|
||||||
|
left.value = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
const colors = computed(() => {
|
||||||
|
return ['Red', 'Blue', 'Green', 'Yellow', 'Purple']
|
||||||
|
})
|
||||||
|
|
||||||
|
const highlightExists = () => {
|
||||||
|
return notes.value?.data?.some(
|
||||||
|
(note: Note) => note.highlighted_text === selectedText.value
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const saveHighLight = (color: string) => {
|
||||||
|
if (!selectedText.value) return
|
||||||
|
|
||||||
|
notes.value?.insert.submit(
|
||||||
|
{
|
||||||
|
lesson: props.lesson,
|
||||||
|
member: user?.data?.name,
|
||||||
|
highlighted_text: selectedText.value,
|
||||||
|
color: color,
|
||||||
|
name: '',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
onSuccess(data: Note) {
|
||||||
|
highlightText(data)
|
||||||
|
resetStates()
|
||||||
|
emit('updateNotes')
|
||||||
|
},
|
||||||
|
onError(err: any) {
|
||||||
|
console.error('Error saving highlight:', err)
|
||||||
|
resetStates()
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const deleteHighlight = () => {
|
||||||
|
let notesToDelete = notes.value?.data.find(
|
||||||
|
(note: Note) => note.highlighted_text === selectedText.value
|
||||||
|
)
|
||||||
|
if (!notesToDelete) return
|
||||||
|
notes.value?.delete.submit(notesToDelete.name, {
|
||||||
|
onSuccess() {
|
||||||
|
resetStates()
|
||||||
|
document.querySelectorAll('.highlighted-text').forEach((el) => {
|
||||||
|
const element = el as HTMLElement
|
||||||
|
if (element.dataset.name === notesToDelete.name) {
|
||||||
|
element.style.backgroundColor = 'transparent'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
},
|
||||||
|
onError(err: any) {
|
||||||
|
console.error('Error deleting highlight:', err)
|
||||||
|
resetStates()
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const addToNotes = () => {
|
||||||
|
if (!selectedText.value) return
|
||||||
|
let noteToUpdate = notes.value?.data.find((note: Note) => {
|
||||||
|
return !note.highlighted_text && note.note !== ''
|
||||||
|
})
|
||||||
|
if (!noteToUpdate) {
|
||||||
|
createNote()
|
||||||
|
} else {
|
||||||
|
updateNote(noteToUpdate)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const createNote = () => {
|
||||||
|
notes.value?.insert.submit(
|
||||||
|
{
|
||||||
|
lesson: props.lesson,
|
||||||
|
member: user?.data?.name,
|
||||||
|
note: `<blockquote><p>${selectedText.value}</p></blockquote><br>`,
|
||||||
|
color: 'Yellow',
|
||||||
|
name: '',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
onSuccess(data: Note) {
|
||||||
|
emit('updateNotes')
|
||||||
|
setTimeout(() => {
|
||||||
|
scrollToText(selectedText.value)
|
||||||
|
blockQuotesClick()
|
||||||
|
resetStates()
|
||||||
|
}, 100)
|
||||||
|
},
|
||||||
|
onError(err: any) {
|
||||||
|
console.error('Error creating note:', err)
|
||||||
|
resetStates()
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateNote = (noteToUpdate: Note) => {
|
||||||
|
notes.value?.setValue.submit(
|
||||||
|
{
|
||||||
|
name: noteToUpdate.name,
|
||||||
|
note: `${noteToUpdate.note}\n\n<blockquote><p>${selectedText.value}</p></blockquote><br>`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
onSuccess(data: Note) {
|
||||||
|
emit('updateNotes')
|
||||||
|
setTimeout(() => {
|
||||||
|
scrollToText(selectedText.value)
|
||||||
|
blockQuotesClick()
|
||||||
|
resetStates()
|
||||||
|
}, 100)
|
||||||
|
},
|
||||||
|
onError(err: any) {
|
||||||
|
console.error('Error updating note:', err)
|
||||||
|
resetStates()
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const scrollToText = (text: string) => {
|
||||||
|
const elements = document.querySelectorAll('blockquote p')
|
||||||
|
Array.from(elements).forEach((el) => {
|
||||||
|
const element = el as HTMLElement
|
||||||
|
if (element.textContent?.toLowerCase().includes(text.toLowerCase())) {
|
||||||
|
element.scrollIntoView({ behavior: 'smooth', block: 'center' })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const resetStates = () => {
|
||||||
|
selectedText.value = ''
|
||||||
|
show.value = false
|
||||||
|
resetMenuPosition()
|
||||||
|
}
|
||||||
|
</script>
|
||||||
115
frontend/src/components/Notes/Notes.vue
Normal file
115
frontend/src/components/Notes/Notes.vue
Normal file
@@ -0,0 +1,115 @@
|
|||||||
|
<template>
|
||||||
|
<div class="text-lg font-semibold mb-4">
|
||||||
|
{{ __('My Notes') }}
|
||||||
|
</div>
|
||||||
|
<TextEditor
|
||||||
|
:content="note"
|
||||||
|
:placeholder="__('Make notes for quick revision. Press / for menu.')"
|
||||||
|
@change="(val: string) => updateNoteText(val)"
|
||||||
|
:editable="true"
|
||||||
|
editorClass="prose prose-sm min-h-[200px] max-w-none"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { TextEditor } from 'frappe-ui'
|
||||||
|
import { useDebounceFn } from '@vueuse/core'
|
||||||
|
import { inject, ref, onMounted, watch } from 'vue'
|
||||||
|
import type { Note, Notes } from '@/components/Notes/types'
|
||||||
|
import { blockQuotesClick } from '@/utils/'
|
||||||
|
|
||||||
|
const note = ref<string | null>(null)
|
||||||
|
const currentNoteName = ref<string | null>(null)
|
||||||
|
const user = inject<any>('$user')
|
||||||
|
const notes = defineModel<Notes>('notes')
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(e: 'updateNotes'): void
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
lesson: string
|
||||||
|
}>()
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
updateCurrentNote()
|
||||||
|
})
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => notes.value?.data,
|
||||||
|
() => {
|
||||||
|
updateCurrentNote()
|
||||||
|
blockQuotesClick()
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
const updateCurrentNote = () => {
|
||||||
|
const currentNote = notes.value?.data?.filter((row: Note) => {
|
||||||
|
return !row.highlighted_text && row.note !== ''
|
||||||
|
})
|
||||||
|
if (currentNote?.length === 0) {
|
||||||
|
note.value = null
|
||||||
|
currentNoteName.value = null
|
||||||
|
return
|
||||||
|
} else if (currentNote && currentNote.length > 0) {
|
||||||
|
currentNoteName.value = currentNote[0].name
|
||||||
|
note.value = currentNote[0].note || null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateNoteText = (val: string) => {
|
||||||
|
note.value = val
|
||||||
|
debouncedSave()
|
||||||
|
}
|
||||||
|
|
||||||
|
const debouncedSave = useDebounceFn(() => {
|
||||||
|
saveNotes()
|
||||||
|
}, 2000)
|
||||||
|
|
||||||
|
const saveNotes = () => {
|
||||||
|
if (currentNoteName.value) {
|
||||||
|
updateNote()
|
||||||
|
} else {
|
||||||
|
createNote()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const createNote = () => {
|
||||||
|
notes.value?.insert.submit(
|
||||||
|
{
|
||||||
|
lesson: props.lesson,
|
||||||
|
member: user?.data?.name,
|
||||||
|
note: note.value,
|
||||||
|
color: 'Yellow',
|
||||||
|
name: '',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
onSuccess(data: Note) {
|
||||||
|
currentNoteName.value = data.name || null
|
||||||
|
emit('updateNotes')
|
||||||
|
},
|
||||||
|
onError(err: any) {
|
||||||
|
console.error('Error creating note:', err)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateNote = () => {
|
||||||
|
if (!currentNoteName.value) return
|
||||||
|
notes.value?.setValue.submit(
|
||||||
|
{
|
||||||
|
name: currentNoteName.value,
|
||||||
|
lesson: props.lesson,
|
||||||
|
member: user?.data?.name,
|
||||||
|
note: note.value,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
onSuccess(data: Note) {
|
||||||
|
emit('updateNotes')
|
||||||
|
},
|
||||||
|
onError(err: any) {
|
||||||
|
console.error('Error updating note:', err)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
</script>
|
||||||
32
frontend/src/components/Notes/types.ts
Normal file
32
frontend/src/components/Notes/types.ts
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
|
||||||
|
export type Note = {
|
||||||
|
highlighted_text?: string
|
||||||
|
color?: string
|
||||||
|
name: string
|
||||||
|
note?: string | null
|
||||||
|
lesson?: string
|
||||||
|
member?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type Notes = {
|
||||||
|
data: Note[]
|
||||||
|
reload: () => void
|
||||||
|
insert: {
|
||||||
|
submit: (
|
||||||
|
data: Note,
|
||||||
|
options: { onSuccess: (data: Note) => void; onError: (err: any) => void }
|
||||||
|
) => void
|
||||||
|
}
|
||||||
|
setValue: {
|
||||||
|
submit: (
|
||||||
|
data: Note,
|
||||||
|
options: { onSuccess: (data: Note) => void; onError: (err: any) => void }
|
||||||
|
) => void
|
||||||
|
},
|
||||||
|
delete: {
|
||||||
|
submit: (
|
||||||
|
data: Note | string,
|
||||||
|
options?: { onSuccess: () => void; onError: (err: any) => void }
|
||||||
|
) => void
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,8 +1,11 @@
|
|||||||
<template>
|
<template>
|
||||||
<div v-if="quiz.data">
|
<div v-if="quiz.data">
|
||||||
<div
|
<div
|
||||||
class="bg-surface-blue-2 space-y-1 py-2 px-2 mb-4 rounded-md text-sm text-ink-blue-3"
|
class="bg-surface-blue-2 space-y-2 py-2 px-3 mb-4 rounded-md text-sm text-ink-blue-2 leading-5"
|
||||||
>
|
>
|
||||||
|
<div v-if="inVideo">
|
||||||
|
{{ __('You will have to complete the quiz to continue the video') }}
|
||||||
|
</div>
|
||||||
<div class="leading-5">
|
<div class="leading-5">
|
||||||
{{
|
{{
|
||||||
__('This quiz consists of {0} questions.').format(questions.length)
|
__('This quiz consists of {0} questions.').format(questions.length)
|
||||||
@@ -38,6 +41,16 @@
|
|||||||
)
|
)
|
||||||
}}
|
}}
|
||||||
</div>
|
</div>
|
||||||
|
<div v-if="quiz.data.enable_negative_marking" class="leading-5">
|
||||||
|
{{
|
||||||
|
__(
|
||||||
|
'If you answer incorrectly, {0} {1} will be deducted from your score for each incorrect answer.'
|
||||||
|
).format(
|
||||||
|
quiz.data.marks_to_cut,
|
||||||
|
quiz.data.marks_to_cut == 1 ? 'mark' : 'marks'
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="quiz.data.duration" class="flex flex-col space-x-1 my-4">
|
<div v-if="quiz.data.duration" class="flex flex-col space-x-1 my-4">
|
||||||
@@ -55,19 +68,30 @@
|
|||||||
<div class="font-semibold text-lg text-ink-gray-9">
|
<div class="font-semibold text-lg text-ink-gray-9">
|
||||||
{{ quiz.data.title }}
|
{{ quiz.data.title }}
|
||||||
</div>
|
</div>
|
||||||
|
<div class="flex items-center justify-center space-x-2 mt-4">
|
||||||
<Button
|
<Button
|
||||||
v-if="
|
v-if="
|
||||||
!quiz.data.max_attempts ||
|
!quiz.data.max_attempts ||
|
||||||
attempts.data?.length < quiz.data.max_attempts
|
attempts.data?.length < quiz.data.max_attempts
|
||||||
"
|
"
|
||||||
|
variant="solid"
|
||||||
@click="startQuiz"
|
@click="startQuiz"
|
||||||
class="mt-2"
|
|
||||||
>
|
>
|
||||||
<span>
|
<span>
|
||||||
{{ __('Start') }}
|
{{ inVideo ? __('Start the Quiz') : __('Start') }}
|
||||||
</span>
|
</span>
|
||||||
</Button>
|
</Button>
|
||||||
<div v-else class="leading-5 text-ink-gray-7">
|
<Button v-if="inVideo" @click="props.backToVideo()">
|
||||||
|
{{ __('Resume Video') }}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-if="
|
||||||
|
quiz.data.max_attempts &&
|
||||||
|
attempts.data?.length >= quiz.data.max_attempts
|
||||||
|
"
|
||||||
|
class="leading-5 text-ink-gray-7"
|
||||||
|
>
|
||||||
{{
|
{{
|
||||||
__(
|
__(
|
||||||
'You have already exceeded the maximum number of attempts allowed for this quiz.'
|
'You have already exceeded the maximum number of attempts allowed for this quiz.'
|
||||||
@@ -247,6 +271,7 @@
|
|||||||
)
|
)
|
||||||
}}
|
}}
|
||||||
</div>
|
</div>
|
||||||
|
<div class="space-x-2">
|
||||||
<Button
|
<Button
|
||||||
@click="resetQuiz()"
|
@click="resetQuiz()"
|
||||||
class="mt-2"
|
class="mt-2"
|
||||||
@@ -259,6 +284,10 @@
|
|||||||
{{ __('Try Again') }}
|
{{ __('Try Again') }}
|
||||||
</span>
|
</span>
|
||||||
</Button>
|
</Button>
|
||||||
|
<Button v-if="inVideo" @click="props.backToVideo()">
|
||||||
|
{{ __('Resume Video') }}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
v-if="
|
v-if="
|
||||||
@@ -308,13 +337,20 @@ let questions = reactive([])
|
|||||||
const possibleAnswer = ref(null)
|
const possibleAnswer = ref(null)
|
||||||
const timer = ref(0)
|
const timer = ref(0)
|
||||||
let timerInterval = null
|
let timerInterval = null
|
||||||
const router = useRouter()
|
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
quizName: {
|
quizName: {
|
||||||
type: String,
|
type: String,
|
||||||
required: true,
|
required: true,
|
||||||
},
|
},
|
||||||
|
inVideo: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
|
backToVideo: {
|
||||||
|
type: Function,
|
||||||
|
default: () => {},
|
||||||
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
const quiz = createResource({
|
const quiz = createResource({
|
||||||
@@ -611,11 +647,17 @@ const getInstructions = (question) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const markLessonProgress = () => {
|
const markLessonProgress = () => {
|
||||||
if (router.currentRoute.value.name == 'Lesson') {
|
let pathname = window.location.pathname.split('/')
|
||||||
|
if (!pathname.includes('courses'))
|
||||||
|
pathname = window.parent.location.pathname.split('/')
|
||||||
|
if (pathname[2] != 'courses') return
|
||||||
|
let lessonIndex = pathname.pop().split('-')
|
||||||
|
|
||||||
|
if (lessonIndex.length == 2) {
|
||||||
call('lms.lms.api.mark_lesson_progress', {
|
call('lms.lms.api.mark_lesson_progress', {
|
||||||
course: router.currentRoute.value.params.courseName,
|
course: pathname[3],
|
||||||
chapter_number: router.currentRoute.value.params.chapterNumber,
|
chapter_number: lessonIndex[0],
|
||||||
lesson_number: router.currentRoute.value.params.lessonNumber,
|
lesson_number: lessonIndex[1],
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
52
frontend/src/components/RelatedCourses.vue
Normal file
52
frontend/src/components/RelatedCourses.vue
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
<template>
|
||||||
|
<div v-if="relatedCourses.data?.length" class="mt-10">
|
||||||
|
<div class="flex items-center justify-between mb-6">
|
||||||
|
<div class="text-2xl font-semibold text-ink-gray-9">
|
||||||
|
{{ __('Related Courses') }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 2xl:grid-cols-4 gap-4"
|
||||||
|
>
|
||||||
|
<router-link
|
||||||
|
v-for="course in relatedCourses.data"
|
||||||
|
:key="course.name"
|
||||||
|
:to="{ name: 'CourseDetail', params: { courseName: course.name } }"
|
||||||
|
class="cursor-pointer"
|
||||||
|
>
|
||||||
|
<CourseCard :course="course" />
|
||||||
|
</router-link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { createResource } from 'frappe-ui'
|
||||||
|
import { watch } from 'vue'
|
||||||
|
import CourseCard from '@/components/CourseCard.vue'
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
courseName: {
|
||||||
|
type: String,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const relatedCourses = createResource({
|
||||||
|
url: 'lms.lms.utils.get_related_courses',
|
||||||
|
cache: ['related_courses', props.courseName],
|
||||||
|
makeParams() {
|
||||||
|
return {
|
||||||
|
course: props.courseName,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
auto: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.courseName,
|
||||||
|
() => {
|
||||||
|
relatedCourses.reload()
|
||||||
|
}
|
||||||
|
)
|
||||||
|
</script>
|
||||||
142
frontend/src/components/Settings/BadgeAssignmentForm.vue
Normal file
142
frontend/src/components/Settings/BadgeAssignmentForm.vue
Normal file
@@ -0,0 +1,142 @@
|
|||||||
|
<template>
|
||||||
|
<Dialog
|
||||||
|
v-model="show"
|
||||||
|
:options="{
|
||||||
|
title:
|
||||||
|
props.badgeAssignmentID === 'new'
|
||||||
|
? __('Assign a Badge')
|
||||||
|
: __('Edit Badge Assignment'),
|
||||||
|
size: 'sm',
|
||||||
|
actions: [
|
||||||
|
{
|
||||||
|
label: __('Save'),
|
||||||
|
variant: 'solid',
|
||||||
|
onClick: ({ close }) => {
|
||||||
|
saveBadgeAssignment(close)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
<template #body-content>
|
||||||
|
<div class="space-y-4">
|
||||||
|
<Link
|
||||||
|
doctype="User"
|
||||||
|
v-model="badgeAssignment.member"
|
||||||
|
:label="__('Member')"
|
||||||
|
:required="true"
|
||||||
|
/>
|
||||||
|
<Link
|
||||||
|
doctype="LMS Badge"
|
||||||
|
v-model="badgeAssignment.badge"
|
||||||
|
:label="__('Badge')"
|
||||||
|
:required="true"
|
||||||
|
/>
|
||||||
|
<div>
|
||||||
|
<label class="text-xs text-ink-gray-5 mb-1">
|
||||||
|
{{ __('Issued On') }}
|
||||||
|
<span class="text-ink-red-3">*</span>
|
||||||
|
</label>
|
||||||
|
<DatePicker
|
||||||
|
v-model="badgeAssignment.issued_on"
|
||||||
|
:placeholder="__('Select Date')"
|
||||||
|
:required="true"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</Dialog>
|
||||||
|
</template>
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { Dialog, DatePicker, toast } from 'frappe-ui'
|
||||||
|
import type {
|
||||||
|
BadgeAssignments,
|
||||||
|
BadgeAssignment,
|
||||||
|
} from '@/components/Settings/types'
|
||||||
|
import { ref, watch } from 'vue'
|
||||||
|
import { cleanError } from '@/utils'
|
||||||
|
import Link from '@/components/Controls/Link.vue'
|
||||||
|
|
||||||
|
const show = defineModel<boolean>({ required: true, default: false })
|
||||||
|
const defaultBadgeAssignment = {
|
||||||
|
name: '',
|
||||||
|
badge: '',
|
||||||
|
member: '',
|
||||||
|
issued_on: '',
|
||||||
|
member_name: '',
|
||||||
|
member_username: '',
|
||||||
|
member_image: '',
|
||||||
|
}
|
||||||
|
const badgeAssignments = defineModel<BadgeAssignments>('badgeAssignments')
|
||||||
|
const badgeAssignment = ref<BadgeAssignment>(defaultBadgeAssignment)
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
badgeAssignmentID: string
|
||||||
|
badge: string | null
|
||||||
|
}>()
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.badgeAssignmentID,
|
||||||
|
(newID) => {
|
||||||
|
if (newID === 'new') {
|
||||||
|
badgeAssignment.value = {
|
||||||
|
...defaultBadgeAssignment,
|
||||||
|
badge: props.badge || '',
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const assignment = badgeAssignments.value?.data?.find(
|
||||||
|
(assignment) => assignment.name === newID
|
||||||
|
)
|
||||||
|
if (assignment) {
|
||||||
|
badgeAssignment.value = { ...assignment }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
const saveBadgeAssignment = (close: () => void) => {
|
||||||
|
if (props.badgeAssignmentID === 'new') {
|
||||||
|
createBadgeAssignment(close)
|
||||||
|
} else {
|
||||||
|
updateBadgeAssignment(close)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateBadgeAssignment = async (close: () => void) => {
|
||||||
|
badgeAssignments.value?.setValue.submit(
|
||||||
|
{
|
||||||
|
...badgeAssignment.value,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
onSuccess: () => {
|
||||||
|
toast.success(__('Badge assignment updated successfully'))
|
||||||
|
close()
|
||||||
|
},
|
||||||
|
onError: (error) => {
|
||||||
|
toast.error(
|
||||||
|
__('Failed to update badge assignment: ') + cleanError(error)
|
||||||
|
)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const createBadgeAssignment = (close: () => void) => {
|
||||||
|
badgeAssignments.value?.insert.submit(
|
||||||
|
{
|
||||||
|
...badgeAssignment.value,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
onSuccess: () => {
|
||||||
|
toast.success(__('Badge assignment created successfully'))
|
||||||
|
close()
|
||||||
|
},
|
||||||
|
onError: (error) => {
|
||||||
|
toast.error(
|
||||||
|
__('Failed to create badge assignment: ') + cleanError(error)
|
||||||
|
)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
</script>
|
||||||
192
frontend/src/components/Settings/BadgeAssignments.vue
Normal file
192
frontend/src/components/Settings/BadgeAssignments.vue
Normal file
@@ -0,0 +1,192 @@
|
|||||||
|
<template>
|
||||||
|
<div class="text-base">
|
||||||
|
<div class="flex items-center justify-between space-x-2 mb-5">
|
||||||
|
<div class="flex items-center space-x-2">
|
||||||
|
<ChevronLeft
|
||||||
|
class="size-5 stroke-1.5 text-ink-gray-5 cursor-pointer"
|
||||||
|
@click="
|
||||||
|
() => {
|
||||||
|
show = false
|
||||||
|
}
|
||||||
|
"
|
||||||
|
/>
|
||||||
|
<div class="text-xl font-semibold text-ink-gray-9">
|
||||||
|
{{ props.badgeName }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Button @click="openForm('new')">
|
||||||
|
<template #prefix>
|
||||||
|
<Plus class="size-4 stroke-1.5" />
|
||||||
|
</template>
|
||||||
|
{{ __('New') }}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<div v-if="assignments.data?.length">
|
||||||
|
<ListView
|
||||||
|
:rows="assignments.data"
|
||||||
|
:columns="columns"
|
||||||
|
rowKey="name"
|
||||||
|
:options="{
|
||||||
|
showTooltip: false,
|
||||||
|
onRowClick: (row: BadgeAssignment) => {
|
||||||
|
openForm(row.name)
|
||||||
|
},
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
<ListHeader
|
||||||
|
class="mb-2 grid items-center space-x-4 rounded bg-surface-gray-2 p-2"
|
||||||
|
>
|
||||||
|
<ListHeaderItem :item="item" v-for="item in columns">
|
||||||
|
<template #prefix="{ item }">
|
||||||
|
<FeatherIcon
|
||||||
|
v-if="item.icon"
|
||||||
|
:name="item.icon"
|
||||||
|
class="h-4 w-4 stroke-1.5"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
</ListHeaderItem>
|
||||||
|
</ListHeader>
|
||||||
|
<ListRows>
|
||||||
|
<ListRow :row="row" v-for="row in assignments.data">
|
||||||
|
<template #default="{ column, item }">
|
||||||
|
<ListRowItem :item="row[column.key]" :align="column.align">
|
||||||
|
<template #prefix>
|
||||||
|
<div v-if="column.key == 'member_name'">
|
||||||
|
<Avatar
|
||||||
|
class="flex items-center"
|
||||||
|
:image="row['member_image']"
|
||||||
|
:label="item"
|
||||||
|
size="sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<div class="leading-5 text-sm">
|
||||||
|
{{ row[column.key] }}
|
||||||
|
</div>
|
||||||
|
</ListRowItem>
|
||||||
|
</template>
|
||||||
|
</ListRow>
|
||||||
|
</ListRows>
|
||||||
|
<ListSelectBanner>
|
||||||
|
<template #actions="{ unselectAll, selections }">
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
@click="deleteBadgeAssignment(selections, unselectAll)"
|
||||||
|
>
|
||||||
|
<Trash2 class="h-4 w-4 stroke-1.5" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</ListSelectBanner>
|
||||||
|
</ListView>
|
||||||
|
</div>
|
||||||
|
<div v-else class="flex flex-col items-center justify-center mt-44">
|
||||||
|
<GraduationCap class="size-10 mx-auto stroke-1 text-ink-gray-5" />
|
||||||
|
<div class="text-lg font-semibold text-ink-gray-7 mb-2.5">
|
||||||
|
{{ __('No Assignments') }}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
class="leading-5 text-base w-2/5 text-base text-center text-ink-gray-7"
|
||||||
|
>
|
||||||
|
{{ __('This badge has not been assigned to any students yet') }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<BadgeAssignmentForm
|
||||||
|
v-model="showForm"
|
||||||
|
:badgeAssignmentID="currentAssignmentID"
|
||||||
|
:badge="props.badgeName"
|
||||||
|
v-model:badgeAssignments="assignments"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
<script setup lang="ts">
|
||||||
|
import {
|
||||||
|
Avatar,
|
||||||
|
Button,
|
||||||
|
createListResource,
|
||||||
|
FeatherIcon,
|
||||||
|
ListView,
|
||||||
|
ListHeader,
|
||||||
|
ListHeaderItem,
|
||||||
|
ListRows,
|
||||||
|
ListRow,
|
||||||
|
ListRowItem,
|
||||||
|
ListSelectBanner,
|
||||||
|
toast,
|
||||||
|
} from 'frappe-ui'
|
||||||
|
import { ChevronLeft, GraduationCap, Plus, Trash2 } from 'lucide-vue-next'
|
||||||
|
import { computed, inject, ref } from 'vue'
|
||||||
|
import type { BadgeAssignment } from '@/components/Settings/types'
|
||||||
|
import BadgeAssignmentForm from '@/components/Settings/BadgeAssignmentForm.vue'
|
||||||
|
|
||||||
|
const show = defineModel<boolean>()
|
||||||
|
const dayjs = inject('$dayjs') as any
|
||||||
|
const showForm = ref(false)
|
||||||
|
const currentAssignmentID = ref<string>('')
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
badgeName: string | null
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const assignments = createListResource({
|
||||||
|
doctype: 'LMS Badge Assignment',
|
||||||
|
fields: [
|
||||||
|
'name',
|
||||||
|
'member',
|
||||||
|
'member_name',
|
||||||
|
'member_username',
|
||||||
|
'member_image',
|
||||||
|
'issued_on',
|
||||||
|
'badge',
|
||||||
|
],
|
||||||
|
filters: {
|
||||||
|
badge: props.badgeName,
|
||||||
|
},
|
||||||
|
order_by: 'issued_on desc',
|
||||||
|
transform(data: BadgeAssignment[]) {
|
||||||
|
return data.map((item: BadgeAssignment) => {
|
||||||
|
return {
|
||||||
|
...item,
|
||||||
|
issued_on: item.issued_on
|
||||||
|
? dayjs(item.issued_on).format('DD MMM YYYY')
|
||||||
|
: null,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
},
|
||||||
|
auto: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
const openForm = (assignmentID: string) => {
|
||||||
|
currentAssignmentID.value = assignmentID
|
||||||
|
showForm.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
const deleteBadgeAssignment = (
|
||||||
|
selections: Set<string>,
|
||||||
|
unselectAll: () => void
|
||||||
|
) => {
|
||||||
|
Array.from(selections).forEach(async (assignment: string) => {
|
||||||
|
await assignments.delete.submit(assignment)
|
||||||
|
})
|
||||||
|
unselectAll()
|
||||||
|
toast.success(__('Badge assignments deleted successfully'))
|
||||||
|
}
|
||||||
|
|
||||||
|
const columns = computed(() => {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
label: __('Member'),
|
||||||
|
key: 'member_name',
|
||||||
|
icon: 'user',
|
||||||
|
width: '60%',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: __('Issued On'),
|
||||||
|
key: 'issued_on',
|
||||||
|
icon: 'calendar',
|
||||||
|
align: 'center',
|
||||||
|
},
|
||||||
|
]
|
||||||
|
})
|
||||||
|
</script>
|
||||||
219
frontend/src/components/Settings/BadgeForm.vue
Normal file
219
frontend/src/components/Settings/BadgeForm.vue
Normal file
@@ -0,0 +1,219 @@
|
|||||||
|
<template>
|
||||||
|
<Dialog
|
||||||
|
v-model="show"
|
||||||
|
:options="{
|
||||||
|
title: badge ? __('Edit Badge') : __('Create a new Badge'),
|
||||||
|
size: '3xl',
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
<template #body-content>
|
||||||
|
<div class="grid grid-cols-2 gap-x-5">
|
||||||
|
<div class="space-y-4">
|
||||||
|
<FormControl
|
||||||
|
v-model="badge.enabled"
|
||||||
|
:label="__('Enabled')"
|
||||||
|
type="checkbox"
|
||||||
|
/>
|
||||||
|
<FormControl
|
||||||
|
v-model="badge.title"
|
||||||
|
:label="__('Title')"
|
||||||
|
type="text"
|
||||||
|
:required="true"
|
||||||
|
/>
|
||||||
|
<Autocomplete
|
||||||
|
@update:modelValue="(opt: any) => (badge.reference_doctype = opt.value)"
|
||||||
|
:modelValue="badge.reference_doctype"
|
||||||
|
:options="referenceDoctypeOptions"
|
||||||
|
:required="true"
|
||||||
|
:label="__('Assign For')"
|
||||||
|
/>
|
||||||
|
<FormControl
|
||||||
|
v-model="badge.description"
|
||||||
|
:label="__('Description')"
|
||||||
|
:required="true"
|
||||||
|
type="textarea"
|
||||||
|
/>
|
||||||
|
<Uploader
|
||||||
|
v-model="badge.image"
|
||||||
|
label="Badge Image"
|
||||||
|
description="An image that represents the badge."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="space-y-4">
|
||||||
|
<FormControl
|
||||||
|
v-model="badge.grant_only_once"
|
||||||
|
:label="__('Grant Only Once')"
|
||||||
|
type="checkbox"
|
||||||
|
/>
|
||||||
|
<FormControl
|
||||||
|
v-model="badge.event"
|
||||||
|
:label="__('Event')"
|
||||||
|
type="select"
|
||||||
|
:options="eventOptions"
|
||||||
|
:required="true"
|
||||||
|
/>
|
||||||
|
<FormControl
|
||||||
|
v-model="badge.user_field"
|
||||||
|
:label="__('Assign To')"
|
||||||
|
type="select"
|
||||||
|
:options="userFieldOptions"
|
||||||
|
:required="true"
|
||||||
|
/>
|
||||||
|
<CodeEditor
|
||||||
|
v-model="badge.condition"
|
||||||
|
:label="__('Condition')"
|
||||||
|
type="JavaScript"
|
||||||
|
:required="true"
|
||||||
|
:showBorder="true"
|
||||||
|
height="82px"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="space-y-4"></div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<template #actions="{ close }">
|
||||||
|
<div class="pb-5 float-right">
|
||||||
|
<Button variant="solid" @click="saveBadge(close)">
|
||||||
|
{{ __('Save') }}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</Dialog>
|
||||||
|
</template>
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { Button, call, Dialog, FormControl, toast } from 'frappe-ui'
|
||||||
|
import { computed, ref, watch } from 'vue'
|
||||||
|
import { cleanError } from '@/utils'
|
||||||
|
import type { Badges, Badge } from '@/components/Settings/types'
|
||||||
|
import Autocomplete from '@/components/Controls/Autocomplete.vue'
|
||||||
|
import CodeEditor from '@/components/Controls/CodeEditor.vue'
|
||||||
|
import Uploader from '@/components/Controls/Uploader.vue'
|
||||||
|
|
||||||
|
const defaultBadge = {
|
||||||
|
name: '',
|
||||||
|
title: '',
|
||||||
|
enabled: true,
|
||||||
|
description: '',
|
||||||
|
image: '',
|
||||||
|
grant_only_once: false,
|
||||||
|
event: 'New',
|
||||||
|
reference_doctype: '',
|
||||||
|
condition: '',
|
||||||
|
user_field: 'member',
|
||||||
|
field_to_check: '',
|
||||||
|
}
|
||||||
|
const show = defineModel<boolean>({ required: true, default: false })
|
||||||
|
const badges = defineModel<Badges>('badges')
|
||||||
|
const badge = ref<Badge>(defaultBadge)
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
badgeName: string | null
|
||||||
|
}>()
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.badgeName,
|
||||||
|
(val) => {
|
||||||
|
if (val != 'new') {
|
||||||
|
badges.value?.data.forEach((bdg: Badge) => {
|
||||||
|
if (bdg.name === val) {
|
||||||
|
badge.value = bdg
|
||||||
|
}
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
badge.value = { ...defaultBadge }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
const saveBadge = (close: () => void) => {
|
||||||
|
if (props.badgeName == 'new') {
|
||||||
|
createBadge(close)
|
||||||
|
} else {
|
||||||
|
updateBadge(close)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateBadge = async (close: () => void) => {
|
||||||
|
if (props.badgeName != badge.value?.title) {
|
||||||
|
await renameDoc()
|
||||||
|
}
|
||||||
|
setValue(close)
|
||||||
|
}
|
||||||
|
|
||||||
|
const renameDoc = async () => {
|
||||||
|
await call('frappe.client.rename_doc', {
|
||||||
|
doctype: 'LMS Badge',
|
||||||
|
old_name: props.badgeName,
|
||||||
|
new_name: badge.value?.title,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const setValue = (close: () => void) => {
|
||||||
|
badges.value?.setValue.submit(
|
||||||
|
{
|
||||||
|
...badge.value,
|
||||||
|
name: badge.value.title,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
onSuccess() {
|
||||||
|
badges.value?.reload()
|
||||||
|
close()
|
||||||
|
toast.success(__('Badge updated successfully'))
|
||||||
|
},
|
||||||
|
onError(err: any) {
|
||||||
|
close()
|
||||||
|
toast.error(cleanError(err.messages[0]) || err)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const createBadge = (close: () => void) => {
|
||||||
|
badges.value?.insert.submit(
|
||||||
|
{
|
||||||
|
...badge.value,
|
||||||
|
name: badge.value.name,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
onSuccess() {
|
||||||
|
badges.value?.reload()
|
||||||
|
close()
|
||||||
|
toast.success(__('Badge created successfully'))
|
||||||
|
},
|
||||||
|
onError(err) {
|
||||||
|
close()
|
||||||
|
toast.error(cleanError(err.messages[0]) || __('Error creating badge'))
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const referenceDoctypeOptions = computed(() => {
|
||||||
|
return [
|
||||||
|
{ label: __('Course'), value: 'LMS Course' },
|
||||||
|
{ label: __('Batch'), value: 'LMS Batch' },
|
||||||
|
{ label: __('User'), value: 'Member' },
|
||||||
|
{ label: __('Quiz Submission'), value: 'LMS Quiz Submission' },
|
||||||
|
{ label: __('Assignment Submission'), value: 'LMS Assignment Submission' },
|
||||||
|
{
|
||||||
|
label: __('Programming Exercise Submission'),
|
||||||
|
value: 'LMS Programming Exercise Submission',
|
||||||
|
},
|
||||||
|
{ label: __('Course Enrollment'), value: 'LMS Enrollment' },
|
||||||
|
{ label: __('Batch Enrollment'), value: 'LMS Batch Enrollment' },
|
||||||
|
]
|
||||||
|
})
|
||||||
|
|
||||||
|
const eventOptions = computed(() => {
|
||||||
|
let options = ['New', 'Value Change', 'Auto Assign']
|
||||||
|
return options.map((event) => ({ label: __(event), value: event }))
|
||||||
|
})
|
||||||
|
|
||||||
|
const userFieldOptions = computed(() => {
|
||||||
|
return [
|
||||||
|
{ label: __('Member'), value: 'member' },
|
||||||
|
{ label: __('Owner'), value: 'owner' },
|
||||||
|
]
|
||||||
|
})
|
||||||
|
</script>
|
||||||
247
frontend/src/components/Settings/Badges.vue
Normal file
247
frontend/src/components/Settings/Badges.vue
Normal file
@@ -0,0 +1,247 @@
|
|||||||
|
<template>
|
||||||
|
<BadgeAssignments
|
||||||
|
v-if="showAssignments"
|
||||||
|
v-model="showAssignments"
|
||||||
|
:badgeName="showAssignmentsFor"
|
||||||
|
/>
|
||||||
|
<div v-else class="flex flex-col min-h-0 text-base">
|
||||||
|
<div class="flex items-center justify-between mb-5">
|
||||||
|
<div class="flex flex-col space-y-2">
|
||||||
|
<div class="text-xl font-semibold text-ink-gray-9">
|
||||||
|
{{ label }}
|
||||||
|
</div>
|
||||||
|
<div class="text-ink-gray-6 leading-5">
|
||||||
|
{{ __(description) }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Button @click="openForm('new')">
|
||||||
|
<template #prefix>
|
||||||
|
<Plus class="h-3 w-3 stroke-1.5" />
|
||||||
|
</template>
|
||||||
|
{{ __('New') }}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<div v-if="badges.data?.length" class="overflow-y-scroll">
|
||||||
|
<ListView
|
||||||
|
:columns="columns"
|
||||||
|
:rows="badges.data"
|
||||||
|
row-key="name"
|
||||||
|
:options="{
|
||||||
|
showTooltip: false,
|
||||||
|
selectable: false,
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
<ListHeader
|
||||||
|
class="mb-2 grid items-center space-x-4 rounded bg-surface-gray-2 p-2"
|
||||||
|
>
|
||||||
|
<ListHeaderItem :item="item" v-for="item in columns" :key="item.key">
|
||||||
|
<template #prefix="{ item }">
|
||||||
|
<FeatherIcon
|
||||||
|
v-if="item.icon"
|
||||||
|
:name="item.icon"
|
||||||
|
class="h-4 w-4 stroke-1.5"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
</ListHeaderItem>
|
||||||
|
</ListHeader>
|
||||||
|
<ListRows>
|
||||||
|
<ListRow :row="row" v-for="row in badges.data" :key="row.name">
|
||||||
|
<template #default="{ column, item }">
|
||||||
|
<ListRowItem :item="row[column.key]" :align="column.align">
|
||||||
|
<div v-if="column.key == 'enabled'">
|
||||||
|
<Badge v-if="row[column.key]" theme="green">
|
||||||
|
{{ __('Enabled') }}
|
||||||
|
</Badge>
|
||||||
|
<Badge v-else theme="gray">
|
||||||
|
{{ __('Disabled') }}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
<div v-else-if="column.key == 'reference_doctype'">
|
||||||
|
{{
|
||||||
|
doctypeLabel[
|
||||||
|
row[column.key] as keyof typeof doctypeLabel
|
||||||
|
] || row[column.key]
|
||||||
|
}}
|
||||||
|
</div>
|
||||||
|
<FormControl
|
||||||
|
v-else-if="column.key == 'grant_only_once'"
|
||||||
|
v-model="row[column.key]"
|
||||||
|
type="checkbox"
|
||||||
|
:disabled="true"
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
v-else-if="column.key != 'action'"
|
||||||
|
class="leading-5 text-sm"
|
||||||
|
>
|
||||||
|
{{ row[column.key] }}
|
||||||
|
</div>
|
||||||
|
<Dropdown
|
||||||
|
v-else
|
||||||
|
:options="getMoreOptions(row.name)"
|
||||||
|
:button="{
|
||||||
|
icon: 'more-horizontal',
|
||||||
|
onblur: (e: Event) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
},
|
||||||
|
}"
|
||||||
|
placement="right"
|
||||||
|
/>
|
||||||
|
</ListRowItem>
|
||||||
|
</template>
|
||||||
|
</ListRow>
|
||||||
|
</ListRows>
|
||||||
|
</ListView>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<BadgeForm
|
||||||
|
v-model="showForm"
|
||||||
|
:badgeName="selectedBadge"
|
||||||
|
v-model:badges="badges"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
<script setup lang="ts">
|
||||||
|
import {
|
||||||
|
Badge,
|
||||||
|
Button,
|
||||||
|
createListResource,
|
||||||
|
Dropdown,
|
||||||
|
FeatherIcon,
|
||||||
|
FormControl,
|
||||||
|
ListView,
|
||||||
|
ListHeader,
|
||||||
|
ListHeaderItem,
|
||||||
|
ListRows,
|
||||||
|
ListRow,
|
||||||
|
ListRowItem,
|
||||||
|
toast,
|
||||||
|
} from 'frappe-ui'
|
||||||
|
import { computed, ref } from 'vue'
|
||||||
|
import { Plus } from 'lucide-vue-next'
|
||||||
|
import { cleanError } from '@/utils'
|
||||||
|
import BadgeForm from '@/components/Settings/BadgeForm.vue'
|
||||||
|
import BadgeAssignments from '@/components/Settings/BadgeAssignments.vue'
|
||||||
|
|
||||||
|
const showForm = ref<boolean>(false)
|
||||||
|
const selectedBadge = ref<string | null>(null)
|
||||||
|
const showAssignments = ref<boolean>(false)
|
||||||
|
const showAssignmentsFor = ref<string | null>(null)
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
label: string
|
||||||
|
description: string
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const badges = createListResource({
|
||||||
|
doctype: 'LMS Badge',
|
||||||
|
fields: [
|
||||||
|
'name',
|
||||||
|
'title',
|
||||||
|
'enabled',
|
||||||
|
'description',
|
||||||
|
'image',
|
||||||
|
'grant_only_once',
|
||||||
|
'event',
|
||||||
|
'reference_doctype',
|
||||||
|
'condition',
|
||||||
|
'user_field',
|
||||||
|
'field_to_check',
|
||||||
|
],
|
||||||
|
order_by: 'creation desc',
|
||||||
|
auto: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
const getMoreOptions = (badgeName: string) => {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
label: __('Edit'),
|
||||||
|
icon: 'edit',
|
||||||
|
onClick() {
|
||||||
|
openForm(badgeName)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: __('Assignments'),
|
||||||
|
icon: 'download',
|
||||||
|
onClick() {
|
||||||
|
showAssignmentsFor.value = badgeName
|
||||||
|
showAssignments.value = true
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: __('Delete'),
|
||||||
|
icon: 'trash-2',
|
||||||
|
onClick() {
|
||||||
|
deleteBadge(badgeName)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
const openForm = (badgeName: string) => {
|
||||||
|
selectedBadge.value = badgeName
|
||||||
|
showForm.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
const deleteBadge = (badgeName: string) => {
|
||||||
|
badges.delete
|
||||||
|
.submit(badgeName)
|
||||||
|
.then(() => {
|
||||||
|
badges.reload()
|
||||||
|
toast.success(__('Badge deleted successfully'))
|
||||||
|
})
|
||||||
|
.catch((err: any) => {
|
||||||
|
toast.error(cleanError(err.messages[0]) || __('Error deleting badge'))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const doctypeLabel = computed(() => {
|
||||||
|
return {
|
||||||
|
'LMS Course': __('Course'),
|
||||||
|
'LMS Batch': __('Batch'),
|
||||||
|
'LMS Enrollment': __('Course Enrollment'),
|
||||||
|
'LMS Batch Enrollment': __('Batch Enrollment'),
|
||||||
|
'LMS Quiz Submission': __('Quiz Submission'),
|
||||||
|
'LMS Assignment Submission': __('Assignment Submission'),
|
||||||
|
'LMS Programming Exercise Submission': __(
|
||||||
|
'Programming Exercise Submission'
|
||||||
|
),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const columns = computed(() => {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
label: __('Badge'),
|
||||||
|
key: 'title',
|
||||||
|
icon: 'award',
|
||||||
|
align: 'left',
|
||||||
|
width: '25%',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: __('Assigned For'),
|
||||||
|
key: 'reference_doctype',
|
||||||
|
icon: 'info',
|
||||||
|
align: 'left',
|
||||||
|
width: '35%',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: __('Status'),
|
||||||
|
key: 'enabled',
|
||||||
|
icon: 'check-square',
|
||||||
|
align: 'left',
|
||||||
|
width: '15%',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: __('Grant Only Once'),
|
||||||
|
key: 'grant_only_once',
|
||||||
|
icon: 'check',
|
||||||
|
align: 'center',
|
||||||
|
width: '20%',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'action',
|
||||||
|
align: 'right',
|
||||||
|
},
|
||||||
|
]
|
||||||
|
})
|
||||||
|
</script>
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="flex flex-col justify-between min-h-0">
|
<div class="flex flex-col justify-between h-full">
|
||||||
<div>
|
<div>
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
<div class="font-semibold mb-1 text-ink-gray-9">
|
<div class="font-semibold mb-1 text-ink-gray-9">
|
||||||
@@ -17,7 +17,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="overflow-y-auto">
|
<div class="overflow-y-auto">
|
||||||
<SettingFields :fields="fields" :data="data.data" />
|
<SettingFields :fields="fields" :data="branding.data" />
|
||||||
</div>
|
</div>
|
||||||
<div class="flex flex-row-reverse mt-auto">
|
<div class="flex flex-row-reverse mt-auto">
|
||||||
<Button variant="solid" :loading="saveSettings.loading" @click="update">
|
<Button variant="solid" :loading="saveSettings.loading" @click="update">
|
||||||
@@ -28,7 +28,7 @@
|
|||||||
</template>
|
</template>
|
||||||
<script setup>
|
<script setup>
|
||||||
import { createResource, Button, Badge } from 'frappe-ui'
|
import { createResource, Button, Badge } from 'frappe-ui'
|
||||||
import SettingFields from '@/components/SettingFields.vue'
|
import SettingFields from '@/components/Settings/SettingFields.vue'
|
||||||
import { watch, ref } from 'vue'
|
import { watch, ref } from 'vue'
|
||||||
|
|
||||||
const isDirty = ref(false)
|
const isDirty = ref(false)
|
||||||
@@ -38,10 +38,6 @@ const props = defineProps({
|
|||||||
type: Array,
|
type: Array,
|
||||||
required: true,
|
required: true,
|
||||||
},
|
},
|
||||||
data: {
|
|
||||||
type: Object,
|
|
||||||
required: true,
|
|
||||||
},
|
|
||||||
label: {
|
label: {
|
||||||
type: String,
|
type: String,
|
||||||
required: true,
|
required: true,
|
||||||
@@ -51,6 +47,12 @@ const props = defineProps({
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const branding = createResource({
|
||||||
|
url: 'lms.lms.api.get_branding',
|
||||||
|
auto: true,
|
||||||
|
cache: 'brand',
|
||||||
|
})
|
||||||
|
|
||||||
const saveSettings = createResource({
|
const saveSettings = createResource({
|
||||||
url: 'frappe.client.set_value',
|
url: 'frappe.client.set_value',
|
||||||
makeParams(values) {
|
makeParams(values) {
|
||||||
@@ -64,7 +66,7 @@ const saveSettings = createResource({
|
|||||||
|
|
||||||
const update = () => {
|
const update = () => {
|
||||||
let fieldsToSave = {}
|
let fieldsToSave = {}
|
||||||
let imageFields = ['favicon', 'banner_image', 'footer_logo']
|
let imageFields = ['favicon', 'banner_image']
|
||||||
props.fields.forEach((f) => {
|
props.fields.forEach((f) => {
|
||||||
if (imageFields.includes(f.name)) {
|
if (imageFields.includes(f.name)) {
|
||||||
fieldsToSave[f.name] = f.value ? f.value.file_url : null
|
fieldsToSave[f.name] = f.value ? f.value.file_url : null
|
||||||
@@ -72,6 +74,8 @@ const update = () => {
|
|||||||
fieldsToSave[f.name] = f.value
|
fieldsToSave[f.name] = f.value
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
fieldsToSave['app_logo'] = fieldsToSave['banner_image']
|
||||||
saveSettings.submit(
|
saveSettings.submit(
|
||||||
{
|
{
|
||||||
fields: fieldsToSave,
|
fields: fieldsToSave,
|
||||||
@@ -84,9 +88,31 @@ const update = () => {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
watch(props.data, (newData) => {
|
watch(branding, (updatedDoc) => {
|
||||||
if (newData && !isDirty.value) {
|
let textFields = []
|
||||||
|
let imageFields = []
|
||||||
|
|
||||||
|
props.fields.forEach((f) => {
|
||||||
|
if (f.type === 'Upload') {
|
||||||
|
imageFields.push(f.name)
|
||||||
|
} else {
|
||||||
|
textFields.push(f.name)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
textFields.forEach((field) => {
|
||||||
|
if (updatedDoc.data[field] != updatedDoc.previousData[field]) {
|
||||||
isDirty.value = true
|
isDirty.value = true
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
imageFields.forEach((field) => {
|
||||||
|
if (
|
||||||
|
updatedDoc.data[field] &&
|
||||||
|
updatedDoc.data[field].file_url != updatedDoc.previousData[field].file_url
|
||||||
|
) {
|
||||||
|
isDirty.value = true
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
@@ -5,9 +5,9 @@
|
|||||||
<div class="text-xl font-semibold text-ink-gray-9">
|
<div class="text-xl font-semibold text-ink-gray-9">
|
||||||
{{ label }}
|
{{ label }}
|
||||||
</div>
|
</div>
|
||||||
<div class="text-xs text-ink-gray-5">
|
<!-- <div class="text-xs text-ink-gray-5">
|
||||||
{{ __(description) }}
|
{{ __(description) }}
|
||||||
</div>
|
</div> -->
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center space-x-5">
|
<div class="flex items-center space-x-5">
|
||||||
<Button @click="openTemplateForm('new')">
|
<Button @click="openTemplateForm('new')">
|
||||||
204
frontend/src/components/Settings/Evaluators.vue
Normal file
204
frontend/src/components/Settings/Evaluators.vue
Normal file
@@ -0,0 +1,204 @@
|
|||||||
|
<template>
|
||||||
|
<div class="flex min-h-0 flex-col text-base">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<div class="text-xl font-semibold mb-1 text-ink-gray-9">
|
||||||
|
{{ __(label) }}
|
||||||
|
</div>
|
||||||
|
<div class="text-ink-gray-6 leading-5">
|
||||||
|
{{ __(description) }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex item-center space-x-2">
|
||||||
|
<Button variant="solid" @click="() => (showForm = !showForm)">
|
||||||
|
<template #prefix>
|
||||||
|
<Plus class="size-4 stroke-1.5" />
|
||||||
|
</template>
|
||||||
|
{{ __('New') }}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-8 pb-5">
|
||||||
|
<FormControl
|
||||||
|
v-model="search"
|
||||||
|
:placeholder="__('Search')"
|
||||||
|
type="text"
|
||||||
|
:debounce="300"
|
||||||
|
class="w-1/4 mb-4"
|
||||||
|
>
|
||||||
|
<template #prefix>
|
||||||
|
<Search class="size-4 stroke-1.5 text-ink-gray-5" />
|
||||||
|
</template>
|
||||||
|
</FormControl>
|
||||||
|
<div class="overflow-auto h-[60vh]">
|
||||||
|
<div class="divide-y">
|
||||||
|
<div
|
||||||
|
v-for="evaluator in evaluators.data"
|
||||||
|
:key="evaluator.evaluator"
|
||||||
|
class="cursor-pointer"
|
||||||
|
>
|
||||||
|
<div class="flex items-center justify-between group py-3">
|
||||||
|
<div
|
||||||
|
class="flex items-center space-x-3"
|
||||||
|
@click="openProfile(evaluator.username)"
|
||||||
|
>
|
||||||
|
<Avatar
|
||||||
|
:image="evaluator.user_image"
|
||||||
|
:label="evaluator.full_name"
|
||||||
|
size="xl"
|
||||||
|
/>
|
||||||
|
<div class="space-y-1">
|
||||||
|
<div class="text-base font-semibold text-ink-gray-9">
|
||||||
|
{{ evaluator.full_name }}
|
||||||
|
</div>
|
||||||
|
<div class="text-xs text-ink-gray-5">
|
||||||
|
{{ evaluator.evaluator }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="invisible group-hover:visible">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
@click="deleteEvaluator(evaluator.evaluator)"
|
||||||
|
>
|
||||||
|
<template #icon>
|
||||||
|
<Trash2 class="size-4 stroke-1.5 text-ink-red-3" />
|
||||||
|
</template>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-if="evaluators.length && hasNextPage"
|
||||||
|
class="flex justify-center mt-4"
|
||||||
|
>
|
||||||
|
<Button @click="evaluators.reload()">
|
||||||
|
<template #prefix>
|
||||||
|
<RefreshCw class="h-3 w-3 stroke-1.5" />
|
||||||
|
</template>
|
||||||
|
{{ __('Load More') }}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Dialog
|
||||||
|
v-model="showForm"
|
||||||
|
:options="{
|
||||||
|
size: 'xl',
|
||||||
|
title: __('Add Evaluator'),
|
||||||
|
actions: [{
|
||||||
|
label: __('Add'),
|
||||||
|
variant: 'solid',
|
||||||
|
onClick({ close }: any) {
|
||||||
|
addEvaluator(close)
|
||||||
|
},
|
||||||
|
}]
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
<template #body-content>
|
||||||
|
<div v-if="showForm" class="flex items-center">
|
||||||
|
<FormControl
|
||||||
|
v-model="email"
|
||||||
|
:label="__('Email')"
|
||||||
|
placeholder="jane@doe.com"
|
||||||
|
type="email"
|
||||||
|
class="w-full"
|
||||||
|
@keydown.enter="addEvaluator"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</Dialog>
|
||||||
|
</template>
|
||||||
|
<script setup lang="ts">
|
||||||
|
import {
|
||||||
|
Avatar,
|
||||||
|
Button,
|
||||||
|
call,
|
||||||
|
createListResource,
|
||||||
|
Dialog,
|
||||||
|
FormControl,
|
||||||
|
toast,
|
||||||
|
} from 'frappe-ui'
|
||||||
|
import { ref, watch } from 'vue'
|
||||||
|
import { Plus, Search, Trash2, RefreshCw } from 'lucide-vue-next'
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
|
|
||||||
|
const show = defineModel('show')
|
||||||
|
const search = ref('')
|
||||||
|
const showForm = ref(false)
|
||||||
|
const email = ref('')
|
||||||
|
const router = useRouter()
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
label: {
|
||||||
|
type: String,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
description: {
|
||||||
|
type: String,
|
||||||
|
default: '',
|
||||||
|
},
|
||||||
|
show: {
|
||||||
|
type: Boolean,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const evaluators = createListResource({
|
||||||
|
doctype: 'Course Evaluator',
|
||||||
|
fields: ['evaluator', 'username', 'full_name', 'user_image'],
|
||||||
|
auto: true,
|
||||||
|
orderBy: 'creation desc',
|
||||||
|
})
|
||||||
|
|
||||||
|
const addEvaluator = (close: () => void) => {
|
||||||
|
call('lms.lms.api.add_an_evaluator', {
|
||||||
|
email: email.value,
|
||||||
|
})
|
||||||
|
.then(() => {
|
||||||
|
email.value = ''
|
||||||
|
evaluators.reload()
|
||||||
|
toast.success(__('Evaluator added successfully'))
|
||||||
|
close()
|
||||||
|
})
|
||||||
|
.catch((error: any) => {
|
||||||
|
toast.error(__(error.messages[0] || error.messages))
|
||||||
|
console.error('Error adding evaluator:', error)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(search, () => {
|
||||||
|
evaluators.update({
|
||||||
|
filters: {
|
||||||
|
full_name: ['like', `%${search.value}%`],
|
||||||
|
},
|
||||||
|
})
|
||||||
|
evaluators.reload()
|
||||||
|
})
|
||||||
|
|
||||||
|
const openProfile = (username: string) => {
|
||||||
|
show.value = false
|
||||||
|
router.push({
|
||||||
|
name: 'Profile',
|
||||||
|
params: {
|
||||||
|
username: username,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const deleteEvaluator = (evaluator: string) => {
|
||||||
|
call('lms.lms.api.delete_evaluator', {
|
||||||
|
evaluator: evaluator,
|
||||||
|
})
|
||||||
|
.then(() => {
|
||||||
|
toast.success(__('Evaluator deleted successfully'))
|
||||||
|
evaluators.reload()
|
||||||
|
})
|
||||||
|
.catch((error: any) => {
|
||||||
|
toast.error(__(error.messages[0] || error.messages))
|
||||||
|
console.error('Error deleting evaluator:', error)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
</script>
|
||||||
@@ -5,53 +5,37 @@
|
|||||||
<div class="text-xl font-semibold mb-1 text-ink-gray-9">
|
<div class="text-xl font-semibold mb-1 text-ink-gray-9">
|
||||||
{{ __(label) }}
|
{{ __(label) }}
|
||||||
</div>
|
</div>
|
||||||
<!-- <div class="text-xs text-ink-gray-5">
|
<div class="text-ink-gray-6 leading-5">
|
||||||
{{ __(description) }}
|
{{ __(description) }}
|
||||||
</div> -->
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex item-center space-x-2">
|
<div class="flex item-center space-x-2">
|
||||||
|
<Button variant="solid" @click="() => (showForm = !showForm)">
|
||||||
|
<template #prefix>
|
||||||
|
<Plus class="size-4 stroke-1.5" />
|
||||||
|
</template>
|
||||||
|
{{ __('New') }}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-8 pb-10">
|
||||||
<FormControl
|
<FormControl
|
||||||
v-model="search"
|
v-model="search"
|
||||||
:placeholder="__('Search')"
|
:placeholder="__('Search')"
|
||||||
type="text"
|
type="text"
|
||||||
:debounce="300"
|
:debounce="300"
|
||||||
/>
|
class="w-1/4 mb-4"
|
||||||
<Button @click="() => (showForm = !showForm)">
|
>
|
||||||
<template #prefix>
|
<template #prefix>
|
||||||
<Plus v-if="!showForm" class="size-4 stroke-1.5" />
|
<Search class="size-4 stroke-1.5 text-ink-gray-5" />
|
||||||
<X v-else class="size-4 stroke-1.5" />
|
|
||||||
</template>
|
</template>
|
||||||
{{ showForm ? __('Close') : __('New') }}
|
</FormControl>
|
||||||
</Button>
|
<div class="overflow-y-scroll h-[60vh]">
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Form to add new member -->
|
|
||||||
<div v-if="showForm" class="flex items-center space-x-2 my-4">
|
|
||||||
<FormControl
|
|
||||||
v-model="member.email"
|
|
||||||
:placeholder="__('Email')"
|
|
||||||
type="email"
|
|
||||||
class="w-full"
|
|
||||||
/>
|
|
||||||
<FormControl
|
|
||||||
v-model="member.first_name"
|
|
||||||
:placeholder="__('First Name')"
|
|
||||||
type="text"
|
|
||||||
class="w-full"
|
|
||||||
/>
|
|
||||||
<Button @click="addMember()" variant="subtle">
|
|
||||||
{{ __('Add') }}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="mt-2 pb-10 overflow-auto">
|
|
||||||
<!-- Member list -->
|
|
||||||
<div class="overflow-y-scroll">
|
|
||||||
<ul class="divide-y">
|
<ul class="divide-y">
|
||||||
<li
|
<li
|
||||||
v-for="member in memberList"
|
v-for="member in memberList"
|
||||||
class="grid grid-cols-3 gap-10 py-2 cursor-pointer"
|
class="flex items-center justify-between py-2 cursor-pointer"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
@click="openProfile(member.username)"
|
@click="openProfile(member.username)"
|
||||||
@@ -60,27 +44,13 @@
|
|||||||
<Avatar
|
<Avatar
|
||||||
:image="member.user_image"
|
:image="member.user_image"
|
||||||
:label="member.full_name"
|
:label="member.full_name"
|
||||||
size="lg"
|
size="xl"
|
||||||
/>
|
/>
|
||||||
<div class="space-y-1">
|
<div class="space-y-1">
|
||||||
<div class="flex">
|
<div class="flex">
|
||||||
<div class="text-ink-gray-9">
|
<div class="text-ink-gray-9">
|
||||||
{{ member.full_name }}
|
{{ member.full_name }}
|
||||||
</div>
|
</div>
|
||||||
<div
|
|
||||||
class="px-1"
|
|
||||||
v-if="member.role && getRole(member.role) !== 'Student'"
|
|
||||||
>
|
|
||||||
<Badge
|
|
||||||
:variant="'subtle'"
|
|
||||||
:ref_for="true"
|
|
||||||
theme="blue"
|
|
||||||
size="sm"
|
|
||||||
label="Badge"
|
|
||||||
>
|
|
||||||
{{ getRole(member.role) }}
|
|
||||||
</Badge>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="text-sm text-ink-gray-7">
|
<div class="text-sm text-ink-gray-7">
|
||||||
{{ member.name }}
|
{{ member.name }}
|
||||||
@@ -88,16 +58,16 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
class="flex items-center justify-center text-ink-gray-7 text-sm"
|
class="flex items-center space-x-1 bg-surface-gray-2 px-2 py-1.5 rounded-md"
|
||||||
|
v-if="member.role && member.role !== 'LMS Student'"
|
||||||
>
|
>
|
||||||
<div v-if="member.last_active">
|
<Shield class="size-4 stroke-1.5" />
|
||||||
{{ dayjs(member.last_active).format('DD MMM, YYYY HH:mm a') }}
|
<span class="text-sm">
|
||||||
</div>
|
{{ getRole(member.role) }}
|
||||||
<div v-else>-</div>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
|
||||||
<div
|
<div
|
||||||
v-if="memberList.length && hasNextPage"
|
v-if="memberList.length && hasNextPage"
|
||||||
class="flex justify-center mt-4"
|
class="flex justify-center mt-4"
|
||||||
@@ -111,36 +81,69 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
<Dialog
|
||||||
|
v-model="showForm"
|
||||||
|
:options="{
|
||||||
|
title: __('Add a new member'),
|
||||||
|
size: 'lg',
|
||||||
|
actions: [{
|
||||||
|
label: __('Add'),
|
||||||
|
variant: 'solid',
|
||||||
|
onClick({ close }: any) {
|
||||||
|
addMember(close)
|
||||||
|
}
|
||||||
|
}]
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
<template #body-content>
|
||||||
|
<div class="flex items-center space-x-2">
|
||||||
|
<FormControl
|
||||||
|
v-model="member.email"
|
||||||
|
:label="__('Email')"
|
||||||
|
placeholder="jane@doe.com"
|
||||||
|
type="email"
|
||||||
|
class="w-full"
|
||||||
|
/>
|
||||||
|
<FormControl
|
||||||
|
v-model="member.first_name"
|
||||||
|
:label="__('First Name')"
|
||||||
|
placeholder="Jane"
|
||||||
|
type="text"
|
||||||
|
class="w-full"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</Dialog>
|
||||||
</template>
|
</template>
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { createResource, Avatar, Button, FormControl, Badge } from 'frappe-ui'
|
import {
|
||||||
|
Avatar,
|
||||||
|
Badge,
|
||||||
|
Button,
|
||||||
|
createResource,
|
||||||
|
Dialog,
|
||||||
|
FormControl,
|
||||||
|
} from 'frappe-ui'
|
||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
import { ref, watch, reactive, inject } from 'vue'
|
import { ref, watch, reactive, inject } from 'vue'
|
||||||
import { RefreshCw, Plus, X } from 'lucide-vue-next'
|
import { RefreshCw, Plus, Search, Shield } from 'lucide-vue-next'
|
||||||
import { useOnboarding } from 'frappe-ui/frappe'
|
import { useOnboarding } from 'frappe-ui/frappe'
|
||||||
|
import type { User } from '@/components/Settings/types'
|
||||||
|
|
||||||
interface User {
|
type Member = {
|
||||||
data: {
|
|
||||||
email: string
|
|
||||||
name: string
|
|
||||||
enabled: boolean
|
|
||||||
user_image: string
|
|
||||||
full_name: string
|
|
||||||
user_type: ['System User', 'Website User']
|
|
||||||
username: string
|
username: string
|
||||||
is_moderator: boolean
|
full_name: string
|
||||||
is_system_manager: boolean
|
name: string
|
||||||
is_evaluator: boolean
|
role?: string
|
||||||
is_instructor: boolean
|
user_image?: string
|
||||||
is_fc_site: boolean
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const show = defineModel('show')
|
const show = defineModel('show')
|
||||||
const search = ref('')
|
const search = ref('')
|
||||||
const start = ref(0)
|
const start = ref(0)
|
||||||
const memberList = ref([])
|
const memberList = ref<Member[]>([])
|
||||||
const hasNextPage = ref(false)
|
const hasNextPage = ref(false)
|
||||||
const showForm = ref(false)
|
const showForm = ref(false)
|
||||||
const dayjs = inject('$dayjs')
|
const dayjs = inject('$dayjs')
|
||||||
@@ -174,7 +177,7 @@ const members = createResource({
|
|||||||
start: start.value,
|
start: start.value,
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onSuccess(data) {
|
onSuccess(data: Member[]) {
|
||||||
memberList.value = memberList.value.concat(data)
|
memberList.value = memberList.value.concat(data)
|
||||||
start.value = start.value + 20
|
start.value = start.value + 20
|
||||||
hasNextPage.value = data.length === 20
|
hasNextPage.value = data.length === 20
|
||||||
@@ -182,7 +185,7 @@ const members = createResource({
|
|||||||
auto: true,
|
auto: true,
|
||||||
})
|
})
|
||||||
|
|
||||||
const openProfile = (username) => {
|
const openProfile = (username: string) => {
|
||||||
show.value = false
|
show.value = false
|
||||||
router.push({
|
router.push({
|
||||||
name: 'Profile',
|
name: 'Profile',
|
||||||
@@ -194,7 +197,7 @@ const openProfile = (username) => {
|
|||||||
|
|
||||||
const newMember = createResource({
|
const newMember = createResource({
|
||||||
url: 'frappe.client.insert',
|
url: 'frappe.client.insert',
|
||||||
makeParams(values) {
|
makeParams() {
|
||||||
return {
|
return {
|
||||||
doc: {
|
doc: {
|
||||||
doctype: 'User',
|
doctype: 'User',
|
||||||
@@ -204,13 +207,12 @@ const newMember = createResource({
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
auto: false,
|
auto: false,
|
||||||
onSuccess(data) {
|
onSuccess(data: Member) {
|
||||||
show.value = false
|
show.value = false
|
||||||
|
|
||||||
if (user?.data?.is_system_manager) updateOnboardingStep('invite_students')
|
if (user?.data?.is_system_manager) updateOnboardingStep('invite_students')
|
||||||
|
|
||||||
router.push({
|
router.push({
|
||||||
name: 'Profile',
|
name: 'ProfileRoles',
|
||||||
params: {
|
params: {
|
||||||
username: data.username,
|
username: data.username,
|
||||||
},
|
},
|
||||||
@@ -218,8 +220,9 @@ const newMember = createResource({
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
const addMember = () => {
|
const addMember = (close: () => void) => {
|
||||||
newMember.reload()
|
newMember.reload()
|
||||||
|
close()
|
||||||
}
|
}
|
||||||
|
|
||||||
watch(search, () => {
|
watch(search, () => {
|
||||||
@@ -228,8 +231,8 @@ watch(search, () => {
|
|||||||
members.reload()
|
members.reload()
|
||||||
})
|
})
|
||||||
|
|
||||||
const getRole = (role) => {
|
const getRole = (role: string) => {
|
||||||
const map = {
|
const map: Record<string, string> = {
|
||||||
'LMS Student': 'Student',
|
'LMS Student': 'Student',
|
||||||
'Course Creator': 'Instructor',
|
'Course Creator': 'Instructor',
|
||||||
Moderator: 'Moderator',
|
Moderator: 'Moderator',
|
||||||
@@ -30,9 +30,9 @@
|
|||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<script setup>
|
<script setup>
|
||||||
import SettingFields from '@/components/SettingFields.vue'
|
import SettingFields from '@/components/Settings/SettingFields.vue'
|
||||||
import { createResource, Badge, Button } from 'frappe-ui'
|
import { createResource, Badge, Button } from 'frappe-ui'
|
||||||
import { watch, ref } from 'vue'
|
import { watch } from 'vue'
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
label: {
|
label: {
|
||||||
@@ -28,7 +28,7 @@
|
|||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { Button, Badge, toast } from 'frappe-ui'
|
import { Button, Badge, toast } from 'frappe-ui'
|
||||||
import SettingFields from '@/components/SettingFields.vue'
|
import SettingFields from '@/components/Settings/SettingFields.vue'
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
fields: {
|
fields: {
|
||||||
@@ -6,7 +6,7 @@
|
|||||||
<div v-for="(column, index) in columns" :key="index">
|
<div v-for="(column, index) in columns" :key="index">
|
||||||
<div
|
<div
|
||||||
class="flex flex-col space-y-5"
|
class="flex flex-col space-y-5"
|
||||||
:class="columns.length > 1 ? 'w-[21rem]' : 'w-1/2'"
|
:class="columns.length > 1 ? 'w-[21rem]' : 'w-full'"
|
||||||
>
|
>
|
||||||
<div v-for="field in column">
|
<div v-for="field in column">
|
||||||
<Link
|
<Link
|
||||||
@@ -55,11 +55,13 @@
|
|||||||
<div v-else>
|
<div v-else>
|
||||||
<div class="flex items-center text-sm space-x-2">
|
<div class="flex items-center text-sm space-x-2">
|
||||||
<div
|
<div
|
||||||
class="flex items-center justify-center rounded border border-outline-gray-1 bg-surface-gray-2 px-20 py-5"
|
class="flex items-center justify-center rounded border border-outline-gray-1 bg-surface-gray-2"
|
||||||
|
:class="field.size == 'lg' ? 'px-5 py-5' : 'px-20 py-8'"
|
||||||
>
|
>
|
||||||
<img
|
<img
|
||||||
:src="data[field.name]?.file_url || data[field.name]"
|
:src="data[field.name]?.file_url || data[field.name]"
|
||||||
class="size-6 rounded"
|
class="rounded"
|
||||||
|
:class="field.size == 'lg' ? 'w-36' : 'size-6'"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex flex-col flex-wrap">
|
<div class="flex flex-col flex-wrap">
|
||||||
@@ -101,6 +103,7 @@
|
|||||||
:rows="field.rows"
|
:rows="field.rows"
|
||||||
:options="field.options"
|
:options="field.options"
|
||||||
:description="field.description"
|
:description="field.description"
|
||||||
|
:class="columns.length > 1 ? 'w-full' : 'w-1/2'"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -34,27 +34,16 @@
|
|||||||
:key="activeTab.label"
|
:key="activeTab.label"
|
||||||
class="flex flex-1 flex-col px-10 py-8 bg-surface-modal"
|
class="flex flex-1 flex-col px-10 py-8 bg-surface-modal"
|
||||||
>
|
>
|
||||||
<Members
|
<component
|
||||||
v-if="activeTab.label === 'Members'"
|
v-if="activeTab.template"
|
||||||
:label="activeTab.label"
|
:is="activeTab.template"
|
||||||
:description="activeTab.description"
|
v-bind="{
|
||||||
v-model:show="show"
|
label: activeTab.label,
|
||||||
/>
|
description: activeTab.description,
|
||||||
<Evaluators
|
...(activeTab.label === 'Branding'
|
||||||
v-else-if="activeTab.label === 'Evaluators'"
|
? { fields: activeTab.fields }
|
||||||
:label="activeTab.label"
|
: {}),
|
||||||
:description="activeTab.description"
|
}"
|
||||||
v-model:show="show"
|
|
||||||
/>
|
|
||||||
<Categories
|
|
||||||
v-else-if="activeTab.label === 'Categories'"
|
|
||||||
:label="activeTab.label"
|
|
||||||
:description="activeTab.description"
|
|
||||||
/>
|
|
||||||
<EmailTemplates
|
|
||||||
v-else-if="activeTab.label === 'Email Templates'"
|
|
||||||
:label="activeTab.label"
|
|
||||||
:description="activeTab.description"
|
|
||||||
/>
|
/>
|
||||||
<PaymentSettings
|
<PaymentSettings
|
||||||
v-else-if="activeTab.label === 'Payment Gateway'"
|
v-else-if="activeTab.label === 'Payment Gateway'"
|
||||||
@@ -63,13 +52,6 @@
|
|||||||
:data="data"
|
:data="data"
|
||||||
:fields="activeTab.fields"
|
:fields="activeTab.fields"
|
||||||
/>
|
/>
|
||||||
<BrandSettings
|
|
||||||
v-else-if="activeTab.label === 'Branding'"
|
|
||||||
:label="activeTab.label"
|
|
||||||
:description="activeTab.description"
|
|
||||||
:fields="activeTab.fields"
|
|
||||||
:data="branding"
|
|
||||||
/>
|
|
||||||
<SettingDetails
|
<SettingDetails
|
||||||
v-else
|
v-else
|
||||||
:fields="activeTab.fields"
|
:fields="activeTab.fields"
|
||||||
@@ -83,17 +65,19 @@
|
|||||||
</Dialog>
|
</Dialog>
|
||||||
</template>
|
</template>
|
||||||
<script setup>
|
<script setup>
|
||||||
import { Dialog, createDocumentResource, createResource } from 'frappe-ui'
|
import { Dialog, createDocumentResource } from 'frappe-ui'
|
||||||
import { ref, computed, watch } from 'vue'
|
import { computed, markRaw, ref, watch } from 'vue'
|
||||||
import { useSettings } from '@/stores/settings'
|
import { useSettings } from '@/stores/settings'
|
||||||
import SettingDetails from '../SettingDetails.vue'
|
import SettingDetails from '@/components/Settings/SettingDetails.vue'
|
||||||
import SidebarLink from '@/components/SidebarLink.vue'
|
import SidebarLink from '@/components/SidebarLink.vue'
|
||||||
import Members from '@/components/Members.vue'
|
import Members from '@/components/Settings/Members.vue'
|
||||||
import Evaluators from '@/components/Evaluators.vue'
|
import Evaluators from '@/components/Settings/Evaluators.vue'
|
||||||
import Categories from '@/components/Categories.vue'
|
import Categories from '@/components/Settings/Categories.vue'
|
||||||
import EmailTemplates from '@/components/EmailTemplates.vue'
|
import EmailTemplates from '@/components/Settings/EmailTemplates.vue'
|
||||||
import BrandSettings from '@/components/BrandSettings.vue'
|
import BrandSettings from '@/components/Settings/BrandSettings.vue'
|
||||||
import PaymentSettings from '@/components/PaymentSettings.vue'
|
import PaymentSettings from '@/components/Settings/PaymentSettings.vue'
|
||||||
|
import ZoomSettings from '@/components/Settings/ZoomSettings.vue'
|
||||||
|
import Badges from '@/components/Settings/Badges.vue'
|
||||||
|
|
||||||
const show = defineModel()
|
const show = defineModel()
|
||||||
const doctype = ref('LMS Settings')
|
const doctype = ref('LMS Settings')
|
||||||
@@ -108,12 +92,6 @@ const data = createDocumentResource({
|
|||||||
auto: true,
|
auto: true,
|
||||||
})
|
})
|
||||||
|
|
||||||
const branding = createResource({
|
|
||||||
url: 'lms.lms.api.get_branding',
|
|
||||||
auto: true,
|
|
||||||
cache: 'brand',
|
|
||||||
})
|
|
||||||
|
|
||||||
const tabsStructure = computed(() => {
|
const tabsStructure = computed(() => {
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
@@ -124,6 +102,13 @@ const tabsStructure = computed(() => {
|
|||||||
label: 'General',
|
label: 'General',
|
||||||
icon: 'Wrench',
|
icon: 'Wrench',
|
||||||
fields: [
|
fields: [
|
||||||
|
{
|
||||||
|
label: 'Allow Guest Access',
|
||||||
|
name: 'allow_guest_access',
|
||||||
|
description:
|
||||||
|
'If enabled, users can access the course and batch lists without logging in.',
|
||||||
|
type: 'checkbox',
|
||||||
|
},
|
||||||
{
|
{
|
||||||
label: 'Enable Learning Paths',
|
label: 'Enable Learning Paths',
|
||||||
name: 'enable_learning_paths',
|
name: 'enable_learning_paths',
|
||||||
@@ -132,11 +117,11 @@ const tabsStructure = computed(() => {
|
|||||||
type: 'checkbox',
|
type: 'checkbox',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'Allow Guest Access',
|
label: 'Prevent Skipping Videos',
|
||||||
name: 'allow_guest_access',
|
name: 'prevent_skipping_videos',
|
||||||
description:
|
|
||||||
'If enabled, users can access the course and batch lists without logging in.',
|
|
||||||
type: 'checkbox',
|
type: 'checkbox',
|
||||||
|
description:
|
||||||
|
'If enabled, users will no able to move forward in a video',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'Send calendar invite for evaluations',
|
label: 'Send calendar invite for evaluations',
|
||||||
@@ -149,13 +134,21 @@ const tabsStructure = computed(() => {
|
|||||||
type: 'Column Break',
|
type: 'Column Break',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'Batch Confirmation Template',
|
label: 'Livecode URL',
|
||||||
|
name: 'livecode_url',
|
||||||
|
doctype: 'Livecode URL',
|
||||||
|
type: 'text',
|
||||||
|
description:
|
||||||
|
'https://docs.frappe.io/learning/falcon-self-hosting-guide',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Batch Confirmation Email Template',
|
||||||
name: 'batch_confirmation_template',
|
name: 'batch_confirmation_template',
|
||||||
doctype: 'Email Template',
|
doctype: 'Email Template',
|
||||||
type: 'Link',
|
type: 'Link',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'Certification Template',
|
label: 'Certification Email Template',
|
||||||
name: 'certification_template',
|
name: 'certification_template',
|
||||||
doctype: 'Email Template',
|
doctype: 'Email Template',
|
||||||
type: 'Link',
|
type: 'Link',
|
||||||
@@ -221,33 +214,55 @@ const tabsStructure = computed(() => {
|
|||||||
items: [
|
items: [
|
||||||
{
|
{
|
||||||
label: 'Members',
|
label: 'Members',
|
||||||
description: 'Manage the members of your learning system',
|
description:
|
||||||
|
'Add new members or manage roles and permissions of existing members',
|
||||||
icon: 'UserRoundPlus',
|
icon: 'UserRoundPlus',
|
||||||
|
template: markRaw(Members),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'Evaluators',
|
label: 'Evaluators',
|
||||||
description: 'Manage the evaluators of your learning system',
|
description: '',
|
||||||
icon: 'UserCheck',
|
icon: 'UserCheck',
|
||||||
|
description:
|
||||||
|
'Add new evaluators or check the slots existing evaluators',
|
||||||
|
template: markRaw(Evaluators),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Zoom Accounts',
|
||||||
|
description:
|
||||||
|
'Manage zoom accounts to conduct live classes from batches',
|
||||||
|
icon: 'Video',
|
||||||
|
template: markRaw(ZoomSettings),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Badges',
|
||||||
|
description:
|
||||||
|
'Create badges and assign them to students to acknowledge their achievements',
|
||||||
|
icon: 'Award',
|
||||||
|
template: markRaw(Badges),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'Categories',
|
label: 'Categories',
|
||||||
description: 'Double click to edit the category',
|
description: 'Double click to edit the category',
|
||||||
icon: 'Network',
|
icon: 'Network',
|
||||||
|
template: markRaw(Categories),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'Email Templates',
|
label: 'Email Templates',
|
||||||
description: 'Manage the email templates for your learning system',
|
description: 'Manage the email templates for your learning system',
|
||||||
icon: 'MailPlus',
|
icon: 'MailPlus',
|
||||||
|
template: markRaw(EmailTemplates),
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'Customise',
|
label: 'Customize',
|
||||||
hideLabel: false,
|
hideLabel: false,
|
||||||
items: [
|
items: [
|
||||||
{
|
{
|
||||||
label: 'Branding',
|
label: 'Branding',
|
||||||
icon: 'Blocks',
|
icon: 'Blocks',
|
||||||
|
template: markRaw(BrandSettings),
|
||||||
fields: [
|
fields: [
|
||||||
{
|
{
|
||||||
label: 'Brand Name',
|
label: 'Brand Name',
|
||||||
@@ -282,8 +297,13 @@ const tabsStructure = computed(() => {
|
|||||||
type: 'checkbox',
|
type: 'checkbox',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'Certified Participants',
|
label: 'Programming Exercises',
|
||||||
name: 'certified_participants',
|
name: 'programming_exercises',
|
||||||
|
type: 'checkbox',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Certified Members',
|
||||||
|
name: 'certified_members',
|
||||||
type: 'checkbox',
|
type: 'checkbox',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -324,6 +344,9 @@ const tabsStructure = computed(() => {
|
|||||||
description:
|
description:
|
||||||
'New users will have to be manually registered by Admins.',
|
'New users will have to be manually registered by Admins.',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
type: 'Column Break',
|
||||||
|
},
|
||||||
{
|
{
|
||||||
label: 'Signup Consent HTML',
|
label: 'Signup Consent HTML',
|
||||||
name: 'custom_signup_content',
|
name: 'custom_signup_content',
|
||||||
@@ -351,12 +374,16 @@ const tabsStructure = computed(() => {
|
|||||||
type: 'textarea',
|
type: 'textarea',
|
||||||
rows: 4,
|
rows: 4,
|
||||||
description:
|
description:
|
||||||
'Keywords for search engines to find your website. Separated by commas.',
|
'Comma separated keywords for search engines to find your website.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'Column Break',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'Meta Image',
|
label: 'Meta Image',
|
||||||
name: 'meta_image',
|
name: 'meta_image',
|
||||||
type: 'Upload',
|
type: 'Upload',
|
||||||
|
size: 'lg',
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
204
frontend/src/components/Settings/ZoomSettings.vue
Normal file
204
frontend/src/components/Settings/ZoomSettings.vue
Normal file
@@ -0,0 +1,204 @@
|
|||||||
|
<template>
|
||||||
|
<div class="flex flex-col min-h-0 text-base">
|
||||||
|
<div class="flex items-center justify-between mb-5">
|
||||||
|
<div class="flex flex-col space-y-2">
|
||||||
|
<div class="text-xl font-semibold text-ink-gray-9">
|
||||||
|
{{ label }}
|
||||||
|
</div>
|
||||||
|
<div class="text-ink-gray-6 leading-5">
|
||||||
|
{{ __(description) }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center space-x-5">
|
||||||
|
<Button @click="openForm('new')">
|
||||||
|
<template #prefix>
|
||||||
|
<Plus class="h-3 w-3 stroke-1.5" />
|
||||||
|
</template>
|
||||||
|
{{ __('New') }}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-if="zoomAccounts.data?.length" class="overflow-y-scroll">
|
||||||
|
<ListView
|
||||||
|
:columns="columns"
|
||||||
|
:rows="zoomAccounts.data"
|
||||||
|
row-key="name"
|
||||||
|
:options="{
|
||||||
|
showTooltip: false,
|
||||||
|
onRowClick: (row) => {
|
||||||
|
openForm(row.name)
|
||||||
|
},
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
<ListHeader
|
||||||
|
class="mb-2 grid items-center space-x-4 rounded bg-surface-gray-2 p-2"
|
||||||
|
>
|
||||||
|
<ListHeaderItem :item="item" v-for="item in columns">
|
||||||
|
<template #prefix="{ item }">
|
||||||
|
<FeatherIcon
|
||||||
|
v-if="item.icon"
|
||||||
|
:name="item.icon"
|
||||||
|
class="h-4 w-4 stroke-1.5"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
</ListHeaderItem>
|
||||||
|
</ListHeader>
|
||||||
|
|
||||||
|
<ListRows>
|
||||||
|
<ListRow :row="row" v-for="row in zoomAccounts.data">
|
||||||
|
<template #default="{ column, item }">
|
||||||
|
<ListRowItem :item="row[column.key]" :align="column.align">
|
||||||
|
<template #prefix>
|
||||||
|
<div v-if="column.key == 'member_name'">
|
||||||
|
<Avatar
|
||||||
|
class="flex items-center"
|
||||||
|
:image="row['member_image']"
|
||||||
|
:label="item"
|
||||||
|
size="sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<div v-if="column.key == 'enabled'">
|
||||||
|
<Badge v-if="row[column.key]" theme="green">
|
||||||
|
{{ __('Enabled') }}
|
||||||
|
</Badge>
|
||||||
|
<Badge v-else theme="gray">
|
||||||
|
{{ __('Disabled') }}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
<div v-else class="leading-5 text-sm">
|
||||||
|
{{ row[column.key] }}
|
||||||
|
</div>
|
||||||
|
</ListRowItem>
|
||||||
|
</template>
|
||||||
|
</ListRow>
|
||||||
|
</ListRows>
|
||||||
|
|
||||||
|
<ListSelectBanner>
|
||||||
|
<template #actions="{ unselectAll, selections }">
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
@click="removeAccount(selections, unselectAll)"
|
||||||
|
>
|
||||||
|
<Trash2 class="h-4 w-4 stroke-1.5" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</ListSelectBanner>
|
||||||
|
</ListView>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<ZoomAccountModal
|
||||||
|
v-model="showForm"
|
||||||
|
v-model:zoomAccounts="zoomAccounts"
|
||||||
|
:accountID="currentAccount"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
<script setup lang="ts">
|
||||||
|
import {
|
||||||
|
Avatar,
|
||||||
|
Button,
|
||||||
|
Badge,
|
||||||
|
call,
|
||||||
|
createListResource,
|
||||||
|
FeatherIcon,
|
||||||
|
ListView,
|
||||||
|
ListHeader,
|
||||||
|
ListHeaderItem,
|
||||||
|
ListRows,
|
||||||
|
ListRow,
|
||||||
|
ListRowItem,
|
||||||
|
ListSelectBanner,
|
||||||
|
toast,
|
||||||
|
} from 'frappe-ui'
|
||||||
|
import { computed, inject, onMounted, ref } from 'vue'
|
||||||
|
import { Plus, Trash2 } from 'lucide-vue-next'
|
||||||
|
import { cleanError } from '@/utils'
|
||||||
|
import { User } from '@/components/Settings/types'
|
||||||
|
import ZoomAccountModal from '@/components/Modals/ZoomAccountModal.vue'
|
||||||
|
|
||||||
|
const user = inject<User | null>('$user')
|
||||||
|
const showForm = ref(false)
|
||||||
|
const currentAccount = ref<string | null>(null)
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
label: String,
|
||||||
|
description: String,
|
||||||
|
})
|
||||||
|
|
||||||
|
const zoomAccounts = createListResource({
|
||||||
|
doctype: 'LMS Zoom Settings',
|
||||||
|
fields: [
|
||||||
|
'name',
|
||||||
|
'enabled',
|
||||||
|
'member',
|
||||||
|
'member_name',
|
||||||
|
'member_image',
|
||||||
|
'account_id',
|
||||||
|
'client_id',
|
||||||
|
'client_secret',
|
||||||
|
],
|
||||||
|
cache: ['zoomAccounts'],
|
||||||
|
})
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
fetchZoomAccounts()
|
||||||
|
})
|
||||||
|
|
||||||
|
const fetchZoomAccounts = () => {
|
||||||
|
if (!user?.data?.is_moderator && !user?.data?.is_evaluator) return
|
||||||
|
|
||||||
|
if (!user?.data?.is_moderator) {
|
||||||
|
zoomAccounts.update({
|
||||||
|
filters: {
|
||||||
|
member: user.data.name,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
zoomAccounts.reload()
|
||||||
|
}
|
||||||
|
|
||||||
|
const openForm = (accountID: string) => {
|
||||||
|
currentAccount.value = accountID
|
||||||
|
showForm.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
const removeAccount = (selections, unselectAll) => {
|
||||||
|
call('lms.lms.api.delete_documents', {
|
||||||
|
doctype: 'LMS Zoom Settings',
|
||||||
|
documents: Array.from(selections),
|
||||||
|
})
|
||||||
|
.then(() => {
|
||||||
|
zoomAccounts.reload()
|
||||||
|
toast.success(__('Email Templates deleted successfully'))
|
||||||
|
unselectAll()
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
toast.error(
|
||||||
|
cleanError(err.messages[0]) || __('Error deleting email templates')
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const columns = computed(() => {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
label: __('Member'),
|
||||||
|
key: 'member_name',
|
||||||
|
icon: 'user',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: __('Account Name'),
|
||||||
|
key: 'name',
|
||||||
|
icon: 'video',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: __('Status'),
|
||||||
|
key: 'enabled',
|
||||||
|
align: 'center',
|
||||||
|
icon: 'check-square',
|
||||||
|
},
|
||||||
|
]
|
||||||
|
})
|
||||||
|
</script>
|
||||||
74
frontend/src/components/Settings/types.ts
Normal file
74
frontend/src/components/Settings/types.ts
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
export interface User {
|
||||||
|
data: {
|
||||||
|
email: string
|
||||||
|
name: string
|
||||||
|
enabled: boolean
|
||||||
|
user_image: string
|
||||||
|
full_name: string
|
||||||
|
user_type: ['System User', 'Website User']
|
||||||
|
username: string
|
||||||
|
is_moderator: boolean
|
||||||
|
is_system_manager: boolean
|
||||||
|
is_evaluator: boolean
|
||||||
|
is_instructor: boolean
|
||||||
|
is_fc_site: boolean
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Badge {
|
||||||
|
name: string;
|
||||||
|
title: string;
|
||||||
|
enabled: boolean;
|
||||||
|
description: string;
|
||||||
|
image: string;
|
||||||
|
grant_only_once: boolean;
|
||||||
|
event: string;
|
||||||
|
reference_doctype: string;
|
||||||
|
condition: string;
|
||||||
|
user_field: string;
|
||||||
|
field_to_check: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface Badges {
|
||||||
|
data: Badge[],
|
||||||
|
reload: () => void
|
||||||
|
insert: {
|
||||||
|
submit: (
|
||||||
|
data: Badge,
|
||||||
|
options: { onSuccess: () => void; onError: (err: any) => void }
|
||||||
|
) => void
|
||||||
|
},
|
||||||
|
setValue: {
|
||||||
|
submit: (
|
||||||
|
data: Badge,
|
||||||
|
options: { onSuccess: () => void; onError: (err: any) => void }
|
||||||
|
) => void
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BadgeAssignment {
|
||||||
|
name: string;
|
||||||
|
member: string;
|
||||||
|
member_name: string;
|
||||||
|
member_username: string;
|
||||||
|
member_image: string;
|
||||||
|
badge: string;
|
||||||
|
issued_on: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BadgeAssignments {
|
||||||
|
data: BadgeAssignment[],
|
||||||
|
reload: () => void
|
||||||
|
insert: {
|
||||||
|
submit: (
|
||||||
|
data: BadgeAssignment,
|
||||||
|
options: { onSuccess: () => void; onError: (err: any) => void }
|
||||||
|
) => void
|
||||||
|
},
|
||||||
|
setValue: {
|
||||||
|
submit: (
|
||||||
|
data: BadgeAssignment,
|
||||||
|
options: { onSuccess: () => void; onError: (err: any) => void }
|
||||||
|
) => void
|
||||||
|
},
|
||||||
|
}
|
||||||
@@ -61,7 +61,7 @@
|
|||||||
</button>
|
</button>
|
||||||
</template>
|
</template>
|
||||||
<script setup>
|
<script setup>
|
||||||
import { Tooltip, Button } from 'frappe-ui'
|
import { Tooltip } from 'frappe-ui'
|
||||||
import { computed } from 'vue'
|
import { computed } from 'vue'
|
||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
import * as icons from 'lucide-vue-next'
|
import * as icons from 'lucide-vue-next'
|
||||||
|
|||||||
@@ -5,10 +5,7 @@
|
|||||||
{{ __('Upcoming Evaluations') }}
|
{{ __('Upcoming Evaluations') }}
|
||||||
</div>
|
</div>
|
||||||
<Button
|
<Button
|
||||||
v-if="
|
v-if="upcoming_evals.data?.length != evaluationCourses.length"
|
||||||
!upcoming_evals.data?.length ||
|
|
||||||
upcoming_evals.length == courses.length
|
|
||||||
"
|
|
||||||
@click="openEvalModal"
|
@click="openEvalModal"
|
||||||
>
|
>
|
||||||
{{ __('Schedule Evaluation') }}
|
{{ __('Schedule Evaluation') }}
|
||||||
@@ -118,8 +115,8 @@ import {
|
|||||||
HeadsetIcon,
|
HeadsetIcon,
|
||||||
EllipsisVertical,
|
EllipsisVertical,
|
||||||
} from 'lucide-vue-next'
|
} from 'lucide-vue-next'
|
||||||
import { inject, ref, getCurrentInstance } from 'vue'
|
import { inject, ref, getCurrentInstance, computed } from 'vue'
|
||||||
import { formatTime } from '../utils'
|
import { formatTime } from '@/utils'
|
||||||
import { Button, createResource, call } from 'frappe-ui'
|
import { Button, createResource, call } from 'frappe-ui'
|
||||||
import EvaluationModal from '@/components/Modals/EvaluationModal.vue'
|
import EvaluationModal from '@/components/Modals/EvaluationModal.vue'
|
||||||
import { Menu, MenuButton, MenuItems, MenuItem } from '@headlessui/vue'
|
import { Menu, MenuButton, MenuItems, MenuItem } from '@headlessui/vue'
|
||||||
@@ -163,6 +160,12 @@ const openEvalCall = (evl) => {
|
|||||||
window.open(evl.google_meet_link, '_blank')
|
window.open(evl.google_meet_link, '_blank')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const evaluationCourses = computed(() => {
|
||||||
|
return props.courses.filter((course) => {
|
||||||
|
return course.evaluator != ''
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
const cancelEvaluation = (evl) => {
|
const cancelEvaluation = (evl) => {
|
||||||
$dialog({
|
$dialog({
|
||||||
title: __('Cancel this evaluation?'),
|
title: __('Cancel this evaluation?'),
|
||||||
|
|||||||
@@ -72,7 +72,7 @@ import { usersStore } from '@/stores/user'
|
|||||||
import { useSettings } from '@/stores/settings'
|
import { useSettings } from '@/stores/settings'
|
||||||
import { markRaw, watch, ref, onMounted, computed } from 'vue'
|
import { markRaw, watch, ref, onMounted, computed } from 'vue'
|
||||||
import { createDialog } from '@/utils/dialogs'
|
import { createDialog } from '@/utils/dialogs'
|
||||||
import SettingsModal from '@/components/Modals/Settings.vue'
|
import SettingsModal from '@/components/Settings/Settings.vue'
|
||||||
import FrappeCloudIcon from '@/components/Icons/FrappeCloudIcon.vue'
|
import FrappeCloudIcon from '@/components/Icons/FrappeCloudIcon.vue'
|
||||||
import {
|
import {
|
||||||
ChevronDown,
|
ChevronDown,
|
||||||
|
|||||||
@@ -1,5 +1,25 @@
|
|||||||
<template>
|
<template>
|
||||||
<div ref="videoContainer" class="video-block relative group">
|
<div>
|
||||||
|
<div v-if="quizzes.length && !showQuiz && readOnly" class="leading-5">
|
||||||
|
{{
|
||||||
|
__('This video contains {0} {1}:').format(
|
||||||
|
quizzes.length,
|
||||||
|
quizzes.length == 1 ? 'quiz' : 'quizzes'
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
|
||||||
|
<div v-for="(quiz, index) in quizzes" class="pl-3 mt-1">
|
||||||
|
<span>
|
||||||
|
{{ index + 1 }}. <span class="font-semibold"> {{ quiz.quiz }} </span>
|
||||||
|
</span>
|
||||||
|
{{ __('at {0} minutes').format(formatTimestamp(quiz.time)) }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-if="!showQuiz"
|
||||||
|
ref="videoContainer"
|
||||||
|
class="video-block relative group"
|
||||||
|
>
|
||||||
<video
|
<video
|
||||||
@timeupdate="updateTime"
|
@timeupdate="updateTime"
|
||||||
@ended="videoEnded"
|
@ended="videoEnded"
|
||||||
@@ -7,9 +27,9 @@
|
|||||||
oncontextmenu="return false"
|
oncontextmenu="return false"
|
||||||
class="rounded-md border border-gray-100 cursor-pointer"
|
class="rounded-md border border-gray-100 cursor-pointer"
|
||||||
ref="videoRef"
|
ref="videoRef"
|
||||||
>
|
:src="fileURL"
|
||||||
<source :src="fileURL" :type="type" />
|
:type="type"
|
||||||
</video>
|
></video>
|
||||||
<div
|
<div
|
||||||
v-if="!playing"
|
v-if="!playing"
|
||||||
class="absolute inset-0 flex items-center justify-center cursor-pointer"
|
class="absolute inset-0 flex items-center justify-center cursor-pointer"
|
||||||
@@ -34,7 +54,7 @@
|
|||||||
'invisible group-hover:visible': playing,
|
'invisible group-hover:visible': playing,
|
||||||
}"
|
}"
|
||||||
>
|
>
|
||||||
<Button variant="ghost">
|
<Button variant="ghost" class="hover:bg-transparent">
|
||||||
<template #icon>
|
<template #icon>
|
||||||
<Play
|
<Play
|
||||||
v-if="!playing"
|
v-if="!playing"
|
||||||
@@ -44,12 +64,8 @@
|
|||||||
<Pause v-else @click="pauseVideo" class="size-5 text-ink-white" />
|
<Pause v-else @click="pauseVideo" class="size-5 text-ink-white" />
|
||||||
</template>
|
</template>
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant="ghost" @click="toggleMute">
|
|
||||||
<template #icon>
|
<div class="relative flex items-center w-full flex-1">
|
||||||
<Volume2 v-if="!muted" class="size-5 text-ink-white" />
|
|
||||||
<VolumeX v-else class="size-5 text-ink-white" />
|
|
||||||
</template>
|
|
||||||
</Button>
|
|
||||||
<input
|
<input
|
||||||
type="range"
|
type="range"
|
||||||
min="0"
|
min="0"
|
||||||
@@ -57,24 +73,91 @@
|
|||||||
step="0.1"
|
step="0.1"
|
||||||
v-model="currentTime"
|
v-model="currentTime"
|
||||||
@input="changeCurrentTime"
|
@input="changeCurrentTime"
|
||||||
class="duration-slider w-full h-1"
|
class="duration-slider h-1"
|
||||||
/>
|
/>
|
||||||
<span class="text-sm font-semibold">
|
<!-- QUIZ MARKERS -->
|
||||||
{{ formatTime(currentTime) }} / {{ formatTime(duration) }}
|
<div class="absolute top-0 left-0 w-full h-full pointer-events-none">
|
||||||
|
<div
|
||||||
|
v-for="(quiz, index) in quizzes"
|
||||||
|
:key="index"
|
||||||
|
:style="getQuizMarkerStyle(quiz.time)"
|
||||||
|
class="absolute top-0 h-full w-2 bg-surface-amber-3"
|
||||||
|
></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<span class="text-sm font-medium">
|
||||||
|
{{ formatSeconds(currentTime) }} / {{ formatSeconds(duration) }}
|
||||||
</span>
|
</span>
|
||||||
<Button variant="ghost" @click="toggleFullscreen">
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
@click="toggleMute"
|
||||||
|
class="hover:bg-transparent"
|
||||||
|
>
|
||||||
|
<template #icon>
|
||||||
|
<Volume2 v-if="!muted" class="size-5 text-ink-white" />
|
||||||
|
<VolumeX v-else class="size-5 text-ink-white" />
|
||||||
|
</template>
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
@click="toggleFullscreen"
|
||||||
|
class="hover:bg-transparent"
|
||||||
|
>
|
||||||
<template #icon>
|
<template #icon>
|
||||||
<Maximize class="size-5 text-ink-white" />
|
<Maximize class="size-5 text-ink-white" />
|
||||||
</template>
|
</template>
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<Quiz
|
||||||
|
v-if="showQuiz"
|
||||||
|
:quizName="currentQuiz"
|
||||||
|
:inVideo="true"
|
||||||
|
:backToVideo="resumeVideo"
|
||||||
|
/>
|
||||||
|
<div v-if="!readOnly" @click="showQuizModal = true">
|
||||||
|
<Button>
|
||||||
|
{{ __('Add Quiz to Video') }}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<QuizInVideo
|
||||||
|
v-model="showQuizModal"
|
||||||
|
:quizzes="quizzes"
|
||||||
|
:saveQuizzes="saveQuizzes"
|
||||||
|
:duration="duration"
|
||||||
|
/>
|
||||||
|
<Dialog
|
||||||
|
v-model="showQuizLoader"
|
||||||
|
:options="{
|
||||||
|
size: 'sm',
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
<template #body>
|
||||||
|
<div class="flex flex-col space-y-2 p-5 text-base leading-5">
|
||||||
|
<span class="font-semibold">
|
||||||
|
{{ __('Time for a Quiz') }}
|
||||||
|
</span>
|
||||||
|
<span>
|
||||||
|
{{
|
||||||
|
__(
|
||||||
|
'Complete the upcoming quiz to continue watching the video. The quiz will open in {0} {1}.'
|
||||||
|
).format(quizLoadTimer, quizLoadTimer === 1 ? 'second' : 'seconds')
|
||||||
|
}}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</Dialog>
|
||||||
</template>
|
</template>
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref, onMounted, computed } from 'vue'
|
import { ref, onMounted, computed, watch } from 'vue'
|
||||||
import { Pause, Maximize, Volume2, VolumeX } from 'lucide-vue-next'
|
import { Pause, Maximize, Volume2, VolumeX } from 'lucide-vue-next'
|
||||||
import { Button } from 'frappe-ui'
|
import { Button, Dialog } from 'frappe-ui'
|
||||||
|
import { formatSeconds, formatTimestamp } from '@/utils'
|
||||||
|
import { useSettings } from '@/stores/settings'
|
||||||
import Play from '@/components/Icons/Play.vue'
|
import Play from '@/components/Icons/Play.vue'
|
||||||
|
import QuizInVideo from '@/components/Modals/QuizInVideo.vue'
|
||||||
|
|
||||||
const videoRef = ref(null)
|
const videoRef = ref(null)
|
||||||
const videoContainer = ref(null)
|
const videoContainer = ref(null)
|
||||||
@@ -82,6 +165,13 @@ let playing = ref(false)
|
|||||||
let currentTime = ref(0)
|
let currentTime = ref(0)
|
||||||
let duration = ref(0)
|
let duration = ref(0)
|
||||||
let muted = ref(false)
|
let muted = ref(false)
|
||||||
|
const showQuizModal = ref(false)
|
||||||
|
const showQuiz = ref(false)
|
||||||
|
const showQuizLoader = ref(false)
|
||||||
|
const quizLoadTimer = ref(0)
|
||||||
|
const currentQuiz = ref(null)
|
||||||
|
const nextQuiz = ref({})
|
||||||
|
const { preventSkippingVideos } = useSettings()
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
file: {
|
file: {
|
||||||
@@ -92,34 +182,94 @@ const props = defineProps({
|
|||||||
type: String,
|
type: String,
|
||||||
default: 'video/mp4',
|
default: 'video/mp4',
|
||||||
},
|
},
|
||||||
|
readOnly: {
|
||||||
|
type: Boolean,
|
||||||
|
default: true,
|
||||||
|
},
|
||||||
|
quizzes: {
|
||||||
|
type: Array,
|
||||||
|
default: () => [],
|
||||||
|
},
|
||||||
|
saveQuizzes: {
|
||||||
|
type: Function,
|
||||||
|
default: () => {},
|
||||||
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
|
updateCurrentTime()
|
||||||
|
updateNextQuiz()
|
||||||
|
})
|
||||||
|
|
||||||
|
const updateCurrentTime = () => {
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
videoRef.value.onloadedmetadata = () => {
|
videoRef.value.onloadedmetadata = () => {
|
||||||
duration.value = videoRef.value.duration
|
duration.value = videoRef.value.duration
|
||||||
}
|
}
|
||||||
videoRef.value.ontimeupdate = () => {
|
videoRef.value.ontimeupdate = () => {
|
||||||
currentTime.value = videoRef.value.currentTime
|
currentTime.value = videoRef.value?.currentTime || currentTime.value
|
||||||
|
if (currentTime.value >= nextQuiz.value.time) {
|
||||||
|
videoRef.value.pause()
|
||||||
|
playing.value = false
|
||||||
|
videoRef.value.onTimeupdate = null
|
||||||
|
currentQuiz.value = nextQuiz.value.quiz
|
||||||
|
quizLoadTimer.value = 7
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}, 0)
|
}, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(quizLoadTimer, () => {
|
||||||
|
if (quizLoadTimer.value > 0) {
|
||||||
|
showQuizLoader.value = true
|
||||||
|
setTimeout(() => {
|
||||||
|
quizLoadTimer.value -= 1
|
||||||
|
}, 1000)
|
||||||
|
} else {
|
||||||
|
showQuizLoader.value = false
|
||||||
|
showQuiz.value = true
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const resumeVideo = (restart = false) => {
|
||||||
|
showQuiz.value = false
|
||||||
|
currentQuiz.value = null
|
||||||
|
updateCurrentTime()
|
||||||
|
setTimeout(() => {
|
||||||
|
videoRef.value.currentTime = restart ? 0 : currentTime.value
|
||||||
|
videoRef.value.play()
|
||||||
|
playing.value = true
|
||||||
|
updateNextQuiz()
|
||||||
|
}, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateNextQuiz = () => {
|
||||||
|
if (!props.quizzes.length) return
|
||||||
|
|
||||||
|
props.quizzes.forEach((quiz) => {
|
||||||
|
if (typeof quiz.time == 'string' && quiz.time.includes(':')) {
|
||||||
|
let time = quiz.time.split(':')
|
||||||
|
let timeInSeconds = parseInt(time[0]) * 60 + parseInt(time[1])
|
||||||
|
quiz.time = timeInSeconds
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
props.quizzes.sort((a, b) => a.time - b.time)
|
||||||
|
|
||||||
|
const nextQuizIndex = props.quizzes.findIndex(
|
||||||
|
(quiz) => quiz.time > currentTime.value
|
||||||
|
)
|
||||||
|
if (nextQuizIndex !== -1) {
|
||||||
|
nextQuiz.value = props.quizzes[nextQuizIndex]
|
||||||
|
} else {
|
||||||
|
nextQuiz.value = {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const fileURL = computed(() => {
|
const fileURL = computed(() => {
|
||||||
if (isYoutube) {
|
|
||||||
let url = props.file
|
|
||||||
if (url.includes('watch?v=')) {
|
|
||||||
url = url.replace('watch?v=', 'embed/')
|
|
||||||
}
|
|
||||||
return `${url}?autoplay=0&controls=0&disablekb=1&playsinline=1&cc_load_policy=1&cc_lang_pref=auto`
|
|
||||||
}
|
|
||||||
return props.file
|
return props.file
|
||||||
})
|
})
|
||||||
|
|
||||||
const isYoutube = computed(() => {
|
|
||||||
return props.type == 'video/youtube'
|
|
||||||
})
|
|
||||||
|
|
||||||
const playVideo = () => {
|
const playVideo = () => {
|
||||||
videoRef.value.play()
|
videoRef.value.play()
|
||||||
playing.value = true
|
playing.value = true
|
||||||
@@ -148,13 +298,13 @@ const toggleMute = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const changeCurrentTime = () => {
|
const changeCurrentTime = () => {
|
||||||
|
if (
|
||||||
|
preventSkippingVideos.data &&
|
||||||
|
currentTime.value > videoRef.value.currentTime
|
||||||
|
)
|
||||||
|
return
|
||||||
videoRef.value.currentTime = currentTime.value
|
videoRef.value.currentTime = currentTime.value
|
||||||
}
|
updateNextQuiz()
|
||||||
|
|
||||||
const formatTime = (time) => {
|
|
||||||
const minutes = Math.floor(time / 60)
|
|
||||||
const seconds = Math.floor(time % 60)
|
|
||||||
return `${minutes}:${seconds < 10 ? '0' : ''}${seconds}`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const toggleFullscreen = () => {
|
const toggleFullscreen = () => {
|
||||||
@@ -164,6 +314,13 @@ const toggleFullscreen = () => {
|
|||||||
videoContainer.value.requestFullscreen()
|
videoContainer.value.requestFullscreen()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const getQuizMarkerStyle = (time) => {
|
||||||
|
const percentage = ((time - 5) / Math.ceil(duration.value)) * 100
|
||||||
|
return {
|
||||||
|
left: `${percentage}%`,
|
||||||
|
}
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
@@ -183,11 +340,10 @@ iframe {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.duration-slider {
|
.duration-slider {
|
||||||
flex: 1;
|
|
||||||
-webkit-appearance: none;
|
-webkit-appearance: none;
|
||||||
appearance: none;
|
appearance: none;
|
||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
background-color: theme('colors.gray.100');
|
background-color: theme('colors.gray.600');
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -195,20 +351,20 @@ iframe {
|
|||||||
width: 2px;
|
width: 2px;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
-webkit-appearance: none;
|
-webkit-appearance: none;
|
||||||
background-color: theme('colors.gray.500');
|
background-color: theme('colors.white');
|
||||||
}
|
}
|
||||||
|
|
||||||
@media screen and (-webkit-min-device-pixel-ratio: 0) {
|
@media screen and (-webkit-min-device-pixel-ratio: 0) {
|
||||||
input[type='range'] {
|
input[type='range'] {
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
width: 150px;
|
width: 100%;
|
||||||
-webkit-appearance: none;
|
-webkit-appearance: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
input[type='range']::-webkit-slider-thumb {
|
input[type='range']::-webkit-slider-thumb {
|
||||||
-webkit-appearance: none;
|
-webkit-appearance: none;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
box-shadow: -500px 0 0 500px theme('colors.gray.600');
|
box-shadow: -500px 0 0 500px theme('colors.white');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
11
frontend/src/global.d.ts
vendored
Normal file
11
frontend/src/global.d.ts
vendored
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
export {}
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
function __(text: string): string
|
||||||
|
}
|
||||||
|
|
||||||
|
declare module 'vue' {
|
||||||
|
interface ComponentCustomProperties {
|
||||||
|
__: (text: string) => string
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,2 +1,3 @@
|
|||||||
@import './assets/Inter/inter.css';
|
@import './assets/Inter/inter.css';
|
||||||
@import 'frappe-ui/src/style.css';
|
@import 'frappe-ui/src/style.css';
|
||||||
|
@import './styles/codemirror.css';
|
||||||
@@ -145,7 +145,6 @@ const submissions = createListResource({
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
// watch changes in assignmentID, member, and status and if changes in any then reload submissions. Also update the url query params for the same
|
|
||||||
watch([assignmentID, member, status], () => {
|
watch([assignmentID, member, status], () => {
|
||||||
router.push({
|
router.push({
|
||||||
query: {
|
query: {
|
||||||
|
|||||||
@@ -16,20 +16,17 @@
|
|||||||
<template #prefix>
|
<template #prefix>
|
||||||
<Plus class="w-4 h-4" />
|
<Plus class="w-4 h-4" />
|
||||||
</template>
|
</template>
|
||||||
{{ __('New') }}
|
{{ __('Create') }}
|
||||||
</Button>
|
</Button>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div class="md:w-3/4 md:mx-auto py-5 mx-5">
|
<div class="md:w-3/4 md:mx-auto py-5 mx-5">
|
||||||
<div class="flex items-center justify-between mb-5">
|
<div class="flex items-center justify-between mb-5">
|
||||||
<div
|
<div v-if="assignmentCount" class="text-lg font-semibold text-ink-gray-9">
|
||||||
v-if="assignmentCount"
|
|
||||||
class="text-xl font-semibold text-ink-gray-7 mb-4"
|
|
||||||
>
|
|
||||||
{{ __('{0} Assignments').format(assignmentCount) }}
|
{{ __('{0} Assignments').format(assignmentCount) }}
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
v-if="assignments.data?.length || assigmentCount > 0"
|
v-if="assignments.data?.length || assignmentCount > 0"
|
||||||
class="grid grid-cols-2 gap-5"
|
class="grid grid-cols-2 gap-5"
|
||||||
>
|
>
|
||||||
<FormControl
|
<FormControl
|
||||||
|
|||||||
@@ -70,7 +70,10 @@
|
|||||||
<BatchStudents :batch="batch" />
|
<BatchStudents :batch="batch" />
|
||||||
</div>
|
</div>
|
||||||
<div v-else-if="tab.label == 'Classes'">
|
<div v-else-if="tab.label == 'Classes'">
|
||||||
<LiveClass :batch="batch.data.name" />
|
<LiveClass
|
||||||
|
:batch="batch.data.name"
|
||||||
|
:zoomAccount="batch.data.zoom_account"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div v-else-if="tab.label == 'Assessments'">
|
<div v-else-if="tab.label == 'Assessments'">
|
||||||
<Assessments :batch="batch.data.name" />
|
<Assessments :batch="batch.data.name" />
|
||||||
@@ -121,7 +124,7 @@
|
|||||||
:endDate="batch.data.end_date"
|
:endDate="batch.data.end_date"
|
||||||
class="mb-3"
|
class="mb-3"
|
||||||
/>
|
/>
|
||||||
<div class="flex items-center mb-4 text-ink-gray-7">
|
<div class="flex items-center mb-3 text-ink-gray-7">
|
||||||
<Clock class="h-4 w-4 stroke-1.5 mr-2" />
|
<Clock class="h-4 w-4 stroke-1.5 mr-2" />
|
||||||
<span>
|
<span>
|
||||||
{{ formatTime(batch.data.start_time) }} -
|
{{ formatTime(batch.data.start_time) }} -
|
||||||
@@ -130,7 +133,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
v-if="batch.data.timezone"
|
v-if="batch.data.timezone"
|
||||||
class="flex items-center mb-4 text-ink-gray-7"
|
class="flex items-center mb-3 text-ink-gray-7"
|
||||||
>
|
>
|
||||||
<Globe class="h-4 w-4 stroke-1.5 mr-2" />
|
<Globe class="h-4 w-4 stroke-1.5 mr-2" />
|
||||||
<span>
|
<span>
|
||||||
|
|||||||
@@ -4,9 +4,16 @@
|
|||||||
class="sticky top-0 z-10 flex items-center justify-between border-b bg-surface-white px-3 py-2.5 sm:px-5"
|
class="sticky top-0 z-10 flex items-center justify-between border-b bg-surface-white px-3 py-2.5 sm:px-5"
|
||||||
>
|
>
|
||||||
<Breadcrumbs class="h-7" :items="breadcrumbs" />
|
<Breadcrumbs class="h-7" :items="breadcrumbs" />
|
||||||
|
<div class="flex items-center space-x-2">
|
||||||
|
<Button v-if="batchDetail.data?.name" @click="deleteBatch">
|
||||||
|
<template #icon>
|
||||||
|
<Trash2 class="size-4 stroke-1.5" />
|
||||||
|
</template>
|
||||||
|
</Button>
|
||||||
<Button variant="solid" @click="saveBatch()">
|
<Button variant="solid" @click="saveBatch()">
|
||||||
{{ __('Save') }}
|
{{ __('Save') }}
|
||||||
</Button>
|
</Button>
|
||||||
|
</div>
|
||||||
</header>
|
</header>
|
||||||
<div class="py-5">
|
<div class="py-5">
|
||||||
<div class="px-20 pb-5 space-y-5 border-b mb-5">
|
<div class="px-20 pb-5 space-y-5 border-b mb-5">
|
||||||
@@ -23,10 +30,10 @@
|
|||||||
/>
|
/>
|
||||||
<MultiSelect
|
<MultiSelect
|
||||||
v-model="instructors"
|
v-model="instructors"
|
||||||
doctype="User"
|
doctype="Course Evaluator"
|
||||||
:label="__('Instructors')"
|
:label="__('Instructors')"
|
||||||
:required="true"
|
:required="true"
|
||||||
:onCreate="(close) => openSettings('Members', close)"
|
:onCreate="(close) => openSettings('Evaluators', close)"
|
||||||
:filters="{ ignore_user_type: 1 }"
|
:filters="{ ignore_user_type: 1 }"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -159,6 +166,16 @@
|
|||||||
}
|
}
|
||||||
"
|
"
|
||||||
/>
|
/>
|
||||||
|
<Link
|
||||||
|
doctype="LMS Zoom Settings"
|
||||||
|
:label="__('Zoom Account')"
|
||||||
|
v-model="batch.zoom_account"
|
||||||
|
:onCreate="
|
||||||
|
(value, close) => {
|
||||||
|
openSettings('Zoom Accounts', close)
|
||||||
|
}
|
||||||
|
"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div class="space-y-5">
|
<div class="space-y-5">
|
||||||
<FormControl
|
<FormControl
|
||||||
@@ -199,7 +216,10 @@
|
|||||||
v-slot="{ file, progress, uploading, openFileSelector }"
|
v-slot="{ file, progress, uploading, openFileSelector }"
|
||||||
>
|
>
|
||||||
<div class="flex items-center">
|
<div class="flex items-center">
|
||||||
<div class="border rounded-md w-fit py-5 px-20">
|
<div
|
||||||
|
class="border rounded-md w-fit py-5 px-20 cursor-pointer"
|
||||||
|
@click="openFileSelector"
|
||||||
|
>
|
||||||
<Image class="size-5 stroke-1 text-ink-gray-7" />
|
<Image class="size-5 stroke-1 text-ink-gray-7" />
|
||||||
</div>
|
</div>
|
||||||
<div class="ml-4">
|
<div class="ml-4">
|
||||||
@@ -263,16 +283,38 @@
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="px-20 pb-5 space-y-5 border-b">
|
||||||
|
<div class="text-lg text-ink-gray-9 font-semibold">
|
||||||
|
{{ __('Meta Tags') }}
|
||||||
|
</div>
|
||||||
|
<div class="space-y-5">
|
||||||
|
<FormControl
|
||||||
|
v-model="meta.description"
|
||||||
|
:label="__('Meta Description')"
|
||||||
|
type="textarea"
|
||||||
|
:rows="7"
|
||||||
|
/>
|
||||||
|
<FormControl
|
||||||
|
v-model="meta.keywords"
|
||||||
|
:label="__('Meta Keywords')"
|
||||||
|
type="textarea"
|
||||||
|
:rows="7"
|
||||||
|
:placeholder="__('Comma separated keywords for SEO')"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<script setup>
|
<script setup>
|
||||||
import {
|
import {
|
||||||
computed,
|
computed,
|
||||||
onMounted,
|
getCurrentInstance,
|
||||||
inject,
|
inject,
|
||||||
reactive,
|
onMounted,
|
||||||
onBeforeUnmount,
|
onBeforeUnmount,
|
||||||
|
reactive,
|
||||||
ref,
|
ref,
|
||||||
} from 'vue'
|
} from 'vue'
|
||||||
import {
|
import {
|
||||||
@@ -284,20 +326,30 @@ import {
|
|||||||
createResource,
|
createResource,
|
||||||
usePageMeta,
|
usePageMeta,
|
||||||
toast,
|
toast,
|
||||||
|
call,
|
||||||
|
Toast,
|
||||||
} from 'frappe-ui'
|
} from 'frappe-ui'
|
||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
import { Image } from 'lucide-vue-next'
|
import { Image, Trash2 } from 'lucide-vue-next'
|
||||||
import { capture } from '@/telemetry'
|
import { capture } from '@/telemetry'
|
||||||
import { useOnboarding } from 'frappe-ui/frappe'
|
import { useOnboarding } from 'frappe-ui/frappe'
|
||||||
import { sessionStore } from '../stores/session'
|
import { sessionStore } from '../stores/session'
|
||||||
import MultiSelect from '@/components/Controls/MultiSelect.vue'
|
import MultiSelect from '@/components/Controls/MultiSelect.vue'
|
||||||
import Link from '@/components/Controls/Link.vue'
|
import Link from '@/components/Controls/Link.vue'
|
||||||
import { openSettings } from '@/utils'
|
import {
|
||||||
|
openSettings,
|
||||||
|
getMetaInfo,
|
||||||
|
updateMetaInfo,
|
||||||
|
validateFile,
|
||||||
|
} from '@/utils'
|
||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const user = inject('$user')
|
const user = inject('$user')
|
||||||
const { brand } = sessionStore()
|
const { brand } = sessionStore()
|
||||||
const { updateOnboardingStep } = useOnboarding('learning')
|
const { updateOnboardingStep } = useOnboarding('learning')
|
||||||
|
const instructors = ref([])
|
||||||
|
const app = getCurrentInstance()
|
||||||
|
const { $dialog } = app.appContext.config.globalProperties
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
batchName: {
|
batchName: {
|
||||||
@@ -327,20 +379,29 @@ const batch = reactive({
|
|||||||
paid_batch: false,
|
paid_batch: false,
|
||||||
currency: '',
|
currency: '',
|
||||||
amount: 0,
|
amount: 0,
|
||||||
|
zoom_account: '',
|
||||||
})
|
})
|
||||||
|
|
||||||
const instructors = ref([])
|
const meta = reactive({
|
||||||
|
description: '',
|
||||||
|
keywords: '',
|
||||||
|
})
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
if (!user.data) window.location.href = '/login'
|
if (!user.data) window.location.href = '/login'
|
||||||
if (props.batchName != 'new') {
|
if (props.batchName != 'new') {
|
||||||
batchDetail.reload()
|
fetchBatchInfo()
|
||||||
} else {
|
} else {
|
||||||
capture('batch_form_opened')
|
capture('batch_form_opened')
|
||||||
}
|
}
|
||||||
window.addEventListener('keydown', keyboardShortcut)
|
window.addEventListener('keydown', keyboardShortcut)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const fetchBatchInfo = () => {
|
||||||
|
batchDetail.reload()
|
||||||
|
getMetaInfo('batches', props.batchName, meta)
|
||||||
|
}
|
||||||
|
|
||||||
const keyboardShortcut = (e) => {
|
const keyboardShortcut = (e) => {
|
||||||
if (
|
if (
|
||||||
e.key === 's' &&
|
e.key === 's' &&
|
||||||
@@ -454,7 +515,7 @@ const createNewBatch = () => {
|
|||||||
localStorage.setItem('firstBatch', data.name)
|
localStorage.setItem('firstBatch', data.name)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
updateMetaInfo('batches', data.name, meta)
|
||||||
capture('batch_created')
|
capture('batch_created')
|
||||||
router.push({
|
router.push({
|
||||||
name: 'BatchDetail',
|
name: 'BatchDetail',
|
||||||
@@ -475,6 +536,7 @@ const editBatchDetails = () => {
|
|||||||
{},
|
{},
|
||||||
{
|
{
|
||||||
onSuccess(data) {
|
onSuccess(data) {
|
||||||
|
updateMetaInfo('batches', data.name, meta)
|
||||||
router.push({
|
router.push({
|
||||||
name: 'BatchDetail',
|
name: 'BatchDetail',
|
||||||
params: {
|
params: {
|
||||||
@@ -489,6 +551,38 @@ const editBatchDetails = () => {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const deleteBatch = () => {
|
||||||
|
$dialog({
|
||||||
|
title: __('Confirm your action to delete'),
|
||||||
|
message: __(
|
||||||
|
'Deleting this batch will also delete all its data including enrolled students, linked courses, assessments, feedback and discussions. Are you sure you want to continue?'
|
||||||
|
),
|
||||||
|
actions: [
|
||||||
|
{
|
||||||
|
label: __('Delete'),
|
||||||
|
theme: 'red',
|
||||||
|
variant: 'solid',
|
||||||
|
onClick({ close }) {
|
||||||
|
trashBatch(close)
|
||||||
|
close()
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const trashBatch = (close) => {
|
||||||
|
call('lms.lms.api.delete_batch', {
|
||||||
|
batch: props.batchName,
|
||||||
|
}).then(() => {
|
||||||
|
toast.success(__('Batch deleted successfully'))
|
||||||
|
close()
|
||||||
|
router.push({
|
||||||
|
name: 'Batches',
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
const saveImage = (file) => {
|
const saveImage = (file) => {
|
||||||
batch.image = file
|
batch.image = file
|
||||||
}
|
}
|
||||||
@@ -497,13 +591,6 @@ const removeImage = () => {
|
|||||||
batch.image = null
|
batch.image = null
|
||||||
}
|
}
|
||||||
|
|
||||||
const validateFile = (file) => {
|
|
||||||
let extension = file.name.split('.').pop().toLowerCase()
|
|
||||||
if (!['jpg', 'jpeg', 'png'].includes(extension)) {
|
|
||||||
return 'Only image file is allowed.'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const breadcrumbs = computed(() => {
|
const breadcrumbs = computed(() => {
|
||||||
let crumbs = [
|
let crumbs = [
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -14,7 +14,7 @@
|
|||||||
<template #prefix>
|
<template #prefix>
|
||||||
<Plus class="h-4 w-4 stroke-1.5" />
|
<Plus class="h-4 w-4 stroke-1.5" />
|
||||||
</template>
|
</template>
|
||||||
{{ __('New') }}
|
{{ __('Create') }}
|
||||||
</Button>
|
</Button>
|
||||||
</router-link>
|
</router-link>
|
||||||
</header>
|
</header>
|
||||||
|
|||||||
@@ -80,7 +80,7 @@
|
|||||||
/>
|
/>
|
||||||
<FormControl :label="__('City')" v-model="billingDetails.city" />
|
<FormControl :label="__('City')" v-model="billingDetails.city" />
|
||||||
<FormControl
|
<FormControl
|
||||||
:label="__('State')"
|
:label="__('State/Province')"
|
||||||
v-model="billingDetails.state"
|
v-model="billingDetails.state"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -303,6 +303,7 @@ const validateAddress = () => {
|
|||||||
'Gujarat',
|
'Gujarat',
|
||||||
'Haryana',
|
'Haryana',
|
||||||
'Himachal Pradesh',
|
'Himachal Pradesh',
|
||||||
|
'Jammu and Kashmir',
|
||||||
'Jharkhand',
|
'Jharkhand',
|
||||||
'Karnataka',
|
'Karnataka',
|
||||||
'Kerala',
|
'Kerala',
|
||||||
|
|||||||
@@ -12,10 +12,7 @@
|
|||||||
</Button>
|
</Button>
|
||||||
</router-link>
|
</router-link>
|
||||||
</header>
|
</header>
|
||||||
<div
|
<div class="mx-auto w-full max-w-4xl pt-6 pb-10">
|
||||||
v-if="participants.data?.length"
|
|
||||||
class="mx-auto w-full max-w-4xl pt-6 pb-10"
|
|
||||||
>
|
|
||||||
<div class="flex flex-col md:flex-row justify-between mb-4 px-3">
|
<div class="flex flex-col md:flex-row justify-between mb-4 px-3">
|
||||||
<div class="text-xl font-semibold text-ink-gray-7 mb-4 md:mb-0">
|
<div class="text-xl font-semibold text-ink-gray-7 mb-4 md:mb-0">
|
||||||
{{ memberCount }} {{ __('certified members') }}
|
{{ memberCount }} {{ __('certified members') }}
|
||||||
@@ -41,7 +38,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="divide-y">
|
<div v-if="participants.data?.length" class="divide-y">
|
||||||
<template v-for="participant in participants.data">
|
<template v-for="participant in participants.data">
|
||||||
<router-link
|
<router-link
|
||||||
:to="{
|
:to="{
|
||||||
@@ -92,6 +89,7 @@
|
|||||||
</router-link>
|
</router-link>
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
|
<EmptyState v-else type="Certified Members" />
|
||||||
<div
|
<div
|
||||||
v-if="!participants.list.loading && participants.hasNextPage"
|
v-if="!participants.list.loading && participants.hasNextPage"
|
||||||
class="flex justify-center mt-5"
|
class="flex justify-center mt-5"
|
||||||
@@ -101,7 +99,6 @@
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<EmptyState v-else type="Certified Members" />
|
|
||||||
</template>
|
</template>
|
||||||
<script setup>
|
<script setup>
|
||||||
import {
|
import {
|
||||||
@@ -127,22 +124,25 @@ const memberCount = ref(0)
|
|||||||
const dayjs = inject('$dayjs')
|
const dayjs = inject('$dayjs')
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
|
getMemberCount()
|
||||||
updateParticipants()
|
updateParticipants()
|
||||||
})
|
})
|
||||||
|
|
||||||
const participants = createListResource({
|
const participants = createListResource({
|
||||||
doctype: 'LMS Certificate',
|
doctype: 'LMS Certificate',
|
||||||
url: 'lms.lms.api.get_certified_participants',
|
url: 'lms.lms.api.get_certified_participants',
|
||||||
cache: ['certified_participants'],
|
|
||||||
start: 0,
|
start: 0,
|
||||||
pageLength: 30,
|
cache: ['certified_participants'],
|
||||||
|
pageLength: 100,
|
||||||
})
|
})
|
||||||
|
|
||||||
const count = call('lms.lms.api.get_count_of_certified_members').then(
|
const getMemberCount = () => {
|
||||||
(data) => {
|
call('lms.lms.api.get_count_of_certified_members', {
|
||||||
|
filters: filters.value,
|
||||||
|
}).then((data) => {
|
||||||
memberCount.value = data
|
memberCount.value = data
|
||||||
|
})
|
||||||
}
|
}
|
||||||
)
|
|
||||||
|
|
||||||
const categories = createListResource({
|
const categories = createListResource({
|
||||||
doctype: 'LMS Certificate',
|
doctype: 'LMS Certificate',
|
||||||
@@ -157,6 +157,7 @@ const categories = createListResource({
|
|||||||
|
|
||||||
const updateParticipants = () => {
|
const updateParticipants = () => {
|
||||||
updateFilters()
|
updateFilters()
|
||||||
|
getMemberCount()
|
||||||
participants.update({
|
participants.update({
|
||||||
filters: filters.value,
|
filters: filters.value,
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
<Breadcrumbs class="h-7" :items="breadcrumbs" />
|
<Breadcrumbs class="h-7" :items="breadcrumbs" />
|
||||||
</header>
|
</header>
|
||||||
<div class="m-5">
|
<div class="m-5">
|
||||||
<div class="flex justify-between w-full">
|
<div class="flex justify-between w-full space-x-5">
|
||||||
<div class="md:w-2/3">
|
<div class="md:w-2/3">
|
||||||
<div class="text-3xl font-semibold text-ink-gray-9">
|
<div class="text-3xl font-semibold text-ink-gray-9">
|
||||||
{{ course.data.title }}
|
{{ course.data.title }}
|
||||||
@@ -66,7 +66,9 @@
|
|||||||
{{ tag }}
|
{{ tag }}
|
||||||
</Badge>
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
<CourseCardOverlay :course="course" class="md:hidden mb-4" />
|
<div class="md:hidden mb-4">
|
||||||
|
<CourseCardOverlay :course="course" />
|
||||||
|
</div>
|
||||||
<div
|
<div
|
||||||
v-html="course.data.description"
|
v-html="course.data.description"
|
||||||
class="ProseMirror prose prose-table:table-fixed prose-td:p-2 prose-th:p-2 prose-td:border prose-th:border prose-td:border-outline-gray-2 prose-th:border-outline-gray-2 prose-td:relative prose-th:relative prose-th:bg-surface-gray-2 prose-sm max-w-none !whitespace-normal mt-10"
|
class="ProseMirror prose prose-table:table-fixed prose-td:p-2 prose-th:p-2 prose-td:border prose-th:border prose-td:border-outline-gray-2 prose-th:border-outline-gray-2 prose-td:relative prose-th:relative prose-th:bg-surface-gray-2 prose-sm max-w-none !whitespace-normal mt-10"
|
||||||
@@ -76,6 +78,7 @@
|
|||||||
:title="__('Course Outline')"
|
:title="__('Course Outline')"
|
||||||
:courseName="course.data.name"
|
:courseName="course.data.name"
|
||||||
:showOutline="true"
|
:showOutline="true"
|
||||||
|
:getProgress="course.data.membership ? true : false"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<CourseReviews
|
<CourseReviews
|
||||||
@@ -88,6 +91,7 @@
|
|||||||
<CourseCardOverlay :course="course" />
|
<CourseCardOverlay :course="course" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<RelatedCourses :courseName="course.data.name" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -99,7 +103,7 @@ import {
|
|||||||
Tooltip,
|
Tooltip,
|
||||||
usePageMeta,
|
usePageMeta,
|
||||||
} from 'frappe-ui'
|
} from 'frappe-ui'
|
||||||
import { computed } from 'vue'
|
import { computed, watch } from 'vue'
|
||||||
import { Users, Star } from 'lucide-vue-next'
|
import { Users, Star } from 'lucide-vue-next'
|
||||||
import { sessionStore } from '@/stores/session'
|
import { sessionStore } from '@/stores/session'
|
||||||
import CourseCardOverlay from '@/components/CourseCardOverlay.vue'
|
import CourseCardOverlay from '@/components/CourseCardOverlay.vue'
|
||||||
@@ -107,6 +111,7 @@ import CourseOutline from '@/components/CourseOutline.vue'
|
|||||||
import CourseReviews from '@/components/CourseReviews.vue'
|
import CourseReviews from '@/components/CourseReviews.vue'
|
||||||
import UserAvatar from '@/components/UserAvatar.vue'
|
import UserAvatar from '@/components/UserAvatar.vue'
|
||||||
import CourseInstructors from '@/components/CourseInstructors.vue'
|
import CourseInstructors from '@/components/CourseInstructors.vue'
|
||||||
|
import RelatedCourses from '@/components/RelatedCourses.vue'
|
||||||
|
|
||||||
const { brand } = sessionStore()
|
const { brand } = sessionStore()
|
||||||
|
|
||||||
@@ -120,12 +125,21 @@ const props = defineProps({
|
|||||||
const course = createResource({
|
const course = createResource({
|
||||||
url: 'lms.lms.utils.get_course_details',
|
url: 'lms.lms.utils.get_course_details',
|
||||||
cache: ['course', props.courseName],
|
cache: ['course', props.courseName],
|
||||||
params: {
|
makeParams() {
|
||||||
|
return {
|
||||||
course: props.courseName,
|
course: props.courseName,
|
||||||
|
}
|
||||||
},
|
},
|
||||||
auto: true,
|
auto: true,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.courseName,
|
||||||
|
() => {
|
||||||
|
course.reload()
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
const breadcrumbs = computed(() => {
|
const breadcrumbs = computed(() => {
|
||||||
let items = [{ label: 'Courses', route: { name: 'Courses' } }]
|
let items = [{ label: 'Courses', route: { name: 'Courses' } }]
|
||||||
items.push({
|
items.push({
|
||||||
|
|||||||
@@ -47,14 +47,22 @@
|
|||||||
:required="true"
|
:required="true"
|
||||||
/>
|
/>
|
||||||
<div>
|
<div>
|
||||||
<div class="mb-1.5 text-xs text-ink-gray-5">
|
<div class="text-xs text-ink-gray-5">
|
||||||
{{ __('Tags') }}
|
{{ __('Tags') }}
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center">
|
<FormControl
|
||||||
|
v-model="newTag"
|
||||||
|
:placeholder="__('Add a keyword and then press enter')"
|
||||||
|
:class="['w-full', 'flex-1', 'my-1']"
|
||||||
|
@keyup.enter="updateTags()"
|
||||||
|
id="tags"
|
||||||
|
/>
|
||||||
|
<div>
|
||||||
|
<div class="flex items-center flex-wrap gap-2">
|
||||||
<div
|
<div
|
||||||
v-if="course.tags"
|
v-if="course.tags"
|
||||||
v-for="tag in course.tags?.split(', ')"
|
v-for="tag in course.tags?.split(', ')"
|
||||||
class="flex items-center bg-surface-gray-2 text-ink-gray-7 p-2 rounded-md mr-2"
|
class="flex items-center bg-surface-gray-2 text-ink-gray-7 p-2 rounded-md"
|
||||||
>
|
>
|
||||||
{{ tag }}
|
{{ tag }}
|
||||||
<X
|
<X
|
||||||
@@ -62,33 +70,14 @@
|
|||||||
@click="removeTag(tag)"
|
@click="removeTag(tag)"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<FormControl
|
</div>
|
||||||
v-model="newTag"
|
|
||||||
:placeholder="__('Add a keyword and then press enter')"
|
|
||||||
class="w-full"
|
|
||||||
@keyup.enter="updateTags()"
|
|
||||||
id="tags"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="grid grid-cols-2 gap-5">
|
<div class="grid grid-cols-2 gap-5">
|
||||||
<FormControl
|
|
||||||
v-model="course.short_introduction"
|
|
||||||
type="textarea"
|
|
||||||
:rows="4"
|
|
||||||
:label="__('Short Introduction')"
|
|
||||||
:placeholder="
|
|
||||||
__(
|
|
||||||
'A one line introduction to the course that appears on the course card'
|
|
||||||
)
|
|
||||||
"
|
|
||||||
:required="true"
|
|
||||||
/>
|
|
||||||
<div class="mb-4">
|
<div class="mb-4">
|
||||||
<div class="text-xs text-ink-gray-5 mb-2">
|
<div class="text-xs text-ink-gray-5 mb-2">
|
||||||
{{ __('Course Image') }}
|
{{ __('Course Image') }}
|
||||||
<span class="text-ink-red-3">*</span>
|
|
||||||
</div>
|
</div>
|
||||||
<FileUploader
|
<FileUploader
|
||||||
v-if="!course.course_image"
|
v-if="!course.course_image"
|
||||||
@@ -100,7 +89,10 @@
|
|||||||
v-slot="{ file, progress, uploading, openFileSelector }"
|
v-slot="{ file, progress, uploading, openFileSelector }"
|
||||||
>
|
>
|
||||||
<div class="flex items-center">
|
<div class="flex items-center">
|
||||||
<div class="border rounded-md w-fit py-5 px-20">
|
<div
|
||||||
|
class="border rounded-md w-fit py-5 px-20 cursor-pointer"
|
||||||
|
@click="openFileSelector"
|
||||||
|
>
|
||||||
<Image class="size-5 stroke-1 text-ink-gray-7" />
|
<Image class="size-5 stroke-1 text-ink-gray-7" />
|
||||||
</div>
|
</div>
|
||||||
<div class="ml-4">
|
<div class="ml-4">
|
||||||
@@ -135,6 +127,13 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<ColorSwatches
|
||||||
|
v-model="course.card_gradient"
|
||||||
|
:label="__('Color')"
|
||||||
|
:description="__('Choose a color for the course card')"
|
||||||
|
class="w-full"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -176,6 +175,21 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="px-10 pb-5 mb-5 space-y-5 border-b">
|
<div class="px-10 pb-5 mb-5 space-y-5 border-b">
|
||||||
|
<div class="text-lg font-semibold">
|
||||||
|
{{ __('About the Course') }}
|
||||||
|
</div>
|
||||||
|
<FormControl
|
||||||
|
v-model="course.short_introduction"
|
||||||
|
type="textarea"
|
||||||
|
:rows="5"
|
||||||
|
:label="__('Short Introduction')"
|
||||||
|
:placeholder="
|
||||||
|
__(
|
||||||
|
'A one line introduction to the course that appears on the course card'
|
||||||
|
)
|
||||||
|
"
|
||||||
|
:required="true"
|
||||||
|
/>
|
||||||
<div class="">
|
<div class="">
|
||||||
<div class="mb-1.5 text-sm text-ink-gray-5">
|
<div class="mb-1.5 text-sm text-ink-gray-5">
|
||||||
{{ __('Course Description') }}
|
{{ __('Course Description') }}
|
||||||
@@ -199,9 +213,24 @@
|
|||||||
)
|
)
|
||||||
"
|
"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<MultiSelect
|
||||||
|
v-model="related_courses"
|
||||||
|
doctype="LMS Course"
|
||||||
|
:label="__('Related Courses')"
|
||||||
|
:filters="{ name: ['!=', courseResource.data?.name] }"
|
||||||
|
:onCreate="
|
||||||
|
(close) => {
|
||||||
|
router.push({
|
||||||
|
name: 'CourseForm',
|
||||||
|
params: { courseName: 'new' },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="px-10 pb-5 space-y-5">
|
<div class="px-10 pb-5 space-y-5 border-b">
|
||||||
<div class="text-lg font-semibold mt-5">
|
<div class="text-lg font-semibold mt-5">
|
||||||
{{ __('Pricing and Certification') }}
|
{{ __('Pricing and Certification') }}
|
||||||
</div>
|
</div>
|
||||||
@@ -248,6 +277,27 @@
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="px-10 pb-5 space-y-5">
|
||||||
|
<div class="text-lg font-semibold mt-5">
|
||||||
|
{{ __('Meta Tags') }}
|
||||||
|
</div>
|
||||||
|
<div class="space-y-5">
|
||||||
|
<FormControl
|
||||||
|
v-model="meta.description"
|
||||||
|
:label="__('Meta Description')"
|
||||||
|
type="textarea"
|
||||||
|
:rows="7"
|
||||||
|
/>
|
||||||
|
<FormControl
|
||||||
|
v-model="meta.keywords"
|
||||||
|
:label="__('Meta Keywords')"
|
||||||
|
type="textarea"
|
||||||
|
:rows="7"
|
||||||
|
:placeholder="__('Comma separated keywords for SEO')"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="border-l">
|
<div class="border-l">
|
||||||
@@ -264,6 +314,7 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import {
|
import {
|
||||||
Breadcrumbs,
|
Breadcrumbs,
|
||||||
|
call,
|
||||||
TextEditor,
|
TextEditor,
|
||||||
Button,
|
Button,
|
||||||
createResource,
|
createResource,
|
||||||
@@ -284,19 +335,26 @@ import {
|
|||||||
} from 'vue'
|
} from 'vue'
|
||||||
import { Image, Trash2, X } from 'lucide-vue-next'
|
import { Image, Trash2, X } from 'lucide-vue-next'
|
||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
import { capture } from '@/telemetry'
|
import { capture, startRecording, stopRecording } from '@/telemetry'
|
||||||
import { useOnboarding } from 'frappe-ui/frappe'
|
import { useOnboarding } from 'frappe-ui/frappe'
|
||||||
import { sessionStore } from '../stores/session'
|
import { sessionStore } from '../stores/session'
|
||||||
import { openSettings } from '@/utils'
|
import {
|
||||||
|
openSettings,
|
||||||
|
getMetaInfo,
|
||||||
|
updateMetaInfo,
|
||||||
|
validateFile,
|
||||||
|
} from '@/utils'
|
||||||
import Link from '@/components/Controls/Link.vue'
|
import Link from '@/components/Controls/Link.vue'
|
||||||
import CourseOutline from '@/components/CourseOutline.vue'
|
import CourseOutline from '@/components/CourseOutline.vue'
|
||||||
import MultiSelect from '@/components/Controls/MultiSelect.vue'
|
import MultiSelect from '@/components/Controls/MultiSelect.vue'
|
||||||
|
import ColorSwatches from '@/components/Controls/ColorSwatches.vue'
|
||||||
|
|
||||||
const user = inject('$user')
|
const user = inject('$user')
|
||||||
const newTag = ref('')
|
const newTag = ref('')
|
||||||
const { brand } = sessionStore()
|
const { brand } = sessionStore()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const instructors = ref([])
|
const instructors = ref([])
|
||||||
|
const related_courses = ref([])
|
||||||
const app = getCurrentInstance()
|
const app = getCurrentInstance()
|
||||||
const { updateOnboardingStep } = useOnboarding('learning')
|
const { updateOnboardingStep } = useOnboarding('learning')
|
||||||
const { $dialog } = app.appContext.config.globalProperties
|
const { $dialog } = app.appContext.config.globalProperties
|
||||||
@@ -313,6 +371,7 @@ const course = reactive({
|
|||||||
description: '',
|
description: '',
|
||||||
video_link: '',
|
video_link: '',
|
||||||
course_image: null,
|
course_image: null,
|
||||||
|
card_gradient: '',
|
||||||
tags: '',
|
tags: '',
|
||||||
category: '',
|
category: '',
|
||||||
published: false,
|
published: false,
|
||||||
@@ -328,19 +387,30 @@ const course = reactive({
|
|||||||
evaluator: '',
|
evaluator: '',
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const meta = reactive({
|
||||||
|
description: '',
|
||||||
|
keywords: '',
|
||||||
|
})
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
if (!user.data?.is_moderator && !user.data?.is_instructor) {
|
if (!user.data?.is_moderator && !user.data?.is_instructor) {
|
||||||
router.push({ name: 'Courses' })
|
router.push({ name: 'Courses' })
|
||||||
}
|
}
|
||||||
|
|
||||||
if (props.courseName !== 'new') {
|
if (props.courseName !== 'new') {
|
||||||
courseResource.reload()
|
fetchCourseInfo()
|
||||||
} else {
|
} else {
|
||||||
capture('course_form_opened')
|
capture('course_form_opened')
|
||||||
|
startRecording()
|
||||||
}
|
}
|
||||||
window.addEventListener('keydown', keyboardShortcut)
|
window.addEventListener('keydown', keyboardShortcut)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const fetchCourseInfo = () => {
|
||||||
|
courseResource.reload()
|
||||||
|
getMetaInfo('courses', props.courseName, meta)
|
||||||
|
}
|
||||||
|
|
||||||
const keyboardShortcut = (e) => {
|
const keyboardShortcut = (e) => {
|
||||||
if (
|
if (
|
||||||
e.key === 's' &&
|
e.key === 's' &&
|
||||||
@@ -354,6 +424,7 @@ const keyboardShortcut = (e) => {
|
|||||||
|
|
||||||
onBeforeUnmount(() => {
|
onBeforeUnmount(() => {
|
||||||
window.removeEventListener('keydown', keyboardShortcut)
|
window.removeEventListener('keydown', keyboardShortcut)
|
||||||
|
stopRecording()
|
||||||
})
|
})
|
||||||
|
|
||||||
const courseCreationResource = createResource({
|
const courseCreationResource = createResource({
|
||||||
@@ -366,6 +437,9 @@ const courseCreationResource = createResource({
|
|||||||
instructors: instructors.value.map((instructor) => ({
|
instructors: instructors.value.map((instructor) => ({
|
||||||
instructor: instructor,
|
instructor: instructor,
|
||||||
})),
|
})),
|
||||||
|
related_courses: related_courses.value.map((course) => ({
|
||||||
|
course: course,
|
||||||
|
})),
|
||||||
...values,
|
...values,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
@@ -384,6 +458,9 @@ const courseEditResource = createResource({
|
|||||||
instructors: instructors.value.map((instructor) => ({
|
instructors: instructors.value.map((instructor) => ({
|
||||||
instructor: instructor,
|
instructor: instructor,
|
||||||
})),
|
})),
|
||||||
|
related_courses: related_courses.value.map((course) => ({
|
||||||
|
course: course,
|
||||||
|
})),
|
||||||
...course,
|
...course,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
@@ -406,6 +483,11 @@ const courseResource = createResource({
|
|||||||
data.instructors.forEach((instructor) => {
|
data.instructors.forEach((instructor) => {
|
||||||
instructors.value.push(instructor.instructor)
|
instructors.value.push(instructor.instructor)
|
||||||
})
|
})
|
||||||
|
} else if (key == 'related_courses') {
|
||||||
|
related_courses.value = []
|
||||||
|
data.related_courses.forEach((course) => {
|
||||||
|
related_courses.value.push(course.course)
|
||||||
|
})
|
||||||
} else if (Object.hasOwn(course, key)) course[key] = data[key]
|
} else if (Object.hasOwn(course, key)) course[key] = data[key]
|
||||||
})
|
})
|
||||||
let checkboxes = [
|
let checkboxes = [
|
||||||
@@ -442,22 +524,16 @@ const imageResource = createResource({
|
|||||||
|
|
||||||
const submitCourse = () => {
|
const submitCourse = () => {
|
||||||
if (courseResource.data) {
|
if (courseResource.data) {
|
||||||
courseEditResource.submit(
|
editCourse()
|
||||||
{
|
|
||||||
course: courseResource.data.name,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
onSuccess() {
|
|
||||||
toast.success(__('Course updated successfully'))
|
|
||||||
},
|
|
||||||
onError(err) {
|
|
||||||
toast.error(err.messages?.[0] || err)
|
|
||||||
},
|
|
||||||
}
|
|
||||||
)
|
|
||||||
} else {
|
} else {
|
||||||
|
createCourse()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const createCourse = () => {
|
||||||
courseCreationResource.submit(course, {
|
courseCreationResource.submit(course, {
|
||||||
onSuccess(data) {
|
onSuccess(data) {
|
||||||
|
updateMetaInfo('courses', data.name, meta)
|
||||||
if (user.data?.is_system_manager) {
|
if (user.data?.is_system_manager) {
|
||||||
updateOnboardingStep('create_first_course', true, false, () => {
|
updateOnboardingStep('create_first_course', true, false, () => {
|
||||||
localStorage.setItem('firstCourse', data.name)
|
localStorage.setItem('firstCourse', data.name)
|
||||||
@@ -476,6 +552,22 @@ const submitCourse = () => {
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const editCourse = () => {
|
||||||
|
courseEditResource.submit(
|
||||||
|
{
|
||||||
|
course: courseResource.data.name,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
onSuccess() {
|
||||||
|
updateMetaInfo('courses', props.courseName, meta)
|
||||||
|
toast.success(__('Course updated successfully'))
|
||||||
|
},
|
||||||
|
onError(err) {
|
||||||
|
toast.error(err.messages?.[0] || err)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const deleteCourse = createResource({
|
const deleteCourse = createResource({
|
||||||
@@ -515,18 +607,11 @@ watch(
|
|||||||
() => props.courseName !== 'new',
|
() => props.courseName !== 'new',
|
||||||
(newVal) => {
|
(newVal) => {
|
||||||
if (newVal) {
|
if (newVal) {
|
||||||
courseResource.reload()
|
fetchCourseInfo()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
const validateFile = (file) => {
|
|
||||||
let extension = file.name.split('.').pop().toLowerCase()
|
|
||||||
if (!['jpg', 'jpeg', 'png', 'webp'].includes(extension)) {
|
|
||||||
return __('Only image file is allowed.')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const updateTags = () => {
|
const updateTags = () => {
|
||||||
if (newTag.value) {
|
if (newTag.value) {
|
||||||
course.tags = course.tags ? `${course.tags}, ${newTag.value}` : newTag.value
|
course.tags = course.tags ? `${course.tags}, ${newTag.value}` : newTag.value
|
||||||
|
|||||||
@@ -14,7 +14,7 @@
|
|||||||
<template #prefix>
|
<template #prefix>
|
||||||
<Plus class="h-4 w-4 stroke-1.5" />
|
<Plus class="h-4 w-4 stroke-1.5" />
|
||||||
</template>
|
</template>
|
||||||
{{ __('New') }}
|
{{ __('Create') }}
|
||||||
</Button>
|
</Button>
|
||||||
</router-link>
|
</router-link>
|
||||||
</header>
|
</header>
|
||||||
@@ -57,7 +57,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
v-if="courses.data?.length"
|
v-if="courses.data?.length"
|
||||||
class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 2xl:grid-cols-4 gap-4"
|
class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 2xl:grid-cols-4 gap-8"
|
||||||
>
|
>
|
||||||
<router-link
|
<router-link
|
||||||
v-for="course in courses.data"
|
v-for="course in courses.data"
|
||||||
@@ -240,7 +240,6 @@ const updateTabFilter = () => {
|
|||||||
filters.value['live'] = 1
|
filters.value['live'] = 1
|
||||||
} else if (currentTab.value == 'Upcoming') {
|
} else if (currentTab.value == 'Upcoming') {
|
||||||
filters.value['upcoming'] = 1
|
filters.value['upcoming'] = 1
|
||||||
filters.value['published'] = 1
|
|
||||||
} else if (currentTab.value == 'New') {
|
} else if (currentTab.value == 'New') {
|
||||||
filters.value['published'] = 1
|
filters.value['published'] = 1
|
||||||
filters.value['published_on'] = [
|
filters.value['published_on'] = [
|
||||||
@@ -249,6 +248,8 @@ const updateTabFilter = () => {
|
|||||||
]
|
]
|
||||||
} else if (currentTab.value == 'Created') {
|
} else if (currentTab.value == 'Created') {
|
||||||
filters.value['created'] = 1
|
filters.value['created'] = 1
|
||||||
|
} else if (currentTab.value == 'Unpublished') {
|
||||||
|
filters.value['published'] = 0
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -318,6 +319,7 @@ const courseTabs = computed(() => {
|
|||||||
user.data?.is_evaluator
|
user.data?.is_evaluator
|
||||||
) {
|
) {
|
||||||
tabs.push({ label: __('Created') })
|
tabs.push({ label: __('Created') })
|
||||||
|
tabs.push({ label: __('Unpublished') })
|
||||||
} else if (user.data) {
|
} else if (user.data) {
|
||||||
tabs.push({ label: __('Enrolled') })
|
tabs.push({ label: __('Enrolled') })
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -151,7 +151,7 @@ import { computed, onMounted, reactive, inject } from 'vue'
|
|||||||
import { FileText, X } from 'lucide-vue-next'
|
import { FileText, X } from 'lucide-vue-next'
|
||||||
import { sessionStore } from '@/stores/session'
|
import { sessionStore } from '@/stores/session'
|
||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
import { getFileSize } from '@/utils'
|
import { getFileSize, validateFile } from '@/utils'
|
||||||
|
|
||||||
const user = inject('$user')
|
const user = inject('$user')
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
@@ -293,13 +293,6 @@ const removeImage = () => {
|
|||||||
job.image = null
|
job.image = null
|
||||||
}
|
}
|
||||||
|
|
||||||
const validateFile = (file) => {
|
|
||||||
let extension = file.name.split('.').pop().toLowerCase()
|
|
||||||
if (!['jpg', 'jpeg', 'png'].includes(extension)) {
|
|
||||||
return 'Only image file is allowed.'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const jobTypes = computed(() => {
|
const jobTypes = computed(() => {
|
||||||
return [
|
return [
|
||||||
{ label: 'Full Time', value: 'Full Time' },
|
{ label: 'Full Time', value: 'Full Time' },
|
||||||
|
|||||||
@@ -26,7 +26,6 @@
|
|||||||
</header>
|
</header>
|
||||||
<div>
|
<div>
|
||||||
<div
|
<div
|
||||||
v-if="jobCount"
|
|
||||||
class="flex flex-col lg:flex-row space-y-4 lg:space-y-0 lg:items-center justify-between w-full md:w-4/5 mx-auto p-5"
|
class="flex flex-col lg:flex-row space-y-4 lg:space-y-0 lg:items-center justify-between w-full md:w-4/5 mx-auto p-5"
|
||||||
>
|
>
|
||||||
<div class="text-xl font-semibold text-ink-gray-7 mb-4 md:mb-0">
|
<div class="text-xl font-semibold text-ink-gray-7 mb-4 md:mb-0">
|
||||||
@@ -34,8 +33,8 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
v-if="jobs.data?.length || jobCount > 0"
|
class="grid grid-cols-1 gap-2"
|
||||||
class="grid grid-cols-1 md:grid-cols-3 gap-2"
|
:class="user.data ? 'md:grid-cols-3' : 'md:grid-cols-2'"
|
||||||
>
|
>
|
||||||
<FormControl
|
<FormControl
|
||||||
type="text"
|
type="text"
|
||||||
@@ -52,6 +51,7 @@
|
|||||||
</template>
|
</template>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<Link
|
<Link
|
||||||
|
v-if="user.data"
|
||||||
doctype="Country"
|
doctype="Country"
|
||||||
v-model="country"
|
v-model="country"
|
||||||
:placeholder="__('Country')"
|
:placeholder="__('Country')"
|
||||||
@@ -117,7 +117,6 @@ onMounted(() => {
|
|||||||
jobType.value = queries.get('type')
|
jobType.value = queries.get('type')
|
||||||
}
|
}
|
||||||
updateJobs()
|
updateJobs()
|
||||||
getJobCount()
|
|
||||||
})
|
})
|
||||||
|
|
||||||
const jobs = createResource({
|
const jobs = createResource({
|
||||||
@@ -163,22 +162,14 @@ const updateFilters = () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const getJobCount = () => {
|
|
||||||
call('frappe.client.get_count', {
|
|
||||||
doctype: 'Job Opportunity',
|
|
||||||
filters: {
|
|
||||||
status: 'Open',
|
|
||||||
disabled: 0,
|
|
||||||
},
|
|
||||||
}).then((data) => {
|
|
||||||
jobCount.value = data
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
watch(country, (val) => {
|
watch(country, (val) => {
|
||||||
updateJobs()
|
updateJobs()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
watch(jobs, () => {
|
||||||
|
jobCount.value = jobs.data?.length || 0
|
||||||
|
})
|
||||||
|
|
||||||
const jobTypes = computed(() => {
|
const jobTypes = computed(() => {
|
||||||
return [
|
return [
|
||||||
'',
|
'',
|
||||||
|
|||||||
@@ -12,6 +12,12 @@
|
|||||||
</template>
|
</template>
|
||||||
</Button>
|
</Button>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
|
<Button v-if="canSeeStats()" @click="showVideoStats()">
|
||||||
|
<template #prefix>
|
||||||
|
<TrendingUp class="size-4 stroke-1.5" />
|
||||||
|
</template>
|
||||||
|
{{ __('Video Statistics') }}
|
||||||
|
</Button>
|
||||||
<CertificationLinks :courseName="courseName" />
|
<CertificationLinks :courseName="courseName" />
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
@@ -63,11 +69,12 @@
|
|||||||
}"
|
}"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
class="border-r container pt-5 pb-10 px-5 h-full"
|
class="border-r pt-5 pb-10 h-full"
|
||||||
:class="{
|
:class="{
|
||||||
'w-full md:w-3/4 mx-auto border-none !pt-10': zenModeEnabled,
|
'w-full md:w-3/5 mx-auto border-none !pt-10': zenModeEnabled,
|
||||||
}"
|
}"
|
||||||
>
|
>
|
||||||
|
<div class="px-5">
|
||||||
<div
|
<div
|
||||||
class="flex flex-col md:flex-row md:items-center justify-between"
|
class="flex flex-col md:flex-row md:items-center justify-between"
|
||||||
>
|
>
|
||||||
@@ -95,23 +102,15 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex items-center space-x-2 mt-2 md:mt-0">
|
<div class="flex items-center space-x-2 mt-2 md:mt-0">
|
||||||
<Button v-if="zenModeEnabled" @click="showDiscussionsInZenMode()">
|
<Button
|
||||||
|
v-if="zenModeEnabled"
|
||||||
|
@click="showDiscussionsInZenMode()"
|
||||||
|
>
|
||||||
<template #icon>
|
<template #icon>
|
||||||
<MessageCircleQuestion class="w-4 h-4 stroke-1.5" />
|
<MessageCircleQuestion class="w-4 h-4 stroke-1.5" />
|
||||||
</template>
|
</template>
|
||||||
</Button>
|
</Button>
|
||||||
<router-link
|
<Button v-if="lesson.data.prev" @click="switchLesson('prev')">
|
||||||
v-if="lesson.data.prev"
|
|
||||||
:to="{
|
|
||||||
name: 'Lesson',
|
|
||||||
params: {
|
|
||||||
courseName: courseName,
|
|
||||||
chapterNumber: lesson.data.prev.split('.')[0],
|
|
||||||
lessonNumber: lesson.data.prev.split('.')[1],
|
|
||||||
},
|
|
||||||
}"
|
|
||||||
>
|
|
||||||
<Button>
|
|
||||||
<template #prefix>
|
<template #prefix>
|
||||||
<ChevronLeft class="w-4 h-4 stroke-1" />
|
<ChevronLeft class="w-4 h-4 stroke-1" />
|
||||||
</template>
|
</template>
|
||||||
@@ -119,7 +118,7 @@
|
|||||||
{{ __('Previous') }}
|
{{ __('Previous') }}
|
||||||
</span>
|
</span>
|
||||||
</Button>
|
</Button>
|
||||||
</router-link>
|
|
||||||
<router-link
|
<router-link
|
||||||
v-if="allowEdit()"
|
v-if="allowEdit()"
|
||||||
:to="{
|
:to="{
|
||||||
@@ -135,18 +134,8 @@
|
|||||||
{{ __('Edit') }}
|
{{ __('Edit') }}
|
||||||
</Button>
|
</Button>
|
||||||
</router-link>
|
</router-link>
|
||||||
<router-link
|
|
||||||
v-if="lesson.data.next"
|
<Button v-if="lesson.data.next" @click="switchLesson('next')">
|
||||||
:to="{
|
|
||||||
name: 'Lesson',
|
|
||||||
params: {
|
|
||||||
courseName: courseName,
|
|
||||||
chapterNumber: lesson.data.next.split('.')[0],
|
|
||||||
lessonNumber: lesson.data.next.split('.')[1],
|
|
||||||
},
|
|
||||||
}"
|
|
||||||
>
|
|
||||||
<Button>
|
|
||||||
<template #suffix>
|
<template #suffix>
|
||||||
<ChevronRight class="w-4 h-4 stroke-1" />
|
<ChevronRight class="w-4 h-4 stroke-1" />
|
||||||
</template>
|
</template>
|
||||||
@@ -154,7 +143,7 @@
|
|||||||
{{ __('Next') }}
|
{{ __('Next') }}
|
||||||
</span>
|
</span>
|
||||||
</Button>
|
</Button>
|
||||||
</router-link>
|
|
||||||
<router-link
|
<router-link
|
||||||
v-else
|
v-else
|
||||||
:to="{
|
:to="{
|
||||||
@@ -190,7 +179,8 @@
|
|||||||
<div
|
<div
|
||||||
v-if="
|
v-if="
|
||||||
lesson.data.instructor_content &&
|
lesson.data.instructor_content &&
|
||||||
JSON.parse(lesson.data.instructor_content)?.blocks?.length > 1 &&
|
JSON.parse(lesson.data.instructor_content)?.blocks?.length >
|
||||||
|
1 &&
|
||||||
allowInstructorContent()
|
allowInstructorContent()
|
||||||
"
|
"
|
||||||
class="bg-surface-gray-2 p-3 rounded-md mt-6"
|
class="bg-surface-gray-2 p-3 rounded-md mt-6"
|
||||||
@@ -211,6 +201,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
v-if="lesson.data.content"
|
v-if="lesson.data.content"
|
||||||
|
@mouseup="toggleInlineMenu"
|
||||||
class="ProseMirror prose prose-table:table-fixed prose-td:p-2 prose-th:p-2 prose-td:border prose-th:border prose-td:border-outline-gray-2 prose-th:border-outline-gray-2 prose-td:relative prose-th:relative prose-th:bg-surface-gray-2 prose-sm max-w-none !whitespace-normal mt-8"
|
class="ProseMirror prose prose-table:table-fixed prose-td:p-2 prose-th:p-2 prose-td:border prose-th:border prose-td:border-outline-gray-2 prose-th:border-outline-gray-2 prose-td:relative prose-th:relative prose-th:bg-surface-gray-2 prose-sm max-w-none !whitespace-normal mt-8"
|
||||||
>
|
>
|
||||||
<div id="editor"></div>
|
<div id="editor"></div>
|
||||||
@@ -226,13 +217,32 @@
|
|||||||
:quizId="lesson.data.quiz_id"
|
:quizId="lesson.data.quiz_id"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div class="mt-20" ref="discussionsContainer">
|
</div>
|
||||||
|
<div
|
||||||
|
v-if="lesson.data"
|
||||||
|
class="mt-10 pt-5 border-t px-5"
|
||||||
|
ref="discussionsContainer"
|
||||||
|
>
|
||||||
|
<TabButtons
|
||||||
|
:buttons="tabs"
|
||||||
|
v-model="currentTab"
|
||||||
|
class="w-fit mb-10"
|
||||||
|
/>
|
||||||
|
<Notes
|
||||||
|
v-if="currentTab === 'Notes'"
|
||||||
|
:lesson="lesson.data?.name"
|
||||||
|
v-model:notes="notes"
|
||||||
|
@updateNotes="updateNotes"
|
||||||
|
/>
|
||||||
<Discussions
|
<Discussions
|
||||||
v-if="allowDiscussions"
|
v-else-if="allowDiscussions"
|
||||||
:title="'Questions'"
|
:title="'Questions'"
|
||||||
:doctype="'Course Lesson'"
|
:doctype="'Course Lesson'"
|
||||||
:docname="lesson.data.name"
|
:docname="lesson.data.name"
|
||||||
:key="lesson.data.name"
|
:key="lesson.data.name"
|
||||||
|
:emptyStateText="
|
||||||
|
__('Ask a question to get help from the community.')
|
||||||
|
"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -262,13 +272,28 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<InlineLessonMenu
|
||||||
|
v-if="lesson.data"
|
||||||
|
v-model="showInlineMenu"
|
||||||
|
:lesson="lesson.data?.name"
|
||||||
|
v-model:notes="notes"
|
||||||
|
@updateNotes="updateNotes"
|
||||||
|
/>
|
||||||
|
<VideoStatistics
|
||||||
|
v-model="showStatsDialog"
|
||||||
|
:lessonName="lesson.data?.name"
|
||||||
|
:lessonTitle="lesson.data?.title"
|
||||||
|
/>
|
||||||
</template>
|
</template>
|
||||||
<script setup>
|
<script setup>
|
||||||
import {
|
import {
|
||||||
createResource,
|
|
||||||
Badge,
|
Badge,
|
||||||
Breadcrumbs,
|
Breadcrumbs,
|
||||||
Button,
|
Button,
|
||||||
|
call,
|
||||||
|
createListResource,
|
||||||
|
createResource,
|
||||||
|
TabButtons,
|
||||||
Tooltip,
|
Tooltip,
|
||||||
usePageMeta,
|
usePageMeta,
|
||||||
} from 'frappe-ui'
|
} from 'frappe-ui'
|
||||||
@@ -281,8 +306,6 @@ import {
|
|||||||
onBeforeUnmount,
|
onBeforeUnmount,
|
||||||
nextTick,
|
nextTick,
|
||||||
} from 'vue'
|
} from 'vue'
|
||||||
import CourseOutline from '@/components/CourseOutline.vue'
|
|
||||||
import UserAvatar from '@/components/UserAvatar.vue'
|
|
||||||
import { useRouter, useRoute } from 'vue-router'
|
import { useRouter, useRoute } from 'vue-router'
|
||||||
import {
|
import {
|
||||||
ChevronLeft,
|
ChevronLeft,
|
||||||
@@ -292,17 +315,25 @@ import {
|
|||||||
Focus,
|
Focus,
|
||||||
Info,
|
Info,
|
||||||
MessageCircleQuestion,
|
MessageCircleQuestion,
|
||||||
|
TrendingUp,
|
||||||
} from 'lucide-vue-next'
|
} from 'lucide-vue-next'
|
||||||
import Discussions from '@/components/Discussions.vue'
|
import { getEditorTools, enablePlyr, highlightText } from '@/utils'
|
||||||
import { getEditorTools, enablePlyr } from '@/utils'
|
|
||||||
import { sessionStore } from '@/stores/session'
|
import { sessionStore } from '@/stores/session'
|
||||||
|
import { useSidebar } from '@/stores/sidebar'
|
||||||
import EditorJS from '@editorjs/editorjs'
|
import EditorJS from '@editorjs/editorjs'
|
||||||
import LessonContent from '@/components/LessonContent.vue'
|
import LessonContent from '@/components/LessonContent.vue'
|
||||||
import CourseInstructors from '@/components/CourseInstructors.vue'
|
import CourseInstructors from '@/components/CourseInstructors.vue'
|
||||||
import ProgressBar from '@/components/ProgressBar.vue'
|
import ProgressBar from '@/components/ProgressBar.vue'
|
||||||
|
import Discussions from '@/components/Discussions.vue'
|
||||||
import CertificationLinks from '@/components/CertificationLinks.vue'
|
import CertificationLinks from '@/components/CertificationLinks.vue'
|
||||||
|
import VideoStatistics from '@/components/Modals/VideoStatistics.vue'
|
||||||
|
import CourseOutline from '@/components/CourseOutline.vue'
|
||||||
|
import UserAvatar from '@/components/UserAvatar.vue'
|
||||||
|
import Notes from '@/components/Notes/Notes.vue'
|
||||||
|
import InlineLessonMenu from '@/components/Notes/InlineLessonMenu.vue'
|
||||||
|
|
||||||
const user = inject('$user')
|
const user = inject('$user')
|
||||||
|
const socket = inject('$socket')
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const allowDiscussions = ref(false)
|
const allowDiscussions = ref(false)
|
||||||
@@ -311,12 +342,24 @@ const instructorEditor = ref(null)
|
|||||||
const lessonProgress = ref(0)
|
const lessonProgress = ref(0)
|
||||||
const lessonContainer = ref(null)
|
const lessonContainer = ref(null)
|
||||||
const zenModeEnabled = ref(false)
|
const zenModeEnabled = ref(false)
|
||||||
|
const showStatsDialog = ref(false)
|
||||||
const hasQuiz = ref(false)
|
const hasQuiz = ref(false)
|
||||||
const discussionsContainer = ref(null)
|
const discussionsContainer = ref(null)
|
||||||
const timer = ref(0)
|
const timer = ref(0)
|
||||||
const { brand } = sessionStore()
|
const { brand } = sessionStore()
|
||||||
|
const sidebarStore = useSidebar()
|
||||||
|
const plyrSources = ref([])
|
||||||
|
const showInlineMenu = ref(false)
|
||||||
|
const currentTab = ref('Notes')
|
||||||
let timerInterval
|
let timerInterval
|
||||||
|
|
||||||
|
const tabs = ref([
|
||||||
|
{
|
||||||
|
label: __('Notes'),
|
||||||
|
value: 'Notes',
|
||||||
|
},
|
||||||
|
])
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
courseName: {
|
courseName: {
|
||||||
type: String,
|
type: String,
|
||||||
@@ -334,7 +377,13 @@ const props = defineProps({
|
|||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
startTimer()
|
startTimer()
|
||||||
|
sidebarStore.isSidebarCollapsed = true
|
||||||
document.addEventListener('fullscreenchange', attachFullscreenEvent)
|
document.addEventListener('fullscreenchange', attachFullscreenEvent)
|
||||||
|
socket.on('update_lesson_progress', (data) => {
|
||||||
|
if (data.course === props.courseName) {
|
||||||
|
lessonProgress.value = data.progress
|
||||||
|
}
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
const attachFullscreenEvent = () => {
|
const attachFullscreenEvent = () => {
|
||||||
@@ -351,6 +400,8 @@ const attachFullscreenEvent = () => {
|
|||||||
|
|
||||||
onBeforeUnmount(() => {
|
onBeforeUnmount(() => {
|
||||||
document.removeEventListener('fullscreenchange', attachFullscreenEvent)
|
document.removeEventListener('fullscreenchange', attachFullscreenEvent)
|
||||||
|
sidebarStore.isSidebarCollapsed = false
|
||||||
|
trackVideoWatchDuration()
|
||||||
})
|
})
|
||||||
|
|
||||||
const lesson = createResource({
|
const lesson = createResource({
|
||||||
@@ -386,16 +437,22 @@ const setupLesson = (data) => {
|
|||||||
editor.value?.isReady.then(() => {
|
editor.value?.isReady.then(() => {
|
||||||
checkIfDiscussionsAllowed()
|
checkIfDiscussionsAllowed()
|
||||||
})
|
})
|
||||||
|
checkQuiz()
|
||||||
|
}
|
||||||
|
|
||||||
if (!editor.value && data.body) {
|
const checkQuiz = () => {
|
||||||
|
if (!editor.value && lesson.body) {
|
||||||
const quizRegex = /\{\{ Quiz\(".*"\) \}\}/
|
const quizRegex = /\{\{ Quiz\(".*"\) \}\}/
|
||||||
hasQuiz.value = quizRegex.test(data.body)
|
hasQuiz.value = quizRegex.test(lesson.body)
|
||||||
if (!hasQuiz.value && !zenModeEnabled) allowDiscussions.value = true
|
if (!hasQuiz.value && !zenModeEnabled) {
|
||||||
|
allowDiscussions.value = true
|
||||||
|
} else {
|
||||||
|
allowDiscussions.value = false
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const renderEditor = (holder, content) => {
|
const renderEditor = (holder, content) => {
|
||||||
// empty the holder
|
|
||||||
if (document.getElementById(holder))
|
if (document.getElementById(holder))
|
||||||
document.getElementById(holder).innerHTML = ''
|
document.getElementById(holder).innerHTML = ''
|
||||||
return new EditorJS({
|
return new EditorJS({
|
||||||
@@ -403,7 +460,7 @@ const renderEditor = (holder, content) => {
|
|||||||
tools: getEditorTools(),
|
tools: getEditorTools(),
|
||||||
data: JSON.parse(content),
|
data: JSON.parse(content),
|
||||||
readOnly: true,
|
readOnly: true,
|
||||||
defaultBlock: 'embed', // editor adds an empty block at the top, so to avoid that added default block as embed
|
defaultBlock: 'embed',
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -426,6 +483,23 @@ const progress = createResource({
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const notes = createListResource({
|
||||||
|
doctype: 'LMS Lesson Note',
|
||||||
|
filters: {
|
||||||
|
lesson: lesson.data?.name,
|
||||||
|
member: user.data?.name,
|
||||||
|
},
|
||||||
|
fields: ['name', 'color', 'highlighted_text', 'note'],
|
||||||
|
cache: ['notes', lesson.data?.name, user.data?.name],
|
||||||
|
onSuccess(data) {
|
||||||
|
data.forEach((note) => {
|
||||||
|
setTimeout(() => {
|
||||||
|
highlightText(note)
|
||||||
|
}, 500)
|
||||||
|
})
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
const breadcrumbs = computed(() => {
|
const breadcrumbs = computed(() => {
|
||||||
let items = [{ label: 'Courses', route: { name: 'Courses' } }]
|
let items = [{ label: 'Courses', route: { name: 'Courses' } }]
|
||||||
items.push({
|
items.push({
|
||||||
@@ -446,13 +520,42 @@ const breadcrumbs = computed(() => {
|
|||||||
return items
|
return items
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const switchLesson = (direction) => {
|
||||||
|
trackVideoWatchDuration()
|
||||||
|
let lessonIndex =
|
||||||
|
direction === 'prev'
|
||||||
|
? lesson.data.prev.split('.')
|
||||||
|
: lesson.data.next.split('.')
|
||||||
|
|
||||||
|
router.push({
|
||||||
|
name: 'Lesson',
|
||||||
|
params: {
|
||||||
|
courseName: props.courseName,
|
||||||
|
chapterNumber: lessonIndex[0],
|
||||||
|
lessonNumber: lessonIndex[1],
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
[() => route.params.chapterNumber, () => route.params.lessonNumber],
|
[() => route.params.chapterNumber, () => route.params.lessonNumber],
|
||||||
(
|
async (
|
||||||
[newChapterNumber, newLessonNumber],
|
[newChapterNumber, newLessonNumber],
|
||||||
[oldChapterNumber, oldLessonNumber]
|
[oldChapterNumber, oldLessonNumber]
|
||||||
) => {
|
) => {
|
||||||
if (newChapterNumber || newLessonNumber) {
|
if (newChapterNumber || newLessonNumber) {
|
||||||
|
plyrSources.value = []
|
||||||
|
await nextTick()
|
||||||
|
resetLessonState(newChapterNumber, newLessonNumber)
|
||||||
|
startTimer()
|
||||||
|
updateNotes()
|
||||||
|
checkIfDiscussionsAllowed()
|
||||||
|
checkQuiz()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
const resetLessonState = (newChapterNumber, newLessonNumber) => {
|
||||||
editor.value = null
|
editor.value = null
|
||||||
instructorEditor.value = null
|
instructorEditor.value = null
|
||||||
allowDiscussions.value = false
|
allowDiscussions.value = false
|
||||||
@@ -462,22 +565,118 @@ watch(
|
|||||||
})
|
})
|
||||||
clearInterval(timerInterval)
|
clearInterval(timerInterval)
|
||||||
timer.value = 0
|
timer.value = 0
|
||||||
startTimer()
|
|
||||||
enablePlyr()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const trackVideoWatchDuration = () => {
|
||||||
|
if (!lesson.data.membership) return
|
||||||
|
let videoDetails = getVideoDetails()
|
||||||
|
videoDetails = videoDetails.concat(getPlyrSourceDetails())
|
||||||
|
call('lms.lms.api.track_video_watch_duration', {
|
||||||
|
lesson: lesson.data.name,
|
||||||
|
videos: videoDetails,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const getVideoDetails = () => {
|
||||||
|
let details = []
|
||||||
|
const videos = document.querySelectorAll('video')
|
||||||
|
if (videos.length > 0) {
|
||||||
|
videos.forEach((video) => {
|
||||||
|
if (video.currentTime == video.duration) markProgress()
|
||||||
|
details.push({
|
||||||
|
source: video.src,
|
||||||
|
watch_time: video.currentTime,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return details
|
||||||
|
}
|
||||||
|
|
||||||
|
const getPlyrSourceDetails = () => {
|
||||||
|
let details = []
|
||||||
|
plyrSources.value.forEach((source) => {
|
||||||
|
if (source.currentTime == source.duration) markProgress()
|
||||||
|
let src = cleanYouTubeUrl(source.source)
|
||||||
|
details.push({
|
||||||
|
source: src,
|
||||||
|
watch_time: source.currentTime,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
return details
|
||||||
|
}
|
||||||
|
|
||||||
|
const cleanYouTubeUrl = (url) => {
|
||||||
|
if (!url) return url
|
||||||
|
const urlObj = new URL(url)
|
||||||
|
urlObj.searchParams.delete('t')
|
||||||
|
return urlObj.toString()
|
||||||
}
|
}
|
||||||
)
|
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
() => lesson.data,
|
() => lesson.data,
|
||||||
(data) => {
|
async (data) => {
|
||||||
setupLesson(data)
|
setupLesson(data)
|
||||||
enablePlyr()
|
getPlyrSource()
|
||||||
|
updateNotes()
|
||||||
|
if (data.icon == 'icon-youtube') clearInterval(timerInterval)
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const getPlyrSource = async () => {
|
||||||
|
await nextTick()
|
||||||
|
if (plyrSources.value.length == 0) {
|
||||||
|
plyrSources.value = await enablePlyr()
|
||||||
|
}
|
||||||
|
updateVideoWatchDuration()
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateVideoWatchDuration = () => {
|
||||||
|
if (lesson.data.videos && lesson.data.videos.length > 0) {
|
||||||
|
lesson.data.videos.forEach((video) => {
|
||||||
|
if (video.source.includes('youtube') || video.source.includes('vimeo')) {
|
||||||
|
updatePlyrVideoTime(video)
|
||||||
|
} else {
|
||||||
|
updateVideoTime(video)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const updatePlyrVideoTime = (video) => {
|
||||||
|
plyrSources.value.forEach((plyrSource) => {
|
||||||
|
let lastWatchedTime = 0
|
||||||
|
let isSeeking = false
|
||||||
|
|
||||||
|
plyrSource.on('ready', () => {
|
||||||
|
if (plyrSource.source === video.source) {
|
||||||
|
plyrSource.embed.seekTo(video.watch_time, true)
|
||||||
|
plyrSource.play()
|
||||||
|
plyrSource.pause()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateVideoTime = (video) => {
|
||||||
|
const videos = document.querySelectorAll('video')
|
||||||
|
if (videos.length > 0) {
|
||||||
|
videos.forEach((vid) => {
|
||||||
|
if (vid.src === video.source) {
|
||||||
|
let watch_time = video.watch_time < vid.duration ? video.watch_time : 0
|
||||||
|
if (vid.readyState >= 1) {
|
||||||
|
vid.currentTime = watch_time
|
||||||
|
} else {
|
||||||
|
vid.addEventListener('loadedmetadata', () => {
|
||||||
|
vid.currentTime = watch_time
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const startTimer = () => {
|
const startTimer = () => {
|
||||||
timerInterval = setInterval(() => {
|
let timerInterval = setInterval(() => {
|
||||||
timer.value++
|
timer.value++
|
||||||
if (timer.value == 30) {
|
if (timer.value == 30) {
|
||||||
clearInterval(timerInterval)
|
clearInterval(timerInterval)
|
||||||
@@ -491,8 +690,11 @@ onBeforeUnmount(() => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
const checkIfDiscussionsAllowed = () => {
|
const checkIfDiscussionsAllowed = () => {
|
||||||
|
hasQuiz.value = false
|
||||||
JSON.parse(lesson.data?.content)?.blocks?.forEach((block) => {
|
JSON.parse(lesson.data?.content)?.blocks?.forEach((block) => {
|
||||||
if (block.type === 'quiz') hasQuiz.value = true
|
if (block.type === 'quiz') {
|
||||||
|
hasQuiz.value = true
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
if (
|
if (
|
||||||
@@ -501,8 +703,11 @@ const checkIfDiscussionsAllowed = () => {
|
|||||||
(lesson.data?.membership ||
|
(lesson.data?.membership ||
|
||||||
user.data?.is_moderator ||
|
user.data?.is_moderator ||
|
||||||
user.data?.is_instructor)
|
user.data?.is_instructor)
|
||||||
)
|
) {
|
||||||
allowDiscussions.value = true
|
allowDiscussions.value = true
|
||||||
|
} else {
|
||||||
|
allowDiscussions.value = false
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const allowEdit = () => {
|
const allowEdit = () => {
|
||||||
@@ -542,13 +747,31 @@ const enrollStudent = () => {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const toggleInlineMenu = async () => {
|
||||||
|
showInlineMenu.value = false
|
||||||
|
await nextTick()
|
||||||
|
let selection = window.getSelection()
|
||||||
|
if (selection.toString()) {
|
||||||
|
showInlineMenu.value = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const canSeeStats = () => {
|
||||||
|
if (user.data?.is_moderator || user.data?.is_instructor) return true
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
const showVideoStats = () => {
|
||||||
|
showStatsDialog.value = true
|
||||||
|
}
|
||||||
|
|
||||||
const canGoZen = () => {
|
const canGoZen = () => {
|
||||||
if (
|
if (
|
||||||
user.data?.is_moderator ||
|
user.data?.is_moderator ||
|
||||||
user.data?.is_instructor ||
|
user.data?.is_instructor ||
|
||||||
user.data?.is_evaluator
|
user.data?.is_evaluator
|
||||||
)
|
)
|
||||||
return false
|
return true
|
||||||
if (lesson.data?.membership) return true
|
if (lesson.data?.membership) return true
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
@@ -584,6 +807,38 @@ const scrollDiscussionsIntoView = () => {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const updateNotes = () => {
|
||||||
|
notes.update({
|
||||||
|
filters: {
|
||||||
|
lesson: lesson.data?.name,
|
||||||
|
member: user.data?.name,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
notes.reload()
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(allowDiscussions, () => {
|
||||||
|
if (allowDiscussions.value) {
|
||||||
|
tabs.value = [
|
||||||
|
{
|
||||||
|
label: __('Notes'),
|
||||||
|
value: 'Notes',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: __('Community'),
|
||||||
|
value: 'Community',
|
||||||
|
},
|
||||||
|
]
|
||||||
|
} else {
|
||||||
|
tabs.value = [
|
||||||
|
{
|
||||||
|
label: __('Notes'),
|
||||||
|
value: 'Notes',
|
||||||
|
},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
const redirectToLogin = () => {
|
const redirectToLogin = () => {
|
||||||
window.location.href = `/login?redirect-to=/lms/courses/${props.courseName}`
|
window.location.href = `/login?redirect-to=/lms/courses/${props.courseName}`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -99,7 +99,7 @@ import EditorJS from '@editorjs/editorjs'
|
|||||||
import LessonHelp from '@/components/LessonHelp.vue'
|
import LessonHelp from '@/components/LessonHelp.vue'
|
||||||
import { ChevronRight } from 'lucide-vue-next'
|
import { ChevronRight } from 'lucide-vue-next'
|
||||||
import { getEditorTools, enablePlyr } from '@/utils'
|
import { getEditorTools, enablePlyr } from '@/utils'
|
||||||
import { capture } from '@/telemetry'
|
import { capture, startRecording, stopRecording } from '@/telemetry'
|
||||||
import { useOnboarding } from 'frappe-ui/frappe'
|
import { useOnboarding } from 'frappe-ui/frappe'
|
||||||
|
|
||||||
const { brand } = sessionStore()
|
const { brand } = sessionStore()
|
||||||
@@ -131,6 +131,7 @@ onMounted(() => {
|
|||||||
window.location.href = '/login'
|
window.location.href = '/login'
|
||||||
}
|
}
|
||||||
capture('lesson_form_opened')
|
capture('lesson_form_opened')
|
||||||
|
startRecording()
|
||||||
editor.value = renderEditor('content')
|
editor.value = renderEditor('content')
|
||||||
instructorEditor.value = renderEditor('instructor-notes')
|
instructorEditor.value = renderEditor('instructor-notes')
|
||||||
window.addEventListener('keydown', keyboardShortcut)
|
window.addEventListener('keydown', keyboardShortcut)
|
||||||
@@ -226,6 +227,7 @@ const keyboardShortcut = (e) => {
|
|||||||
onBeforeUnmount(() => {
|
onBeforeUnmount(() => {
|
||||||
clearInterval(autoSaveInterval)
|
clearInterval(autoSaveInterval)
|
||||||
window.removeEventListener('keydown', keyboardShortcut)
|
window.removeEventListener('keydown', keyboardShortcut)
|
||||||
|
stopRecording()
|
||||||
})
|
})
|
||||||
|
|
||||||
const newLessonResource = createResource({
|
const newLessonResource = createResource({
|
||||||
@@ -383,8 +385,10 @@ const saveLesson = (e) => {
|
|||||||
showSuccessMessage = true
|
showSuccessMessage = true
|
||||||
}
|
}
|
||||||
editor.value.save().then((outputData) => {
|
editor.value.save().then((outputData) => {
|
||||||
|
outputData = removeEmptyBlocks(outputData)
|
||||||
lesson.content = JSON.stringify(outputData)
|
lesson.content = JSON.stringify(outputData)
|
||||||
instructorEditor.value.save().then((outputData) => {
|
instructorEditor.value.save().then((outputData) => {
|
||||||
|
outputData = removeEmptyBlocks(outputData)
|
||||||
lesson.instructor_content = JSON.stringify(outputData)
|
lesson.instructor_content = JSON.stringify(outputData)
|
||||||
if (lessonDetails.data?.lesson) {
|
if (lessonDetails.data?.lesson) {
|
||||||
editCurrentLesson()
|
editCurrentLesson()
|
||||||
@@ -395,6 +399,14 @@ const saveLesson = (e) => {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const removeEmptyBlocks = (outputData) => {
|
||||||
|
let blocks = outputData.blocks.filter((block) => {
|
||||||
|
return Object.keys(block.data).length > 0 || block.type == 'paragraph'
|
||||||
|
})
|
||||||
|
outputData.blocks = blocks
|
||||||
|
return outputData
|
||||||
|
}
|
||||||
|
|
||||||
const createNewLesson = () => {
|
const createNewLesson = () => {
|
||||||
newLessonResource.submit(
|
newLessonResource.submit(
|
||||||
{},
|
{},
|
||||||
@@ -651,6 +663,57 @@ iframe {
|
|||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.ce-popover__container {
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cdx-search-field {
|
||||||
|
border: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cdx-search-field__input {
|
||||||
|
font-weight: 400;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cdx-search-field__input::before {
|
||||||
|
font-weight: 400;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cdx-search-field__input:focus {
|
||||||
|
--tw-ring-color: theme('colors.gray.100');
|
||||||
|
}
|
||||||
|
|
||||||
|
.ce-popover-item__title {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 400;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ce-popover-item__icon svg {
|
||||||
|
width: 15px;
|
||||||
|
height: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ce-popover--opened > .ce-popover__container {
|
||||||
|
max-height: unset;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cdx-search-field__icon svg {
|
||||||
|
width: 15px;
|
||||||
|
height: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cdx-search-field__icon {
|
||||||
|
margin-right: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cdx-block.embed-tool {
|
||||||
|
position: relative;
|
||||||
|
display: inline-block;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
:root {
|
:root {
|
||||||
--plyr-range-fill-background: white;
|
--plyr-range-fill-background: white;
|
||||||
--plyr-video-control-background-hover: transparent;
|
--plyr-video-control-background-hover: transparent;
|
||||||
|
|||||||
@@ -22,6 +22,7 @@
|
|||||||
<div
|
<div
|
||||||
v-if="notifications?.length"
|
v-if="notifications?.length"
|
||||||
v-for="log in notifications"
|
v-for="log in notifications"
|
||||||
|
:key="log.name"
|
||||||
class="flex items-center py-2 justify-between"
|
class="flex items-center py-2 justify-between"
|
||||||
>
|
>
|
||||||
<div class="flex items-center">
|
<div class="flex items-center">
|
||||||
@@ -32,22 +33,20 @@
|
|||||||
<Link
|
<Link
|
||||||
v-if="log.link"
|
v-if="log.link"
|
||||||
:to="log.link"
|
:to="log.link"
|
||||||
@click="markAsRead.submit({ name: log.name })"
|
@click="(e) => handleMarkAsRead(e, log.name)"
|
||||||
class="text-ink-gray-5 font-medium text-sm hover:text-ink-gray-7"
|
class="text-ink-gray-5 font-medium text-sm hover:text-ink-gray-7"
|
||||||
>
|
>
|
||||||
{{ __('View') }}
|
{{ __('View') }}
|
||||||
</Link>
|
</Link>
|
||||||
<Tooltip :text="__('Mark as read')">
|
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
v-if="!log.read"
|
v-if="!log.read"
|
||||||
@click="markAsRead.submit({ name: log.name })"
|
@click.stop="(e) => handleMarkAsRead(e, log.name)"
|
||||||
>
|
>
|
||||||
<template #icon>
|
<template #icon>
|
||||||
<X class="h-4 w-4 text-ink-gray-7 stroke-1.5" />
|
<X class="h-4 w-4 text-ink-gray-7 stroke-1.5" />
|
||||||
</template>
|
</template>
|
||||||
</Button>
|
</Button>
|
||||||
</Tooltip>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div v-else class="text-ink-gray-5">
|
<div v-else class="text-ink-gray-5">
|
||||||
@@ -64,11 +63,10 @@ import {
|
|||||||
Link,
|
Link,
|
||||||
TabButtons,
|
TabButtons,
|
||||||
Button,
|
Button,
|
||||||
Tooltip,
|
|
||||||
usePageMeta,
|
usePageMeta,
|
||||||
} from 'frappe-ui'
|
} from 'frappe-ui'
|
||||||
import { sessionStore } from '../stores/session'
|
import { sessionStore } from '../stores/session'
|
||||||
import { computed, inject, ref, onMounted } from 'vue'
|
import { computed, inject, ref, onMounted, onUnmounted } from 'vue'
|
||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
import { X } from 'lucide-vue-next'
|
import { X } from 'lucide-vue-next'
|
||||||
|
|
||||||
@@ -135,6 +133,14 @@ const markAllAsRead = createResource({
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const handleMarkAsRead = (e, logName) => {
|
||||||
|
markAsRead.submit({ name: logName })
|
||||||
|
}
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
socket.off('publish_lms_notifications')
|
||||||
|
})
|
||||||
|
|
||||||
const breadcrumbs = computed(() => {
|
const breadcrumbs = computed(() => {
|
||||||
let crumbs = [
|
let crumbs = [
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -72,7 +72,7 @@ const persona = reactive({
|
|||||||
const submitPersona = () => {
|
const submitPersona = () => {
|
||||||
let responses = {
|
let responses = {
|
||||||
site: user.data?.sitename,
|
site: user.data?.sitename,
|
||||||
no_of_students: persona.noOfStudents,
|
role: persona.role,
|
||||||
use_case: persona.useCase,
|
use_case: persona.useCase,
|
||||||
}
|
}
|
||||||
call('lms.lms.api.capture_user_persona', {
|
call('lms.lms.api.capture_user_persona', {
|
||||||
|
|||||||
@@ -80,7 +80,7 @@
|
|||||||
v-model="activeTab"
|
v-model="activeTab"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<router-view :profile="profile" />
|
<router-view :profile="profile" :key="profile.data?.name" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<EditProfile
|
<EditProfile
|
||||||
|
|||||||
@@ -58,6 +58,7 @@ const evaluations = createListResource({
|
|||||||
doctype: 'LMS Certificate Request',
|
doctype: 'LMS Certificate Request',
|
||||||
filters: {
|
filters: {
|
||||||
evaluator: user.data?.name,
|
evaluator: user.data?.name,
|
||||||
|
status: ['!=', 'Cancelled'],
|
||||||
},
|
},
|
||||||
fields: [
|
fields: [
|
||||||
'name',
|
'name',
|
||||||
@@ -76,7 +77,7 @@ const evaluations = createListResource({
|
|||||||
],
|
],
|
||||||
auto: true,
|
auto: true,
|
||||||
orderBy: 'creation desc',
|
orderBy: 'creation desc',
|
||||||
limit: 100,
|
pageLength: 500,
|
||||||
cache: ['schedule', user.data?.name],
|
cache: ['schedule', user.data?.name],
|
||||||
transform(data) {
|
transform(data) {
|
||||||
return data.map((d) => {
|
return data.map((d) => {
|
||||||
|
|||||||
@@ -45,7 +45,7 @@
|
|||||||
</template>
|
</template>
|
||||||
<script setup>
|
<script setup>
|
||||||
import { FormControl, createResource, toast } from 'frappe-ui'
|
import { FormControl, createResource, toast } from 'frappe-ui'
|
||||||
import { ref } from 'vue'
|
import { ref, watch } from 'vue'
|
||||||
import { convertToTitleCase } from '@/utils'
|
import { convertToTitleCase } from '@/utils'
|
||||||
import { CircleAlert } from 'lucide-vue-next'
|
import { CircleAlert } from 'lucide-vue-next'
|
||||||
|
|
||||||
@@ -66,10 +66,9 @@ const roles = createResource({
|
|||||||
url: 'lms.lms.utils.get_roles',
|
url: 'lms.lms.utils.get_roles',
|
||||||
makeParams(values) {
|
makeParams(values) {
|
||||||
return {
|
return {
|
||||||
name: props.profile.data?.name,
|
name: values.member,
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
auto: true,
|
|
||||||
onSuccess(data) {
|
onSuccess(data) {
|
||||||
let roles = [
|
let roles = [
|
||||||
'moderator',
|
'moderator',
|
||||||
@@ -83,6 +82,16 @@ const roles = createResource({
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.profile,
|
||||||
|
(newValue) => {
|
||||||
|
roles.reload({
|
||||||
|
member: newValue.data?.name,
|
||||||
|
})
|
||||||
|
},
|
||||||
|
{ immediate: true }
|
||||||
|
)
|
||||||
|
|
||||||
const updateRole = createResource({
|
const updateRole = createResource({
|
||||||
url: 'lms.lms.api.save_role',
|
url: 'lms.lms.api.save_role',
|
||||||
makeParams(values) {
|
makeParams(values) {
|
||||||
@@ -97,7 +106,10 @@ const updateRole = createResource({
|
|||||||
const changeRole = (role) => {
|
const changeRole = (role) => {
|
||||||
updateRole.submit(
|
updateRole.submit(
|
||||||
{
|
{
|
||||||
role: convertToTitleCase(role.split('_').join(' ')),
|
role:
|
||||||
|
role == 'lms_student'
|
||||||
|
? 'LMS Student'
|
||||||
|
: convertToTitleCase(role.split('_').join(' ')),
|
||||||
value: eval(role).value,
|
value: eval(role).value,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -0,0 +1,255 @@
|
|||||||
|
<template>
|
||||||
|
<Dialog v-model="show" :options="{ size: '5xl' }">
|
||||||
|
<template #body-title>
|
||||||
|
<div class="text-xl font-semibold text-ink-gray-9">
|
||||||
|
{{
|
||||||
|
props.exerciseID === 'new'
|
||||||
|
? __('Create Programming Exercise')
|
||||||
|
: __('Edit Programming Exercise')
|
||||||
|
}}
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<template #body-content>
|
||||||
|
<div class="grid grid-cols-2 gap-10">
|
||||||
|
<div class="space-y-4">
|
||||||
|
<FormControl
|
||||||
|
v-model="exercise.title"
|
||||||
|
:label="__('Title')"
|
||||||
|
:required="true"
|
||||||
|
/>
|
||||||
|
<FormControl
|
||||||
|
v-model="exercise.language"
|
||||||
|
:label="__('Language')"
|
||||||
|
type="select"
|
||||||
|
:options="languageOptions"
|
||||||
|
:required="true"
|
||||||
|
/>
|
||||||
|
<ChildTable
|
||||||
|
v-model="exercise.test_cases"
|
||||||
|
:label="__('Test Cases')"
|
||||||
|
:columns="testCaseColumns"
|
||||||
|
:required="true"
|
||||||
|
:addable="true"
|
||||||
|
:deletable="true"
|
||||||
|
:editable="true"
|
||||||
|
:placeholder="__('Add Test Case')"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div>
|
||||||
|
<div class="text-xs text-ink-gray-5 mb-2">
|
||||||
|
{{ __('Problem Statement') }}
|
||||||
|
<span class="text-ink-red-3">*</span>
|
||||||
|
</div>
|
||||||
|
<TextEditor
|
||||||
|
:content="exercise.problem_statement"
|
||||||
|
@change="(val: string) => (exercise.problem_statement = val)"
|
||||||
|
:editable="true"
|
||||||
|
:fixedMenu="true"
|
||||||
|
editorClass="prose-sm max-w-none border-b border-x bg-surface-gray-2 rounded-b-md py-1 px-2 min-h-[7rem] max-h-[21rem] overflow-y-auto"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<template #actions="{ close }">
|
||||||
|
<div class="flex justify-end space-x-2 group">
|
||||||
|
<Button
|
||||||
|
v-if="exerciseID != 'new'"
|
||||||
|
@click="deleteExercise(close)"
|
||||||
|
variant="outline"
|
||||||
|
theme="red"
|
||||||
|
class="invisible group-hover:visible"
|
||||||
|
>
|
||||||
|
<template #prefix>
|
||||||
|
<Trash2 class="size-4 stroke-1.5" />
|
||||||
|
</template>
|
||||||
|
{{ __('Delete') }}
|
||||||
|
</Button>
|
||||||
|
<router-link
|
||||||
|
:to="{
|
||||||
|
name: 'ProgrammingExerciseSubmission',
|
||||||
|
params: {
|
||||||
|
exerciseID: props.exerciseID,
|
||||||
|
submissionID: 'new',
|
||||||
|
},
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
<Button>
|
||||||
|
<template #prefix>
|
||||||
|
<Play class="size-4 stroke-1.5" />
|
||||||
|
</template>
|
||||||
|
{{ __('Test this Exercise') }}
|
||||||
|
</Button>
|
||||||
|
</router-link>
|
||||||
|
<router-link
|
||||||
|
:to="{
|
||||||
|
name: 'ProgrammingExerciseSubmissions',
|
||||||
|
query: {
|
||||||
|
exercise: props.exerciseID,
|
||||||
|
},
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
<Button>
|
||||||
|
<template #prefix>
|
||||||
|
<ClipboardList class="size-4 stroke-1.5" />
|
||||||
|
</template>
|
||||||
|
{{ __('Check Submission') }}
|
||||||
|
</Button>
|
||||||
|
</router-link>
|
||||||
|
<Button variant="solid" @click="saveExercise(close)">
|
||||||
|
{{ __('Save') }}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</Dialog>
|
||||||
|
</template>
|
||||||
|
<script setup lang="ts">
|
||||||
|
import {
|
||||||
|
Button,
|
||||||
|
createListResource,
|
||||||
|
Dialog,
|
||||||
|
FormControl,
|
||||||
|
TextEditor,
|
||||||
|
toast,
|
||||||
|
} from 'frappe-ui'
|
||||||
|
import { computed, ref, watch } from 'vue'
|
||||||
|
import {
|
||||||
|
ProgrammingExercise,
|
||||||
|
ProgrammingExercises,
|
||||||
|
TestCase,
|
||||||
|
} from '@/types/programming-exercise'
|
||||||
|
import ChildTable from '@/components/Controls/ChildTable.vue'
|
||||||
|
import { ClipboardList, Play, Trash2 } from 'lucide-vue-next'
|
||||||
|
|
||||||
|
const show = defineModel()
|
||||||
|
const exercises = defineModel<ProgrammingExercises>('exercises')
|
||||||
|
|
||||||
|
const exercise = ref<ProgrammingExercise>({
|
||||||
|
title: '',
|
||||||
|
language: 'Python',
|
||||||
|
problem_statement: '',
|
||||||
|
test_cases: [],
|
||||||
|
})
|
||||||
|
|
||||||
|
const languageOptions = [
|
||||||
|
{ label: 'Python', value: 'Python' },
|
||||||
|
{ label: 'JavaScript', value: 'JavaScript' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const props = withDefaults(
|
||||||
|
defineProps<{
|
||||||
|
exerciseID: string
|
||||||
|
}>(),
|
||||||
|
{
|
||||||
|
exerciseID: 'new',
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.exerciseID,
|
||||||
|
() => {
|
||||||
|
setExerciseData()
|
||||||
|
fetchTestCases()
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
const setExerciseData = () => {
|
||||||
|
let isNew = true
|
||||||
|
exercises.value?.data.forEach((ex: ProgrammingExercise) => {
|
||||||
|
if (ex.name === props.exerciseID) {
|
||||||
|
isNew = false
|
||||||
|
exercise.value = { ...ex }
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
if (isNew) {
|
||||||
|
exercise.value = {
|
||||||
|
title: '',
|
||||||
|
language: 'Python',
|
||||||
|
problem_statement: '',
|
||||||
|
test_cases: [],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const testCases = createListResource({
|
||||||
|
doctype: 'LMS Test Case',
|
||||||
|
fields: ['input', 'expected_output', 'name'],
|
||||||
|
cache: ['testCases', props.exerciseID],
|
||||||
|
parent: 'LMS Programming Exercise',
|
||||||
|
onSuccess(data: TestCase[]) {
|
||||||
|
exercise.value.test_cases = data
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const fetchTestCases = () => {
|
||||||
|
testCases.update({
|
||||||
|
filters: {
|
||||||
|
parent: props.exerciseID,
|
||||||
|
parenttype: 'LMS Programming Exercise',
|
||||||
|
parentfield: 'test_cases',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
testCases.reload()
|
||||||
|
}
|
||||||
|
|
||||||
|
const saveExercise = (close: () => void) => {
|
||||||
|
if (props.exerciseID == 'new') createNewExercise(close)
|
||||||
|
else updateExercise(close)
|
||||||
|
}
|
||||||
|
|
||||||
|
const createNewExercise = (close: () => void) => {
|
||||||
|
exercises.value?.insert.submit(
|
||||||
|
{
|
||||||
|
...exercise.value,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
onSuccess() {
|
||||||
|
close()
|
||||||
|
exercises.value?.reload()
|
||||||
|
toast.success(__('Programming Exercise created successfully'))
|
||||||
|
},
|
||||||
|
onError(err: any) {
|
||||||
|
toast.warning(__(err.messages?.[0] || err))
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateExercise = (close: () => void) => {
|
||||||
|
exercises.value?.setValue.submit(
|
||||||
|
{
|
||||||
|
name: props.exerciseID,
|
||||||
|
...exercise.value,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
onSuccess() {
|
||||||
|
close()
|
||||||
|
exercises.value?.reload()
|
||||||
|
toast.success(__('Programming Exercise updated successfully'))
|
||||||
|
},
|
||||||
|
onError(err: any) {
|
||||||
|
toast.warning(__(err.messages?.[0] || err))
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const testCaseColumns = computed(() => {
|
||||||
|
return ['Input', 'Expected Output']
|
||||||
|
})
|
||||||
|
|
||||||
|
const deleteExercise = (close: () => void) => {
|
||||||
|
if (props.exerciseID == 'new') return
|
||||||
|
exercises.value?.delete.submit(props.exerciseID, {
|
||||||
|
onSuccess() {
|
||||||
|
toast.success(__('Programming Exercise deleted successfully'))
|
||||||
|
close()
|
||||||
|
},
|
||||||
|
onError(err: any) {
|
||||||
|
toast.warning(__(err.messages?.[0] || err))
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
<template>
|
||||||
|
<Dialog
|
||||||
|
v-model="show"
|
||||||
|
:options="{
|
||||||
|
title: __('Add a programming exercise to your lesson'),
|
||||||
|
size: 'xl',
|
||||||
|
actions: [
|
||||||
|
{
|
||||||
|
label: __('Save'),
|
||||||
|
variant: 'solid',
|
||||||
|
onClick: () => {
|
||||||
|
saveExercise()
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
<template #body-content>
|
||||||
|
<div class="text-base">
|
||||||
|
<Link
|
||||||
|
v-model="exercise"
|
||||||
|
doctype="LMS Programming Exercise"
|
||||||
|
:label="__('Select a Programming Exercise')"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</Dialog>
|
||||||
|
</template>
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { Dialog } from 'frappe-ui'
|
||||||
|
import { onMounted, nextTick, ref } from 'vue'
|
||||||
|
import Link from '@/components/Controls/Link.vue'
|
||||||
|
|
||||||
|
const show = ref(false)
|
||||||
|
const exercise = ref(null)
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
onSave: {
|
||||||
|
type: Function,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
await nextTick()
|
||||||
|
show.value = true
|
||||||
|
})
|
||||||
|
|
||||||
|
const saveExercise = () => {
|
||||||
|
props.onSave(exercise.value)
|
||||||
|
show.value = false
|
||||||
|
}
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,430 @@
|
|||||||
|
<template>
|
||||||
|
<header
|
||||||
|
v-if="!fromLesson"
|
||||||
|
class="sticky flex items-center justify-between top-0 z-10 border-b bg-surface-white px-3 py-2.5 sm:px-5"
|
||||||
|
>
|
||||||
|
<Breadcrumbs :items="breadcrumbs" />
|
||||||
|
</header>
|
||||||
|
<div
|
||||||
|
v-if="falconError"
|
||||||
|
class="flex items-center justify-between p-3 text-sm bg-surface-amber-1 text-ink-amber-3"
|
||||||
|
>
|
||||||
|
<span>
|
||||||
|
{{ falconError }}
|
||||||
|
</span>
|
||||||
|
<Button v-if="user.data?.is_moderator" @click="openSettings('General')">
|
||||||
|
<template #prefix>
|
||||||
|
<Settings class="size-4 stroke-1.5" />
|
||||||
|
</template>
|
||||||
|
{{ __('Settings') }}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<div class="grid grid-cols-2 h-[calc(100vh_-_3rem)]">
|
||||||
|
<div class="border-r py-5 px-8 h-full">
|
||||||
|
<div class="font-semibold mb-2">
|
||||||
|
{{ __('Problem Statement') }}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-html="exercise.doc?.problem_statement"
|
||||||
|
class="ProseMirror prose prose-table:table-fixed prose-td:p-2 prose-th:p-2 prose-td:border prose-th:border prose-td:border-outline-gray-2 prose-th:border-outline-gray-2 prose-td:relative prose-th:relative prose-th:bg-surface-gray-2 prose-sm max-w-none !whitespace-normal"
|
||||||
|
></div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div class="flex items-center justify-between p-2 bg-surface-gray-2">
|
||||||
|
<div class="font-semibold">
|
||||||
|
{{ exercise.doc?.language }}
|
||||||
|
</div>
|
||||||
|
<div class="space-x-2">
|
||||||
|
<Badge
|
||||||
|
v-if="submission.doc?.status"
|
||||||
|
:theme="submission.doc.status == 'Passed' ? 'green' : 'red'"
|
||||||
|
>
|
||||||
|
{{ submission.doc.status }}
|
||||||
|
</Badge>
|
||||||
|
<Button
|
||||||
|
v-if="
|
||||||
|
!falconError &&
|
||||||
|
(submissionID == 'new' ||
|
||||||
|
user.data?.name == submission.doc?.owner)
|
||||||
|
"
|
||||||
|
variant="solid"
|
||||||
|
@click="submitCode"
|
||||||
|
>
|
||||||
|
<template #prefix>
|
||||||
|
<Play class="size-3" />
|
||||||
|
</template>
|
||||||
|
{{ __('Run') }}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-col space-y-4 pt-5 border-b">
|
||||||
|
<Code
|
||||||
|
v-model="code"
|
||||||
|
:language="exercise.doc?.language.toLowerCase()"
|
||||||
|
height="400px"
|
||||||
|
maxHeight="1000px"
|
||||||
|
/>
|
||||||
|
<div class="flex flex-col space-y-1">
|
||||||
|
<span v-if="error" class="text-xs text-ink-gray-5 px-1">
|
||||||
|
{{ __('Compiler Message') }}:
|
||||||
|
</span>
|
||||||
|
<textarea
|
||||||
|
v-if="error"
|
||||||
|
v-model="errorMessage"
|
||||||
|
class="font-mono text-ink-red-3 bg-surface-gray-1 border-none text-sm h-32 leading-6"
|
||||||
|
readonly
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<!-- <textarea v-else v-model="output" class="bg-surface-gray-1 border-none text-sm h-28 leading-6" readonly /> -->
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div ref="testCaseSection" class="p-5">
|
||||||
|
<span class="text-lg font-semibold text-ink-gray-9">
|
||||||
|
{{ __('Test Cases') }}
|
||||||
|
</span>
|
||||||
|
<div v-if="testCases.length" class="divide-y mt-5">
|
||||||
|
<div
|
||||||
|
v-for="(testCase, index) in testCases"
|
||||||
|
:key="testCase.input"
|
||||||
|
class="py-3"
|
||||||
|
>
|
||||||
|
<div class="flex items-center mb-3">
|
||||||
|
<span class=""> {{ __('Test {0}').format(index + 1) }} - </span>
|
||||||
|
<span
|
||||||
|
class="font-semibold ml-2 mr-1"
|
||||||
|
:class="
|
||||||
|
testCase.status === 'Passed'
|
||||||
|
? 'text-ink-green-3'
|
||||||
|
: 'text-ink-red-3'
|
||||||
|
"
|
||||||
|
>
|
||||||
|
{{ testCase.status }}
|
||||||
|
</span>
|
||||||
|
<!-- <span v-if="testCase.status === 'Passed'">
|
||||||
|
<Check class="size-4 text-ink-green-3" />
|
||||||
|
</span>
|
||||||
|
<span v-else>
|
||||||
|
<X class="size-4 text-ink-red-3" />
|
||||||
|
</span> -->
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center justify-between w-[60%]">
|
||||||
|
<div v-if="testCase.input" class="space-y-2">
|
||||||
|
<div class="text-xs text-ink-gray-7">
|
||||||
|
{{ __('Input') }}
|
||||||
|
</div>
|
||||||
|
<div>{{ testCase.input }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="space-y-2">
|
||||||
|
<div class="text-xs text-ink-gray-7">
|
||||||
|
{{ __('Your Output') }}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
{{ testCase.output }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="space-y-2">
|
||||||
|
<div class="text-xs text-ink-gray-7">
|
||||||
|
{{ __('Expected Output') }}
|
||||||
|
</div>
|
||||||
|
<div>{{ testCase.expected_output }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-else class="text-sm text-ink-gray-6 mt-4">
|
||||||
|
{{ __('Please run the code to execute the test cases.') }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<script setup lang="ts">
|
||||||
|
import {
|
||||||
|
Badge,
|
||||||
|
Breadcrumbs,
|
||||||
|
Button,
|
||||||
|
call,
|
||||||
|
createDocumentResource,
|
||||||
|
toast,
|
||||||
|
usePageMeta,
|
||||||
|
} from 'frappe-ui'
|
||||||
|
import { computed, inject, onMounted, ref, watch } from 'vue'
|
||||||
|
import { Play, X, Check, Settings } from 'lucide-vue-next'
|
||||||
|
import { sessionStore } from '@/stores/session'
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
|
import { openSettings } from '@/utils'
|
||||||
|
|
||||||
|
const user = inject<any>('$user')
|
||||||
|
const code = ref<string | null>('')
|
||||||
|
const output = ref<string | null>(null)
|
||||||
|
const error = ref<boolean | null>(null)
|
||||||
|
const errorMessage = ref<string | null>(null)
|
||||||
|
const testCaseSection = ref<HTMLElement | null>(null)
|
||||||
|
const testCases = ref<TestCase[]>([])
|
||||||
|
const boilerplate = ref<string>('')
|
||||||
|
const { brand, livecodeURL } = sessionStore()
|
||||||
|
const router = useRouter()
|
||||||
|
const fromLesson = ref(false)
|
||||||
|
const falconURL = ref<string>('https://falcon.frappe.io/')
|
||||||
|
const falconError = ref<string | null>(null)
|
||||||
|
|
||||||
|
const props = withDefaults(
|
||||||
|
defineProps<{
|
||||||
|
exerciseID: string
|
||||||
|
submissionID?: string
|
||||||
|
}>(),
|
||||||
|
{
|
||||||
|
submissionID: 'new',
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
loadFalcon()
|
||||||
|
checkIfUserIsPermitted()
|
||||||
|
checkIfInLesson()
|
||||||
|
fetchSubmission()
|
||||||
|
})
|
||||||
|
|
||||||
|
const checkIfInLesson = () => {
|
||||||
|
if (new URLSearchParams(window.location.search).get('fromLesson')) {
|
||||||
|
fromLesson.value = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const fetchSubmission = (name: string = '') => {
|
||||||
|
if (name) {
|
||||||
|
submission.name = name
|
||||||
|
submission.reload()
|
||||||
|
} else if (props.submissionID != 'new') {
|
||||||
|
submission.reload()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const exercise = createDocumentResource({
|
||||||
|
doctype: 'LMS Programming Exercise',
|
||||||
|
name: props.exerciseID,
|
||||||
|
cache: ['programmingExercise', props.exerciseID],
|
||||||
|
auto: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
const submission = createDocumentResource({
|
||||||
|
doctype: 'LMS Programming Exercise Submission',
|
||||||
|
name: props.submissionID,
|
||||||
|
auto: false,
|
||||||
|
onError(error: any) {
|
||||||
|
if (error.messages?.[0].includes('not found')) {
|
||||||
|
router.push({
|
||||||
|
name: 'ProgrammingExerciseSubmission',
|
||||||
|
params: { exerciseID: props.exerciseID, submissionID: 'new' },
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
toast.error(__(error.messages?.[0] || error))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
watch(exercise, () => {
|
||||||
|
updateCode()
|
||||||
|
})
|
||||||
|
|
||||||
|
const updateCode = (submissionCode = '') => {
|
||||||
|
updateBoilerPlate()
|
||||||
|
if (!code.value?.includes(boilerplate.value)) {
|
||||||
|
code.value = `${boilerplate.value}${code.value}`
|
||||||
|
}
|
||||||
|
if (submissionCode && !code.value?.includes(submissionCode)) {
|
||||||
|
code.value = `${code.value}${submissionCode}`
|
||||||
|
} else if (!submissionCode && !code.value) {
|
||||||
|
code.value = boilerplate.value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateBoilerPlate = () => {
|
||||||
|
if (exercise.doc?.language == 'Python') {
|
||||||
|
boilerplate.value = `with open("stdin", "r") as f:\n data = f.read()\n\ninputs = data.split() if len(data) else []\n\n# inputs is a list of strings\n# write your code below\n\n`
|
||||||
|
} else if (exercise.doc?.language == 'JavaScript') {
|
||||||
|
boilerplate.value = `const fs = require('fs');\n\nlet input = fs.readFileSync('/app/stdin', 'utf8').trim();\nconst inputs = input.split("\\n");\n// inputs is an array of strings\n// write your code below\n`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const checkIfUserIsPermitted = (doc: any = null) => {
|
||||||
|
if (!user.data) {
|
||||||
|
window.location.href = `/login?redirect-to=/lms/programming-exercises/${props.exerciseID}/submission/${props.submissionID}`
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!doc) return
|
||||||
|
if (
|
||||||
|
doc.owner != user.data?.name &&
|
||||||
|
!user.data?.is_instructor &&
|
||||||
|
!user.data?.is_moderator &&
|
||||||
|
!user.data.is_evaluator
|
||||||
|
) {
|
||||||
|
router.push({
|
||||||
|
name: 'ProgrammingExerciseSubmission',
|
||||||
|
params: { exerciseID: props.exerciseID, submissionID: 'new' },
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateTestCases = (doc: any) => {
|
||||||
|
if (testCases.value.length === 0) {
|
||||||
|
testCases.value = doc.test_cases || []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => submission.doc,
|
||||||
|
(doc) => {
|
||||||
|
if (doc) {
|
||||||
|
checkIfUserIsPermitted(doc)
|
||||||
|
updateTestCases(doc)
|
||||||
|
updateCode(doc.code)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ immediate: true }
|
||||||
|
)
|
||||||
|
|
||||||
|
const loadFalcon = () => {
|
||||||
|
if (livecodeURL.data) {
|
||||||
|
falconURL.value = livecodeURL.data
|
||||||
|
}
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const script = document.createElement('script')
|
||||||
|
script.src = `${falconURL.value}static/livecode.js`
|
||||||
|
script.onload = resolve
|
||||||
|
script.onerror = reject
|
||||||
|
document.head.appendChild(script)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const submitCode = async () => {
|
||||||
|
await runCode()
|
||||||
|
createSubmission()
|
||||||
|
}
|
||||||
|
|
||||||
|
const runCode = async () => {
|
||||||
|
if (!exercise.doc?.test_cases?.length) return
|
||||||
|
|
||||||
|
testCases.value = []
|
||||||
|
if (testCaseSection.value) {
|
||||||
|
testCaseSection.value.scrollIntoView({ behavior: 'smooth' })
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const test_case of exercise.doc.test_cases) {
|
||||||
|
let result = await execute(test_case.input)
|
||||||
|
if (error.value) {
|
||||||
|
errorMessage.value = result
|
||||||
|
break
|
||||||
|
} else {
|
||||||
|
output.value = result
|
||||||
|
}
|
||||||
|
let status =
|
||||||
|
result.trim() === test_case.expected_output.trim() ? 'Passed' : 'Failed'
|
||||||
|
testCases.value.push({
|
||||||
|
input: test_case.input,
|
||||||
|
output: result,
|
||||||
|
expected_output: test_case.expected_output,
|
||||||
|
status: status,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const createSubmission = () => {
|
||||||
|
if (!testCases.value.length) return
|
||||||
|
let codeToSave = code.value?.replace(boilerplate.value, '') || ''
|
||||||
|
|
||||||
|
call('lms.lms.api.create_programming_exercise_submission', {
|
||||||
|
exercise: props.exerciseID,
|
||||||
|
submission: props.submissionID,
|
||||||
|
code: codeToSave,
|
||||||
|
test_cases: testCases.value,
|
||||||
|
})
|
||||||
|
.then((data: any) => {
|
||||||
|
if (props.submissionID == 'new') {
|
||||||
|
router.push({
|
||||||
|
name: 'ProgrammingExerciseSubmission',
|
||||||
|
params: { exerciseID: props.exerciseID, submissionID: data },
|
||||||
|
})
|
||||||
|
fetchSubmission(data)
|
||||||
|
} else {
|
||||||
|
fetchSubmission(props.submissionID)
|
||||||
|
}
|
||||||
|
toast.success(__('Submission saved!'))
|
||||||
|
})
|
||||||
|
.catch((error: any) => {
|
||||||
|
console.error('Error creating submission:', error)
|
||||||
|
toast.error(
|
||||||
|
__('Failed to submit. Please try again. {0}').format({ error })
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const execute = (stdin = ''): Promise<string> => {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
let outputChunks: string[] = []
|
||||||
|
let hasExited = false
|
||||||
|
let hasError = false
|
||||||
|
|
||||||
|
let session = new LiveCodeSession({
|
||||||
|
base_url: falconURL.value,
|
||||||
|
runtime: exercise.doc?.language.toLowerCase() || 'python',
|
||||||
|
code: code.value,
|
||||||
|
files: [{ filename: 'stdin', contents: stdin }],
|
||||||
|
onMessage: (msg: any) => {
|
||||||
|
console.log('msg', msg)
|
||||||
|
|
||||||
|
if (msg.msgtype === 'write' && msg.file === 'stdout') {
|
||||||
|
outputChunks.push(msg.data)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (msg.msgtype === 'write' && msg.file === 'stderr') {
|
||||||
|
hasError = true
|
||||||
|
errorMessage.value = msg.data
|
||||||
|
}
|
||||||
|
|
||||||
|
if (msg.msgtype === 'exitstatus') {
|
||||||
|
hasExited = true
|
||||||
|
if (msg.exitstatus !== 0) {
|
||||||
|
error.value = true
|
||||||
|
} else {
|
||||||
|
error.value = false
|
||||||
|
}
|
||||||
|
resolve(outputChunks.join('').trim())
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
if (!hasExited) {
|
||||||
|
error.value = true
|
||||||
|
errorMessage.value = 'Execution timed out.'
|
||||||
|
reject('Execution timed out.')
|
||||||
|
}
|
||||||
|
}, 20000)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const breadcrumbs = computed(() => {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
label: __('Programming Exercise Submissions'),
|
||||||
|
route: { name: 'ProgrammingExerciseSubmissions' },
|
||||||
|
},
|
||||||
|
{ label: exercise.doc?.title },
|
||||||
|
]
|
||||||
|
})
|
||||||
|
|
||||||
|
usePageMeta(() => {
|
||||||
|
return {
|
||||||
|
title: __('Programming Exercise Submission'),
|
||||||
|
icon: brand.favicon,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
<style>
|
||||||
|
.ProseMirror pre {
|
||||||
|
background: theme('colors.gray.200');
|
||||||
|
color: theme('colors.gray.900');
|
||||||
|
}
|
||||||
|
</style>
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user