Compare commits
940 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
39106de96c | ||
|
|
8adb76abfb | ||
|
|
4889b04283 | ||
|
|
e9973a242b | ||
|
|
d324ad0ac5 | ||
|
|
ab6cc2698a | ||
|
|
da076f71a1 | ||
|
|
63c1fe8e75 | ||
|
|
31f0833629 | ||
|
|
d4dc094049 | ||
|
|
21a84e8032 | ||
|
|
819a1baae0 | ||
|
|
b0ee67faff | ||
|
|
0684bd105a | ||
|
|
5c948862a0 | ||
|
|
23291b38de | ||
|
|
9357bd55a6 | ||
|
|
e51d668418 | ||
|
|
a48df70631 | ||
|
|
9853e8311b | ||
|
|
cd8041b048 | ||
|
|
00bf5b7ad6 | ||
|
|
40bcc983c7 | ||
|
|
dfb26f31db | ||
|
|
a57a0bebef | ||
|
|
4853621b1b | ||
|
|
e3209c6fb3 | ||
|
|
13d25cce1f | ||
|
|
cb16b0ca64 | ||
|
|
b3ce3159e7 | ||
|
|
76ffc70892 | ||
|
|
2734587981 | ||
|
|
223e93d654 | ||
|
|
26351726a8 | ||
|
|
efc84db580 | ||
|
|
3bf58bb6f0 | ||
|
|
a7962d9404 | ||
|
|
5ce9bb306d | ||
|
|
5b5bb38f4f | ||
|
|
419ad311a0 | ||
|
|
0c3af09566 | ||
|
|
c9063625ec | ||
|
|
205858a41d | ||
|
|
87edad17c3 | ||
|
|
1c54e80951 | ||
|
|
6c19cdc729 | ||
|
|
84a703bb50 | ||
|
|
27ed95b044 | ||
|
|
0358dfe790 | ||
|
|
68cee65f22 | ||
|
|
24b2125b97 | ||
|
|
eceed12992 | ||
|
|
f9028433a0 | ||
|
|
1936e7b212 | ||
|
|
c04a972be3 | ||
|
|
9c26b011e4 | ||
|
|
821f125789 | ||
|
|
e0f376880a | ||
|
|
6da77bb3c7 | ||
|
|
2fd660a93f | ||
|
|
f3eefc748a | ||
|
|
4852698d74 | ||
|
|
36b24dc826 | ||
|
|
d60bc1d4b6 | ||
|
|
06a02c0877 | ||
|
|
1a53a9f30b | ||
|
|
7ee81d4693 | ||
|
|
0a32d03fda | ||
|
|
6c43dfea18 | ||
|
|
7d1e226743 | ||
|
|
8ea903b81a | ||
|
|
24b08599b3 | ||
|
|
a0f1c1f227 | ||
|
|
efbb014588 | ||
|
|
f881a0e1d5 | ||
|
|
acdb81e8a3 | ||
|
|
6559a87323 | ||
|
|
8301bab768 | ||
|
|
eb0b2010f9 | ||
|
|
8158ea164d | ||
|
|
beb3134af9 | ||
|
|
5fdc6a21a5 | ||
|
|
e7516c57bc | ||
|
|
2169d81b73 | ||
|
|
1d6bb9f9f6 | ||
|
|
812bd07d03 | ||
|
|
19bb02f905 | ||
|
|
58d750f726 | ||
|
|
7f3bb58ec1 | ||
|
|
08ceaf204f | ||
|
|
eced1221a8 | ||
|
|
3a5bbde0cc | ||
|
|
eb1a790485 | ||
|
|
21e9c85bf7 | ||
|
|
632f783d57 | ||
|
|
3ba3908108 | ||
|
|
903a4e91b0 | ||
|
|
6496b129ce | ||
|
|
9fff7a2ea8 | ||
|
|
6d94617e59 | ||
|
|
b95d07babb | ||
|
|
9748d075fa | ||
|
|
aaeeb84ed3 | ||
|
|
c3702ee6d5 | ||
|
|
f239987043 | ||
|
|
6aa67c3fae | ||
|
|
1fc5d75cc0 | ||
|
|
2e0cd1964c | ||
|
|
bcfd3bb636 | ||
|
|
9b893b56f4 | ||
|
|
733254e2c0 | ||
|
|
1e351e60a5 | ||
|
|
90c53e4b46 | ||
|
|
69f54a00c5 | ||
|
|
372c4d33f0 | ||
|
|
5ba77aae3d | ||
|
|
28f4de1b7f | ||
|
|
ed162e2546 | ||
|
|
e16cecd149 | ||
|
|
3a26a5b4d8 | ||
|
|
3848af08fe | ||
|
|
ae79e52486 | ||
|
|
1968f5064d | ||
|
|
9d21bcecbe | ||
|
|
7a84933b2f | ||
|
|
9a10240fcc | ||
|
|
99516421ac | ||
|
|
5eecffb75e | ||
|
|
9d2627849a | ||
|
|
22b8222c3d | ||
|
|
b3d1b14abd | ||
|
|
f1cd0d3dd4 | ||
|
|
3d7815d65f | ||
|
|
a3e7d1f981 | ||
|
|
2e79190977 | ||
|
|
ee7debeef7 | ||
|
|
61f547733c | ||
|
|
5129e6d6ac | ||
|
|
0a9c14f8b1 | ||
|
|
a1960489e1 | ||
|
|
66fe1a83c6 | ||
|
|
a88d384ac7 | ||
|
|
2dc6b68974 | ||
|
|
0988ecc515 | ||
|
|
5ff100da27 | ||
|
|
f218846f4a | ||
|
|
fc0aba60b9 | ||
|
|
82e6a62c54 | ||
|
|
fce8950cc5 | ||
|
|
54247e85e0 | ||
|
|
cc3f9cd8a4 | ||
|
|
6dd79dc5d2 | ||
|
|
6eb53770c3 | ||
|
|
10faeca121 | ||
|
|
38252d9ffc | ||
|
|
86f62aeb99 | ||
|
|
e3a6bb8781 | ||
|
|
8ed94ed12f | ||
|
|
95bcfc2ee0 | ||
|
|
313e4811a1 | ||
|
|
5bb9dfeaf8 | ||
|
|
aabb316c7d | ||
|
|
ae40e6f41b | ||
|
|
e644d5d20d | ||
|
|
ae9abd08ff | ||
|
|
965128c802 | ||
|
|
26324a63df | ||
|
|
b9d9754818 | ||
|
|
0fb516a86c | ||
|
|
4b4c7d8927 | ||
|
|
e3cae93bd3 | ||
|
|
324f87dc19 | ||
|
|
347b5c9411 | ||
|
|
7371f8a29d | ||
|
|
fb381a30cf | ||
|
|
474e3dae65 | ||
|
|
738ac3f1c9 | ||
|
|
2fbe69afc5 | ||
|
|
693448cb42 | ||
|
|
17a416d905 | ||
|
|
09a6ede925 | ||
|
|
5fc04ae318 | ||
|
|
900ea0fb5c | ||
|
|
85a6a1d884 | ||
|
|
0572009456 | ||
|
|
9bcd98cb69 | ||
|
|
aefce2a842 | ||
|
|
ee4fe99b08 | ||
|
|
2cb85d47b8 | ||
|
|
0713b6b419 | ||
|
|
8f116fddab | ||
|
|
58838cd806 | ||
|
|
7d5918b320 | ||
|
|
7eb4ca0fb4 | ||
|
|
d575bfa0fb | ||
|
|
b60ea3f153 | ||
|
|
6e7bc6cfb4 | ||
|
|
9b85a0044c | ||
|
|
b8708382b1 | ||
|
|
e0601c7b38 | ||
|
|
0725714144 | ||
|
|
ab501e1c6a | ||
|
|
7cd401327b | ||
|
|
e886088dff | ||
|
|
ebe7cc32af | ||
|
|
5e607c3b8e | ||
|
|
5ec809e3dd | ||
|
|
9d3b6e0556 | ||
|
|
acd003814a | ||
|
|
c4991d09f3 | ||
|
|
ca9b2dada3 | ||
|
|
6a3004d75d | ||
|
|
9bd624f31a | ||
|
|
acb264da20 | ||
|
|
c23b0fbfe7 | ||
|
|
0e78b5e289 | ||
|
|
91ab895a1d | ||
|
|
5c37238114 | ||
|
|
3cf40ba504 | ||
|
|
9229961f72 | ||
|
|
0b35b11909 | ||
|
|
77a27d8716 | ||
|
|
be32939029 | ||
|
|
4f046425aa | ||
|
|
89bf6b1fd9 | ||
|
|
d81414cca6 | ||
|
|
d8eb96a37a | ||
|
|
d1b3b0ba34 | ||
|
|
dca207347f | ||
|
|
5a7ebf8027 | ||
|
|
f6d877715c | ||
|
|
754eb32db4 | ||
|
|
67c04c7f59 | ||
|
|
daebb26ffa | ||
|
|
3e2993d048 | ||
|
|
2745cf4015 | ||
|
|
4e99e2960c | ||
|
|
649028307d | ||
|
|
24787c32dd | ||
|
|
625ddac65a | ||
|
|
78ff2e6d07 | ||
|
|
837426d3c5 | ||
|
|
65477a5b2b | ||
|
|
a9fa8be5a2 | ||
|
|
0bb26150d6 | ||
|
|
fd8f7ade51 | ||
|
|
3310bc2fed | ||
|
|
0cbb640054 | ||
|
|
b4aa88ac33 | ||
|
|
f2906e57f0 | ||
|
|
8d381b167e | ||
|
|
d78e316349 | ||
|
|
82d00c6a4a | ||
|
|
ba4fd7f1ef | ||
|
|
591d265167 | ||
|
|
af9d973a91 | ||
|
|
15096a3118 | ||
|
|
7c78a9697c | ||
|
|
3f5051f697 | ||
|
|
044a66f2e8 | ||
|
|
6fb8775bb7 | ||
|
|
f16d0300ba | ||
|
|
84e12f1eea | ||
|
|
ba40b7ff15 | ||
|
|
e733326056 | ||
|
|
b3d987e933 | ||
|
|
e919874e9e | ||
|
|
a2f51f151a | ||
|
|
ea288abb27 | ||
|
|
b274900ae0 | ||
|
|
10d29d3a3f | ||
|
|
a92e04343a | ||
|
|
be31c18bfe | ||
|
|
9d0a5fd8f3 | ||
|
|
5ca577bc0a | ||
|
|
4d25d185c3 | ||
|
|
ea289e02da | ||
|
|
a3f16eb7e8 | ||
|
|
ddd29abe99 | ||
|
|
decd56f2d2 | ||
|
|
387401cb44 | ||
|
|
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 | ||
|
|
3025ea9a7b | ||
|
|
5dba4d1384 | ||
|
|
e4f1e7b093 | ||
|
|
d0a0597087 | ||
|
|
c9ccf9a1b5 | ||
|
|
69107d4441 | ||
|
|
e25afc1ef7 | ||
|
|
9babfd150e | ||
|
|
532dbbea4a | ||
|
|
0d284d05d9 | ||
|
|
28fccae3ac | ||
|
|
3a4a6da69c | ||
|
|
4ea07a95e7 | ||
|
|
80ceb49358 | ||
|
|
589337116a | ||
|
|
cb50067223 | ||
|
|
4d63266d88 | ||
|
|
90dd33ce21 | ||
|
|
763b849ddf | ||
|
|
9c76c54283 | ||
|
|
5cb17b3a36 | ||
|
|
2f7b5d1cbb | ||
|
|
4fe14eb2e9 | ||
|
|
eb089f2b58 | ||
|
|
4f0ac98eea | ||
|
|
af19940fa1 | ||
|
|
5635d2a325 | ||
|
|
5e2de35693 | ||
|
|
ef7180f23f | ||
|
|
f939973d4f | ||
|
|
63f327733e | ||
|
|
c1fb807fe4 | ||
|
|
b7ddf44267 | ||
|
|
6d4c72ea5e | ||
|
|
3db11b9372 | ||
|
|
b8714f4abe | ||
|
|
7ccbe74bbe | ||
|
|
ea3ae3516b | ||
|
|
d33af3ca52 | ||
|
|
291c3fa908 | ||
|
|
a51fa58122 | ||
|
|
65a3967abd | ||
|
|
e1e5c94a43 | ||
|
|
f15127eceb | ||
|
|
071a238b71 | ||
|
|
050b052156 | ||
|
|
8f65cca776 | ||
|
|
66624a8c47 | ||
|
|
c8b9a415e6 | ||
|
|
a1dcb4c203 | ||
|
|
d4edc3e622 | ||
|
|
e2b8c3ee0e | ||
|
|
c37816e90d | ||
|
|
a35cfcdca7 | ||
|
|
d381646226 | ||
|
|
285e7afec2 | ||
|
|
df7d678c32 | ||
|
|
f36f7e58de | ||
|
|
0e16c834d8 | ||
|
|
31a3256128 | ||
|
|
aa8f70da28 | ||
|
|
f375ffb8f8 | ||
|
|
de240e40a5 | ||
|
|
7d30aea07f | ||
|
|
04a7361d0d | ||
|
|
7b19618eca | ||
|
|
bd9600cc08 | ||
|
|
32172bc791 | ||
|
|
c92f57fb07 | ||
|
|
8fbdea7f36 | ||
|
|
df15da5145 | ||
|
|
846fe53c0f | ||
|
|
3bbdc828d9 | ||
|
|
c454c3f0f2 | ||
|
|
77b1a546e8 | ||
|
|
7c7f063204 | ||
|
|
0a0fcb305c | ||
|
|
da8028784d | ||
|
|
48edd888a6 | ||
|
|
da4f134095 | ||
|
|
0a71620046 | ||
|
|
1b5a762578 | ||
|
|
d9d031ed2b | ||
|
|
403e56b4ef | ||
|
|
499b06e300 | ||
|
|
cb69540bdd | ||
|
|
1f27fa419a | ||
|
|
a561b2bd91 | ||
|
|
eeec85d1de | ||
|
|
e01484f854 | ||
|
|
fb996ded88 | ||
|
|
a11bfca15a | ||
|
|
6262e1c9e6 | ||
|
|
4e318af7cc | ||
|
|
d587b7867e | ||
|
|
bd03ead9c3 | ||
|
|
c1685b7128 | ||
|
|
7625e79574 | ||
|
|
c5bf7875b9 | ||
|
|
da026293bc | ||
|
|
86e5677574 | ||
|
|
a48636604f | ||
|
|
e6945ac076 | ||
|
|
9107d76522 | ||
|
|
52b925b306 | ||
|
|
49d3dc0aa0 | ||
|
|
0d41a1ae70 | ||
|
|
49e22d790a | ||
|
|
12e5eedd6b | ||
|
|
159b651871 | ||
|
|
080be7a885 | ||
|
|
e526627eb9 | ||
|
|
67fc37c76c | ||
|
|
b2b92aea31 | ||
|
|
e0680d9612 | ||
|
|
d54ac37403 | ||
|
|
eedb3d3dd8 | ||
|
|
d286df649e | ||
|
|
e0cbc247b2 | ||
|
|
a2c8a82559 | ||
|
|
8b91323705 | ||
|
|
89fdbf5660 | ||
|
|
7ed5dfdb8f | ||
|
|
824c65eb38 | ||
|
|
e43eeeba4a | ||
|
|
9e2c7cc145 | ||
|
|
989598b9cd | ||
|
|
6a41942de6 | ||
|
|
d263072aca | ||
|
|
78c8467bf6 | ||
|
|
084908bd04 | ||
|
|
39cc83c1b8 | ||
|
|
039a775ce4 | ||
|
|
dd9e80f067 | ||
|
|
5dd1bb949c | ||
|
|
b76486e4dc | ||
|
|
a3a2af948e | ||
|
|
2ce2df6390 | ||
|
|
19e5136d64 | ||
|
|
281e155480 | ||
|
|
70b2a11cb7 | ||
|
|
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 |
124
.eslintrc
Normal file
@@ -0,0 +1,124 @@
|
|||||||
|
{
|
||||||
|
"env": {
|
||||||
|
"browser": true,
|
||||||
|
"node": true,
|
||||||
|
"es2022": true
|
||||||
|
},
|
||||||
|
"parserOptions": {
|
||||||
|
"sourceType": "module"
|
||||||
|
},
|
||||||
|
"extends": "eslint:recommended",
|
||||||
|
"rules": {
|
||||||
|
"indent": "off",
|
||||||
|
"brace-style": "off",
|
||||||
|
"no-mixed-spaces-and-tabs": "off",
|
||||||
|
"no-useless-escape": "off",
|
||||||
|
"space-unary-ops": ["error", { "words": true }],
|
||||||
|
"linebreak-style": "off",
|
||||||
|
"quotes": ["off"],
|
||||||
|
"semi": "off",
|
||||||
|
"camelcase": "off",
|
||||||
|
"no-unused-vars": "off",
|
||||||
|
"no-console": ["warn"],
|
||||||
|
"no-extra-boolean-cast": ["off"],
|
||||||
|
"no-control-regex": ["off"],
|
||||||
|
},
|
||||||
|
"root": true,
|
||||||
|
"globals": {
|
||||||
|
"frappe": true,
|
||||||
|
"Vue": true,
|
||||||
|
"SetVueGlobals": true,
|
||||||
|
"__": true,
|
||||||
|
"repl": true,
|
||||||
|
"Class": true,
|
||||||
|
"locals": true,
|
||||||
|
"cint": true,
|
||||||
|
"cstr": true,
|
||||||
|
"cur_frm": true,
|
||||||
|
"cur_dialog": true,
|
||||||
|
"cur_page": true,
|
||||||
|
"cur_list": true,
|
||||||
|
"cur_tree": true,
|
||||||
|
"msg_dialog": true,
|
||||||
|
"is_null": true,
|
||||||
|
"in_list": true,
|
||||||
|
"has_common": true,
|
||||||
|
"posthog": true,
|
||||||
|
"has_words": true,
|
||||||
|
"validate_email": true,
|
||||||
|
"open_web_template_values_editor": true,
|
||||||
|
"validate_name": true,
|
||||||
|
"validate_phone": true,
|
||||||
|
"validate_url": true,
|
||||||
|
"get_number_format": true,
|
||||||
|
"format_number": true,
|
||||||
|
"format_currency": true,
|
||||||
|
"comment_when": true,
|
||||||
|
"open_url_post": true,
|
||||||
|
"toTitle": true,
|
||||||
|
"lstrip": true,
|
||||||
|
"rstrip": true,
|
||||||
|
"strip": true,
|
||||||
|
"strip_html": true,
|
||||||
|
"replace_all": true,
|
||||||
|
"flt": true,
|
||||||
|
"precision": true,
|
||||||
|
"CREATE": true,
|
||||||
|
"AMEND": true,
|
||||||
|
"CANCEL": true,
|
||||||
|
"copy_dict": true,
|
||||||
|
"get_number_format_info": true,
|
||||||
|
"strip_number_groups": true,
|
||||||
|
"print_table": true,
|
||||||
|
"Layout": true,
|
||||||
|
"web_form_settings": true,
|
||||||
|
"$c": true,
|
||||||
|
"$a": true,
|
||||||
|
"$i": true,
|
||||||
|
"$bg": true,
|
||||||
|
"$y": true,
|
||||||
|
"$c_obj": true,
|
||||||
|
"refresh_many": true,
|
||||||
|
"refresh_field": true,
|
||||||
|
"toggle_field": true,
|
||||||
|
"get_field_obj": true,
|
||||||
|
"get_query_params": true,
|
||||||
|
"unhide_field": true,
|
||||||
|
"hide_field": true,
|
||||||
|
"set_field_options": true,
|
||||||
|
"getCookie": true,
|
||||||
|
"getCookies": true,
|
||||||
|
"get_url_arg": true,
|
||||||
|
"md5": true,
|
||||||
|
"$": true,
|
||||||
|
"jQuery": true,
|
||||||
|
"moment": true,
|
||||||
|
"hljs": true,
|
||||||
|
"Awesomplete": true,
|
||||||
|
"Sortable": true,
|
||||||
|
"Showdown": true,
|
||||||
|
"Taggle": true,
|
||||||
|
"Gantt": true,
|
||||||
|
"Slick": true,
|
||||||
|
"Webcam": true,
|
||||||
|
"PhotoSwipe": true,
|
||||||
|
"PhotoSwipeUI_Default": true,
|
||||||
|
"io": true,
|
||||||
|
"JsBarcode": true,
|
||||||
|
"L": true,
|
||||||
|
"Chart": true,
|
||||||
|
"DataTable": true,
|
||||||
|
"Cypress": true,
|
||||||
|
"cy": true,
|
||||||
|
"it": true,
|
||||||
|
"describe": true,
|
||||||
|
"expect": true,
|
||||||
|
"context": true,
|
||||||
|
"before": true,
|
||||||
|
"beforeEach": true,
|
||||||
|
"after": true,
|
||||||
|
"qz": true,
|
||||||
|
"localforage": true,
|
||||||
|
"extend_cscript": true
|
||||||
|
}
|
||||||
|
}
|
||||||
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
@@ -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:
|
||||||
|
|||||||
2
.github/workflows/ui-tests.yml
vendored
@@ -105,7 +105,7 @@ jobs:
|
|||||||
- name: cypress pre-requisites
|
- name: cypress pre-requisites
|
||||||
run: |
|
run: |
|
||||||
cd ~/frappe-bench/apps/lms
|
cd ~/frappe-bench/apps/lms
|
||||||
yarn add cypress@^10 --no-lockfile
|
yarn add cypress@^10 --no-lockfile -W
|
||||||
|
|
||||||
- name: UI Tests
|
- name: UI Tests
|
||||||
run: cd ~/frappe-bench/ && bench --site lms.test run-ui-tests lms --headless
|
run: cd ~/frappe-bench/ && bench --site lms.test run-ui-tests lms --headless
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
exclude: 'node_modules|.git'
|
exclude: 'node_modules|.git'
|
||||||
default_stages: [commit]
|
default_stages: [pre-commit]
|
||||||
fail_fast: false
|
fail_fast: false
|
||||||
|
|
||||||
repos:
|
repos:
|
||||||
- repo: https://github.com/pre-commit/pre-commit-hooks
|
- repo: https://github.com/pre-commit/pre-commit-hooks
|
||||||
rev: v4.3.0
|
rev: v5.0.0
|
||||||
hooks:
|
hooks:
|
||||||
- id: trailing-whitespace
|
- id: trailing-whitespace
|
||||||
files: "lms.*"
|
files: "lms.*"
|
||||||
@@ -16,17 +16,16 @@ repos:
|
|||||||
- id: check-toml
|
- id: check-toml
|
||||||
- id: debug-statements
|
- id: debug-statements
|
||||||
|
|
||||||
- repo: https://github.com/asottile/pyupgrade
|
- repo: https://github.com/astral-sh/ruff-pre-commit
|
||||||
rev: v2.34.0
|
rev: v0.8.1
|
||||||
hooks:
|
hooks:
|
||||||
- id: pyupgrade
|
- id: ruff
|
||||||
args: ['--py310-plus']
|
name: "Run ruff import sorter"
|
||||||
|
args: ["--select=I", "--fix"]
|
||||||
- repo: https://github.com/adityahase/black
|
- id: ruff
|
||||||
rev: 9cb0a69f4d0030cdf687eddf314468b39ed54119
|
name: "Run ruff linter"
|
||||||
hooks:
|
- id: ruff-format
|
||||||
- id: black
|
name: "Run ruff formatter"
|
||||||
additional_dependencies: ['click==8.0.4']
|
|
||||||
|
|
||||||
- repo: https://github.com/pre-commit/mirrors-prettier
|
- repo: https://github.com/pre-commit/mirrors-prettier
|
||||||
rev: v2.7.1
|
rev: v2.7.1
|
||||||
@@ -44,12 +43,22 @@ repos:
|
|||||||
lms/public/js/lib/.*
|
lms/public/js/lib/.*
|
||||||
)$
|
)$
|
||||||
|
|
||||||
- repo: https://github.com/PyCQA/flake8
|
- repo: https://github.com/pre-commit/mirrors-eslint
|
||||||
rev: 5.0.4
|
rev: v8.44.0
|
||||||
hooks:
|
hooks:
|
||||||
- id: flake8
|
- id: eslint
|
||||||
additional_dependencies: ['flake8-bugbear',]
|
types_or: [javascript]
|
||||||
args: ['--config', '.github/helper/flake8.conf']
|
args: ['--quiet']
|
||||||
|
exclude: |
|
||||||
|
(?x)^(
|
||||||
|
lms/public/dist/.*|
|
||||||
|
cypress/.*|
|
||||||
|
.*node_modules.*|
|
||||||
|
.*boilerplate.*|
|
||||||
|
lms/www/website_script.js|
|
||||||
|
lms/templates/includes/.*|
|
||||||
|
lms/public/js/lib/.*
|
||||||
|
)$
|
||||||
|
|
||||||
ci:
|
ci:
|
||||||
autoupdate_schedule: weekly
|
autoupdate_schedule: weekly
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
module.exports = {
|
export default {
|
||||||
parserPreset: "conventional-changelog-conventionalcommits",
|
parserPreset: "conventional-changelog-conventionalcommits",
|
||||||
rules: {
|
rules: {
|
||||||
"subject-empty": [2, "never"],
|
"subject-empty": [2, "never"],
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
const { defineConfig } = require("cypress");
|
import { defineConfig } from "cypress";
|
||||||
|
|
||||||
module.exports = defineConfig({
|
export default defineConfig({
|
||||||
projectId: "vandxn",
|
projectId: "vandxn",
|
||||||
adminPassword: "admin",
|
adminPassword: "admin",
|
||||||
testUser: "frappe@example.com",
|
testUser: "frappe@example.com",
|
||||||
|
|||||||
168
cypress/e2e/batch_creation.cy.js
Normal file
@@ -0,0 +1,168 @@
|
|||||||
|
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("[data-dismissable-layer]")
|
||||||
|
.find("span")
|
||||||
|
.contains(/^Members$/)
|
||||||
|
.click();
|
||||||
|
cy.get("[data-dismissable-layer]")
|
||||||
|
.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("[data-dismissable-layer]")
|
||||||
|
.find("span")
|
||||||
|
.contains(/^Evaluators$/)
|
||||||
|
.click();
|
||||||
|
|
||||||
|
cy.get("[data-dismissable-layer]")
|
||||||
|
.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:visible")
|
||||||
|
.contains("01 Oct 2030 - 31 Oct 2030")
|
||||||
|
.should("be.visible");
|
||||||
|
cy.get("span:visible")
|
||||||
|
.contains("10:00 AM - 11:00 AM")
|
||||||
|
.should("be.visible");
|
||||||
|
cy.get("span:visible").contains("IST").should("be.visible");
|
||||||
|
cy.contains("div:visible", "10 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:visible").contains("Manage Batch").click();
|
||||||
|
|
||||||
|
/* Add student to batch */
|
||||||
|
cy.get("button").contains("Add").click();
|
||||||
|
cy.get('div[role="dialog"]').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.contains("div:visible", "9 Seats Left").should("be.visible");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,12 +1,15 @@
|
|||||||
describe("Course Creation", () => {
|
describe("Course Creation", () => {
|
||||||
it("creates a new course", () => {
|
it("creates a new course", () => {
|
||||||
cy.login();
|
cy.login();
|
||||||
cy.wait(1000);
|
cy.wait(500);
|
||||||
cy.visit("/lms/courses");
|
cy.visit("/lms/courses");
|
||||||
|
|
||||||
|
// Close onboarding modal
|
||||||
|
cy.closeOnboardingModal();
|
||||||
|
|
||||||
// Create a course
|
// Create a course
|
||||||
cy.get("button").contains("New").click();
|
cy.get("button").contains("Create").click();
|
||||||
cy.wait(1000);
|
cy.wait(500);
|
||||||
cy.url().should("include", "/courses/new/edit");
|
cy.url().should("include", "/courses/new/edit");
|
||||||
|
|
||||||
cy.get("label").contains("Title").type("Test Course");
|
cy.get("label").contains("Title").type("Test Course");
|
||||||
@@ -73,7 +76,7 @@ describe("Course Creation", () => {
|
|||||||
cy.button("Add Chapter").click();
|
cy.button("Add Chapter").click();
|
||||||
|
|
||||||
cy.wait(1000);
|
cy.wait(1000);
|
||||||
cy.get("[id^=headlessui-dialog-panel-")
|
cy.get("[data-dismissable-layer]")
|
||||||
.should("be.visible")
|
.should("be.visible")
|
||||||
.within(() => {
|
.within(() => {
|
||||||
cy.get("label").contains("Title").type("Test Chapter");
|
cy.get("label").contains("Title").type("Test Chapter");
|
||||||
@@ -95,15 +98,16 @@ describe("Course Creation", () => {
|
|||||||
|
|
||||||
// View Course
|
// View Course
|
||||||
cy.wait(1000);
|
cy.wait(1000);
|
||||||
cy.visit("/lms");
|
cy.visit("/lms/courses");
|
||||||
cy.wait(500);
|
cy.closeOnboardingModal();
|
||||||
|
|
||||||
cy.url().should("include", "/lms/courses");
|
cy.url().should("include", "/lms/courses");
|
||||||
cy.get(".grid a:first").within(() => {
|
cy.get(".grid a:first").within(() => {
|
||||||
cy.get("div").contains("Test Course");
|
cy.get("div").contains("Test Course");
|
||||||
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");
|
||||||
});
|
});
|
||||||
@@ -136,9 +140,10 @@ 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("[data-dismissable-layer]").within(() => {
|
||||||
cy.get("label").contains("Title").type("Test Discussion");
|
cy.get("label").contains("Title").type("Test Discussion");
|
||||||
cy.get("div[contenteditable=true]").invoke(
|
cy.get("div[contenteditable=true]").invoke(
|
||||||
"text",
|
"text",
|
||||||
|
|||||||
@@ -25,6 +25,7 @@
|
|||||||
// Cypress.Commands.overwrite('visit', (originalFn, url, options) => { ... })
|
// Cypress.Commands.overwrite('visit', (originalFn, url, options) => { ... })
|
||||||
|
|
||||||
import "cypress-file-upload";
|
import "cypress-file-upload";
|
||||||
|
import "cypress-real-events";
|
||||||
|
|
||||||
Cypress.Commands.add("login", (email, password) => {
|
Cypress.Commands.add("login", (email, password) => {
|
||||||
if (!email) {
|
if (!email) {
|
||||||
@@ -68,3 +69,18 @@ Cypress.Commands.add("paste", { prevSubject: true }, (subject, text) => {
|
|||||||
element.dispatchEvent(event);
|
element.dispatchEvent(event);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
Cypress.Commands.add("closeOnboardingModal", () => {
|
||||||
|
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"]')
|
||||||
|
.find('button:has(svg[class*="feather-x"])')
|
||||||
|
.realClick();
|
||||||
|
cy.wait(1000);
|
||||||
|
} else {
|
||||||
|
cy.log("Onboarding modal not found, skipping close.");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -16,9 +16,9 @@ cd frappe-bench
|
|||||||
|
|
||||||
# Use containers instead of localhost
|
# Use containers instead of localhost
|
||||||
bench set-mariadb-host mariadb
|
bench set-mariadb-host mariadb
|
||||||
bench set-redis-cache-host redis:6379
|
bench set-redis-cache-host redis://redis:6379
|
||||||
bench set-redis-queue-host redis:6379
|
bench set-redis-queue-host redis://redis:6379
|
||||||
bench set-redis-socketio-host redis:6379
|
bench set-redis-socketio-host redis://redis:6379
|
||||||
|
|
||||||
# Remove redis, watch from Procfile
|
# Remove redis, watch from Procfile
|
||||||
sed -i '/redis/d' ./Procfile
|
sed -i '/redis/d' ./Procfile
|
||||||
|
|||||||
1
frappe-ui
Submodule
1
frontend/.gitignore
vendored
@@ -2,4 +2,5 @@ node_modules
|
|||||||
.DS_Store
|
.DS_Store
|
||||||
dist
|
dist
|
||||||
dist-ssr
|
dist-ssr
|
||||||
|
dev-dist
|
||||||
*.local
|
*.local
|
||||||
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 {
|
||||||
|
|
||||||
|
}
|
||||||
43
frontend/components.d.ts
vendored
@@ -10,6 +10,7 @@ declare module 'vue' {
|
|||||||
export interface GlobalComponents {
|
export interface GlobalComponents {
|
||||||
Annoucements: typeof import('./src/components/Annoucements.vue')['default']
|
Annoucements: typeof import('./src/components/Annoucements.vue')['default']
|
||||||
AnnouncementModal: typeof import('./src/components/Modals/AnnouncementModal.vue')['default']
|
AnnouncementModal: typeof import('./src/components/Modals/AnnouncementModal.vue')['default']
|
||||||
|
AppHeader: typeof import('./src/components/AppHeader.vue')['default']
|
||||||
Apps: typeof import('./src/components/Apps.vue')['default']
|
Apps: typeof import('./src/components/Apps.vue')['default']
|
||||||
AppSidebar: typeof import('./src/components/AppSidebar.vue')['default']
|
AppSidebar: typeof import('./src/components/AppSidebar.vue')['default']
|
||||||
AssessmentModal: typeof import('./src/components/Modals/AssessmentModal.vue')['default']
|
AssessmentModal: typeof import('./src/components/Modals/AssessmentModal.vue')['default']
|
||||||
@@ -19,6 +20,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 +32,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']
|
||||||
@@ -47,51 +56,69 @@ declare module 'vue' {
|
|||||||
Discussions: typeof import('./src/components/Discussions.vue')['default']
|
Discussions: typeof import('./src/components/Discussions.vue')['default']
|
||||||
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']
|
||||||
|
EmailTemplates: typeof import('./src/components/Settings/EmailTemplates.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']
|
||||||
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']
|
||||||
|
InstallPrompt: typeof import('./src/components/InstallPrompt.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']
|
||||||
|
LayoutHeader: typeof import('./src/components/LayoutHeader.vue')['default']
|
||||||
LessonContent: typeof import('./src/components/LessonContent.vue')['default']
|
LessonContent: typeof import('./src/components/LessonContent.vue')['default']
|
||||||
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']
|
PaymentGatewayDetails: typeof import('./src/components/Settings/PaymentGatewayDetails.vue')['default']
|
||||||
|
PaymentGateways: typeof import('./src/components/Settings/PaymentGateways.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']
|
||||||
|
TransactionDetails: typeof import('./src/components/Settings/TransactionDetails.vue')['default']
|
||||||
|
Transactions: typeof import('./src/components/Settings/Transactions.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']
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,203 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<link rel="icon" href="{{ favicon }}" />
|
<link rel="icon" href="{{ favicon }}" />
|
||||||
|
<link rel="manifest" href="/api/method/lms.lms.api.get_pwa_manifest" />
|
||||||
|
<link rel="apple-touch-icon" href="public/manifest/apple-icon-180.png" />
|
||||||
|
<meta name="mobile-web-app-capable" content="yes" />
|
||||||
|
<meta name="theme-color" content="#FFFFFF" media="(prefers-color-scheme: light)" />
|
||||||
|
<meta name="theme-color" content="#0F0F0F" media="(prefers-color-scheme: dark)" />
|
||||||
|
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
|
||||||
|
<meta name="msapplication-navbutton-color" content="#ffffff" />
|
||||||
|
<link
|
||||||
|
rel="apple-touch-startup-image"
|
||||||
|
href="public/manifest/apple-splash-2048-2732.jpg"
|
||||||
|
media="(device-width: 1024px) and (device-height: 1366px) and (-webkit-device-pixel-ratio: 2) and (orientation: portrait)"
|
||||||
|
/>
|
||||||
|
<link
|
||||||
|
rel="apple-touch-startup-image"
|
||||||
|
href="public/manifest/apple-splash-2732-2048.jpg"
|
||||||
|
media="(device-width: 1024px) and (device-height: 1366px) and (-webkit-device-pixel-ratio: 2) and (orientation: landscape)"
|
||||||
|
/>
|
||||||
|
<link
|
||||||
|
rel="apple-touch-startup-image"
|
||||||
|
href="public/manifest/apple-splash-1668-2388.jpg"
|
||||||
|
media="(device-width: 834px) and (device-height: 1194px) and (-webkit-device-pixel-ratio: 2) and (orientation: portrait)"
|
||||||
|
/>
|
||||||
|
<link
|
||||||
|
rel="apple-touch-startup-image"
|
||||||
|
href="public/manifest/apple-splash-2388-1668.jpg"
|
||||||
|
media="(device-width: 834px) and (device-height: 1194px) and (-webkit-device-pixel-ratio: 2) and (orientation: landscape)"
|
||||||
|
/>
|
||||||
|
<link
|
||||||
|
rel="apple-touch-startup-image"
|
||||||
|
href="public/manifest/apple-splash-1536-2048.jpg"
|
||||||
|
media="(device-width: 768px) and (device-height: 1024px) and (-webkit-device-pixel-ratio: 2) and (orientation: portrait)"
|
||||||
|
/>
|
||||||
|
<link
|
||||||
|
rel="apple-touch-startup-image"
|
||||||
|
href="public/manifest/apple-splash-2048-1536.jpg"
|
||||||
|
media="(device-width: 768px) and (device-height: 1024px) and (-webkit-device-pixel-ratio: 2) and (orientation: landscape)"
|
||||||
|
/>
|
||||||
|
<link
|
||||||
|
rel="apple-touch-startup-image"
|
||||||
|
href="public/manifest/apple-splash-1640-2360.jpg"
|
||||||
|
media="(device-width: 820px) and (device-height: 1180px) and (-webkit-device-pixel-ratio: 2) and (orientation: portrait)"
|
||||||
|
/>
|
||||||
|
<link
|
||||||
|
rel="apple-touch-startup-image"
|
||||||
|
href="public/manifest/apple-splash-2360-1640.jpg"
|
||||||
|
media="(device-width: 820px) and (device-height: 1180px) and (-webkit-device-pixel-ratio: 2) and (orientation: landscape)"
|
||||||
|
/>
|
||||||
|
<link
|
||||||
|
rel="apple-touch-startup-image"
|
||||||
|
href="public/manifest/apple-splash-1668-2224.jpg"
|
||||||
|
media="(device-width: 834px) and (device-height: 1112px) and (-webkit-device-pixel-ratio: 2) and (orientation: portrait)"
|
||||||
|
/>
|
||||||
|
<link
|
||||||
|
rel="apple-touch-startup-image"
|
||||||
|
href="public/manifest/apple-splash-2224-1668.jpg"
|
||||||
|
media="(device-width: 834px) and (device-height: 1112px) and (-webkit-device-pixel-ratio: 2) and (orientation: landscape)"
|
||||||
|
/>
|
||||||
|
<link
|
||||||
|
rel="apple-touch-startup-image"
|
||||||
|
href="public/manifest/apple-splash-1620-2160.jpg"
|
||||||
|
media="(device-width: 810px) and (device-height: 1080px) and (-webkit-device-pixel-ratio: 2) and (orientation: portrait)"
|
||||||
|
/>
|
||||||
|
<link
|
||||||
|
rel="apple-touch-startup-image"
|
||||||
|
href="public/manifest/apple-splash-2160-1620.jpg"
|
||||||
|
media="(device-width: 810px) and (device-height: 1080px) and (-webkit-device-pixel-ratio: 2) and (orientation: landscape)"
|
||||||
|
/>
|
||||||
|
<link
|
||||||
|
rel="apple-touch-startup-image"
|
||||||
|
href="public/manifest/apple-splash-1488-2266.jpg"
|
||||||
|
media="(device-width: 744px) and (device-height: 1133px) and (-webkit-device-pixel-ratio: 2) and (orientation: portrait)"
|
||||||
|
/>
|
||||||
|
<link
|
||||||
|
rel="apple-touch-startup-image"
|
||||||
|
href="public/manifest/apple-splash-2266-1488.jpg"
|
||||||
|
media="(device-width: 744px) and (device-height: 1133px) and (-webkit-device-pixel-ratio: 2) and (orientation: landscape)"
|
||||||
|
/>
|
||||||
|
<link
|
||||||
|
rel="apple-touch-startup-image"
|
||||||
|
href="public/manifest/apple-splash-1320-2868.jpg"
|
||||||
|
media="(device-width: 440px) and (device-height: 956px) and (-webkit-device-pixel-ratio: 3) and (orientation: portrait)"
|
||||||
|
/>
|
||||||
|
<link
|
||||||
|
rel="apple-touch-startup-image"
|
||||||
|
href="public/manifest/apple-splash-2868-1320.jpg"
|
||||||
|
media="(device-width: 440px) and (device-height: 956px) and (-webkit-device-pixel-ratio: 3) and (orientation: landscape)"
|
||||||
|
/>
|
||||||
|
<link
|
||||||
|
rel="apple-touch-startup-image"
|
||||||
|
href="public/manifest/apple-splash-1206-2622.jpg"
|
||||||
|
media="(device-width: 402px) and (device-height: 874px) and (-webkit-device-pixel-ratio: 3) and (orientation: portrait)"
|
||||||
|
/>
|
||||||
|
<link
|
||||||
|
rel="apple-touch-startup-image"
|
||||||
|
href="public/manifest/apple-splash-2622-1206.jpg"
|
||||||
|
media="(device-width: 402px) and (device-height: 874px) and (-webkit-device-pixel-ratio: 3) and (orientation: landscape)"
|
||||||
|
/>
|
||||||
|
<link
|
||||||
|
rel="apple-touch-startup-image"
|
||||||
|
href="public/manifest/apple-splash-1290-2796.jpg"
|
||||||
|
media="(device-width: 430px) and (device-height: 932px) and (-webkit-device-pixel-ratio: 3) and (orientation: portrait)"
|
||||||
|
/>
|
||||||
|
<link
|
||||||
|
rel="apple-touch-startup-image"
|
||||||
|
href="public/manifest/apple-splash-2796-1290.jpg"
|
||||||
|
media="(device-width: 430px) and (device-height: 932px) and (-webkit-device-pixel-ratio: 3) and (orientation: landscape)"
|
||||||
|
/>
|
||||||
|
<link
|
||||||
|
rel="apple-touch-startup-image"
|
||||||
|
href="public/manifest/apple-splash-1179-2556.jpg"
|
||||||
|
media="(device-width: 393px) and (device-height: 852px) and (-webkit-device-pixel-ratio: 3) and (orientation: portrait)"
|
||||||
|
/>
|
||||||
|
<link
|
||||||
|
rel="apple-touch-startup-image"
|
||||||
|
href="public/manifest/apple-splash-2556-1179.jpg"
|
||||||
|
media="(device-width: 393px) and (device-height: 852px) and (-webkit-device-pixel-ratio: 3) and (orientation: landscape)"
|
||||||
|
/>
|
||||||
|
<link
|
||||||
|
rel="apple-touch-startup-image"
|
||||||
|
href="public/manifest/apple-splash-1170-2532.jpg"
|
||||||
|
media="(device-width: 390px) and (device-height: 844px) and (-webkit-device-pixel-ratio: 3) and (orientation: portrait)"
|
||||||
|
/>
|
||||||
|
<link
|
||||||
|
rel="apple-touch-startup-image"
|
||||||
|
href="public/manifest/apple-splash-2532-1170.jpg"
|
||||||
|
media="(device-width: 390px) and (device-height: 844px) and (-webkit-device-pixel-ratio: 3) and (orientation: landscape)"
|
||||||
|
/>
|
||||||
|
<link
|
||||||
|
rel="apple-touch-startup-image"
|
||||||
|
href="public/manifest/apple-splash-1284-2778.jpg"
|
||||||
|
media="(device-width: 428px) and (device-height: 926px) and (-webkit-device-pixel-ratio: 3) and (orientation: portrait)"
|
||||||
|
/>
|
||||||
|
<link
|
||||||
|
rel="apple-touch-startup-image"
|
||||||
|
href="public/manifest/apple-splash-2778-1284.jpg"
|
||||||
|
media="(device-width: 428px) and (device-height: 926px) and (-webkit-device-pixel-ratio: 3) and (orientation: landscape)"
|
||||||
|
/>
|
||||||
|
<link
|
||||||
|
rel="apple-touch-startup-image"
|
||||||
|
href="public/manifest/apple-splash-1125-2436.jpg"
|
||||||
|
media="(device-width: 375px) and (device-height: 812px) and (-webkit-device-pixel-ratio: 3) and (orientation: portrait)"
|
||||||
|
/>
|
||||||
|
<link
|
||||||
|
rel="apple-touch-startup-image"
|
||||||
|
href="public/manifest/apple-splash-2436-1125.jpg"
|
||||||
|
media="(device-width: 375px) and (device-height: 812px) and (-webkit-device-pixel-ratio: 3) and (orientation: landscape)"
|
||||||
|
/>
|
||||||
|
<link
|
||||||
|
rel="apple-touch-startup-image"
|
||||||
|
href="public/manifest/apple-splash-1242-2688.jpg"
|
||||||
|
media="(device-width: 414px) and (device-height: 896px) and (-webkit-device-pixel-ratio: 3) and (orientation: portrait)"
|
||||||
|
/>
|
||||||
|
<link
|
||||||
|
rel="apple-touch-startup-image"
|
||||||
|
href="public/manifest/apple-splash-2688-1242.jpg"
|
||||||
|
media="(device-width: 414px) and (device-height: 896px) and (-webkit-device-pixel-ratio: 3) and (orientation: landscape)"
|
||||||
|
/>
|
||||||
|
<link
|
||||||
|
rel="apple-touch-startup-image"
|
||||||
|
href="public/manifest/apple-splash-828-1792.jpg"
|
||||||
|
media="(device-width: 414px) and (device-height: 896px) and (-webkit-device-pixel-ratio: 2) and (orientation: portrait)"
|
||||||
|
/>
|
||||||
|
<link
|
||||||
|
rel="apple-touch-startup-image"
|
||||||
|
href="public/manifest/apple-splash-1792-828.jpg"
|
||||||
|
media="(device-width: 414px) and (device-height: 896px) and (-webkit-device-pixel-ratio: 2) and (orientation: landscape)"
|
||||||
|
/>
|
||||||
|
<link
|
||||||
|
rel="apple-touch-startup-image"
|
||||||
|
href="public/manifest/apple-splash-1242-2208.jpg"
|
||||||
|
media="(device-width: 414px) and (device-height: 736px) and (-webkit-device-pixel-ratio: 3) and (orientation: portrait)"
|
||||||
|
/>
|
||||||
|
<link
|
||||||
|
rel="apple-touch-startup-image"
|
||||||
|
href="public/manifest/apple-splash-2208-1242.jpg"
|
||||||
|
media="(device-width: 414px) and (device-height: 736px) and (-webkit-device-pixel-ratio: 3) and (orientation: landscape)"
|
||||||
|
/>
|
||||||
|
<link
|
||||||
|
rel="apple-touch-startup-image"
|
||||||
|
href="public/manifest/apple-splash-750-1334.jpg"
|
||||||
|
media="(device-width: 375px) and (device-height: 667px) and (-webkit-device-pixel-ratio: 2) and (orientation: portrait)"
|
||||||
|
/>
|
||||||
|
<link
|
||||||
|
rel="apple-touch-startup-image"
|
||||||
|
href="public/manifest/apple-splash-1334-750.jpg"
|
||||||
|
media="(device-width: 375px) and (device-height: 667px) and (-webkit-device-pixel-ratio: 2) and (orientation: landscape)"
|
||||||
|
/>
|
||||||
|
<link
|
||||||
|
rel="apple-touch-startup-image"
|
||||||
|
href="public/manifest/apple-splash-640-1136.jpg"
|
||||||
|
media="(device-width: 320px) and (device-height: 568px) and (-webkit-device-pixel-ratio: 2) and (orientation: portrait)"
|
||||||
|
/>
|
||||||
|
<link
|
||||||
|
rel="apple-touch-startup-image"
|
||||||
|
href="public/manifest/apple-splash-1136-640.jpg"
|
||||||
|
media="(device-width: 320px) and (device-height: 568px) and (-webkit-device-pixel-ratio: 2) and (orientation: landscape)"
|
||||||
|
/>
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>{{ title }}</title>
|
<title>{{ title }}</title>
|
||||||
<meta name="title" content="{{ meta.title }}" />
|
<meta name="title" content="{{ meta.title }}" />
|
||||||
@@ -16,7 +213,7 @@
|
|||||||
<meta name="twitter:image" content="{{ meta.image }}" />
|
<meta name="twitter:image" content="{{ meta.image }}" />
|
||||||
<meta name="twitter:description" content="{{ meta.description }}" />
|
<meta name="twitter:description" content="{{ meta.description }}" />
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body class="sm:overscroll-y-none no-scrollbar">
|
||||||
<div id="app">
|
<div id="app">
|
||||||
<div id="seo-content">
|
<div id="seo-content">
|
||||||
<h1>{{ meta.title }}</h1>
|
<h1>{{ meta.title }}</h1>
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
"name": "frappe-ui-frontend",
|
"name": "frappe-ui-frontend",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.0.0",
|
"version": "0.0.0",
|
||||||
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"serve": "vite preview",
|
"serve": "vite preview",
|
||||||
@@ -9,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",
|
||||||
@@ -23,10 +28,11 @@
|
|||||||
"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",
|
||||||
|
"dompurify": "^3.2.6",
|
||||||
"feather-icons": "^4.28.0",
|
"feather-icons": "^4.28.0",
|
||||||
"frappe-ui": "^0.1.134",
|
"frappe-ui": "^0.1.200",
|
||||||
"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",
|
||||||
@@ -34,9 +40,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",
|
||||||
@@ -46,6 +54,7 @@
|
|||||||
"@vitejs/plugin-vue": "^5.0.3",
|
"@vitejs/plugin-vue": "^5.0.3",
|
||||||
"autoprefixer": "^10.4.2",
|
"autoprefixer": "^10.4.2",
|
||||||
"postcss": "^8.4.5",
|
"postcss": "^8.4.5",
|
||||||
"vite": "^5.0.11"
|
"vite": "^5.0.11",
|
||||||
|
"vite-plugin-pwa": "^1.0.2"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
module.exports = {
|
export default {
|
||||||
plugins: {
|
plugins: {
|
||||||
tailwindcss: {},
|
tailwindcss: {},
|
||||||
autoprefixer: {},
|
autoprefixer: {},
|
||||||
|
|||||||
BIN
frontend/public/Remove.mp4
Normal file
BIN
frontend/public/manifest/apple-icon-180.png
Normal file
|
After Width: | Height: | Size: 4.1 KiB |
BIN
frontend/public/manifest/apple-splash-1125-2436.jpg
Normal file
|
After Width: | Height: | Size: 34 KiB |
BIN
frontend/public/manifest/apple-splash-1136-640.jpg
Normal file
|
After Width: | Height: | Size: 16 KiB |
BIN
frontend/public/manifest/apple-splash-1170-2532.jpg
Normal file
|
After Width: | Height: | Size: 36 KiB |
BIN
frontend/public/manifest/apple-splash-1179-2556.jpg
Normal file
|
After Width: | Height: | Size: 37 KiB |
BIN
frontend/public/manifest/apple-splash-1206-2622.jpg
Normal file
|
After Width: | Height: | Size: 38 KiB |
BIN
frontend/public/manifest/apple-splash-1242-2208.jpg
Normal file
|
After Width: | Height: | Size: 35 KiB |
BIN
frontend/public/manifest/apple-splash-1242-2688.jpg
Normal file
|
After Width: | Height: | Size: 39 KiB |
BIN
frontend/public/manifest/apple-splash-1284-2778.jpg
Normal file
|
After Width: | Height: | Size: 42 KiB |
BIN
frontend/public/manifest/apple-splash-1290-2796.jpg
Normal file
|
After Width: | Height: | Size: 42 KiB |
BIN
frontend/public/manifest/apple-splash-1320-2868.jpg
Normal file
|
After Width: | Height: | Size: 44 KiB |
BIN
frontend/public/manifest/apple-splash-1334-750.jpg
Normal file
|
After Width: | Height: | Size: 19 KiB |
BIN
frontend/public/manifest/apple-splash-1488-2266.jpg
Normal file
|
After Width: | Height: | Size: 44 KiB |
BIN
frontend/public/manifest/apple-splash-1536-2048.jpg
Normal file
|
After Width: | Height: | Size: 43 KiB |
BIN
frontend/public/manifest/apple-splash-1620-2160.jpg
Normal file
|
After Width: | Height: | Size: 46 KiB |
BIN
frontend/public/manifest/apple-splash-1640-2360.jpg
Normal file
|
After Width: | Height: | Size: 49 KiB |
BIN
frontend/public/manifest/apple-splash-1668-2224.jpg
Normal file
|
After Width: | Height: | Size: 48 KiB |
BIN
frontend/public/manifest/apple-splash-1668-2388.jpg
Normal file
|
After Width: | Height: | Size: 50 KiB |
BIN
frontend/public/manifest/apple-splash-1792-828.jpg
Normal file
|
After Width: | Height: | Size: 23 KiB |
BIN
frontend/public/manifest/apple-splash-2048-1536.jpg
Normal file
|
After Width: | Height: | Size: 45 KiB |
BIN
frontend/public/manifest/apple-splash-2048-2732.jpg
Normal file
|
After Width: | Height: | Size: 65 KiB |
BIN
frontend/public/manifest/apple-splash-2160-1620.jpg
Normal file
|
After Width: | Height: | Size: 48 KiB |
BIN
frontend/public/manifest/apple-splash-2208-1242.jpg
Normal file
|
After Width: | Height: | Size: 38 KiB |
BIN
frontend/public/manifest/apple-splash-2224-1668.jpg
Normal file
|
After Width: | Height: | Size: 50 KiB |
BIN
frontend/public/manifest/apple-splash-2266-1488.jpg
Normal file
|
After Width: | Height: | Size: 46 KiB |
BIN
frontend/public/manifest/apple-splash-2360-1640.jpg
Normal file
|
After Width: | Height: | Size: 50 KiB |
BIN
frontend/public/manifest/apple-splash-2388-1668.jpg
Normal file
|
After Width: | Height: | Size: 52 KiB |
BIN
frontend/public/manifest/apple-splash-2436-1125.jpg
Normal file
|
After Width: | Height: | Size: 35 KiB |
BIN
frontend/public/manifest/apple-splash-2532-1170.jpg
Normal file
|
After Width: | Height: | Size: 38 KiB |
BIN
frontend/public/manifest/apple-splash-2556-1179.jpg
Normal file
|
After Width: | Height: | Size: 38 KiB |
BIN
frontend/public/manifest/apple-splash-2622-1206.jpg
Normal file
|
After Width: | Height: | Size: 40 KiB |
BIN
frontend/public/manifest/apple-splash-2688-1242.jpg
Normal file
|
After Width: | Height: | Size: 41 KiB |
BIN
frontend/public/manifest/apple-splash-2732-2048.jpg
Normal file
|
After Width: | Height: | Size: 68 KiB |
BIN
frontend/public/manifest/apple-splash-2778-1284.jpg
Normal file
|
After Width: | Height: | Size: 44 KiB |
BIN
frontend/public/manifest/apple-splash-2796-1290.jpg
Normal file
|
After Width: | Height: | Size: 43 KiB |
BIN
frontend/public/manifest/apple-splash-2868-1320.jpg
Normal file
|
After Width: | Height: | Size: 45 KiB |
BIN
frontend/public/manifest/apple-splash-640-1136.jpg
Normal file
|
After Width: | Height: | Size: 15 KiB |
BIN
frontend/public/manifest/apple-splash-750-1334.jpg
Normal file
|
After Width: | Height: | Size: 18 KiB |
BIN
frontend/public/manifest/apple-splash-828-1792.jpg
Normal file
|
After Width: | Height: | Size: 22 KiB |
BIN
frontend/public/manifest/manifest-icon-192.maskable.png
Normal file
|
After Width: | Height: | Size: 4.4 KiB |
BIN
frontend/public/manifest/manifest-icon-512.maskable.png
Normal file
|
After Width: | Height: | Size: 12 KiB |
@@ -1,27 +1,29 @@
|
|||||||
<template>
|
<template>
|
||||||
<Layout>
|
<FrappeUIProvider>
|
||||||
|
<Layout class="isolate text-base">
|
||||||
<router-view />
|
<router-view />
|
||||||
</Layout>
|
</Layout>
|
||||||
|
<InstallPrompt v-if="isMobile" />
|
||||||
<Dialogs />
|
<Dialogs />
|
||||||
<Toasts />
|
</FrappeUIProvider>
|
||||||
</template>
|
</template>
|
||||||
<script setup>
|
<script setup>
|
||||||
import { Toasts } from 'frappe-ui'
|
import { FrappeUIProvider } from 'frappe-ui'
|
||||||
import { Dialogs } from '@/utils/dialogs'
|
import { Dialogs } from '@/utils/dialogs'
|
||||||
import { computed, onMounted, onUnmounted, ref } from 'vue'
|
import { computed, onUnmounted, ref, watch } from 'vue'
|
||||||
import { useScreenSize } from './utils/composables'
|
import { useScreenSize } from './utils/composables'
|
||||||
|
import { usersStore } from '@/stores/user'
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
|
import { posthogSettings } from '@/telemetry'
|
||||||
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 { stopSession } from '@/telemetry'
|
import InstallPrompt from './components/InstallPrompt.vue'
|
||||||
import { init as initTelemetry } from '@/telemetry'
|
|
||||||
import { usersStore } from '@/stores/user'
|
|
||||||
import { useRouter } from 'vue-router'
|
|
||||||
|
|
||||||
const screenSize = useScreenSize()
|
const { isMobile } = useScreenSize()
|
||||||
let { userResource } = usersStore()
|
|
||||||
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') {
|
||||||
@@ -36,19 +38,19 @@ const Layout = computed(() => {
|
|||||||
if (noSidebar.value) {
|
if (noSidebar.value) {
|
||||||
return NoSidebarLayout
|
return NoSidebarLayout
|
||||||
}
|
}
|
||||||
if (screenSize.width < 640) {
|
if (isMobile.value) {
|
||||||
return MobileLayout
|
return MobileLayout
|
||||||
} else {
|
|
||||||
return DesktopLayout
|
|
||||||
}
|
}
|
||||||
})
|
return DesktopLayout
|
||||||
|
|
||||||
onMounted(async () => {
|
|
||||||
if (userResource.data) await initTelemetry()
|
|
||||||
})
|
})
|
||||||
|
|
||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
noSidebar.value = false
|
noSidebar.value = false
|
||||||
stopSession()
|
})
|
||||||
|
|
||||||
|
watch(userResource, () => {
|
||||||
|
if (userResource.data) {
|
||||||
|
posthogSettings.reload()
|
||||||
|
}
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -125,7 +125,7 @@
|
|||||||
@click="redirectToWebsite()"
|
@click="redirectToWebsite()"
|
||||||
/>
|
/>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
<Tooltip :text="__('Help')">
|
<Tooltip v-if="showOnboarding" :text="__('Help')">
|
||||||
<CircleHelp
|
<CircleHelp
|
||||||
class="size-4 stroke-1.5 text-ink-gray-7 cursor-pointer"
|
class="size-4 stroke-1.5 text-ink-gray-7 cursor-pointer"
|
||||||
@click="
|
@click="
|
||||||
@@ -181,14 +181,22 @@
|
|||||||
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 { useStorage } from '@vueuse/core'
|
import {
|
||||||
import { ref, onMounted, inject, watch, reactive, markRaw, h } from 'vue'
|
ref,
|
||||||
import { getSidebarLinks } from '../utils'
|
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'
|
||||||
import { useSettings } from '@/stores/settings'
|
import { useSettings } from '@/stores/settings'
|
||||||
import { Button, createResource, Tooltip } from 'frappe-ui'
|
import { Button, call, createResource, Tooltip } from 'frappe-ui'
|
||||||
import PageModal from '@/components/Modals/PageModal.vue'
|
import PageModal from '@/components/Modals/PageModal.vue'
|
||||||
import { capture } from '@/telemetry'
|
import { capture } from '@/telemetry'
|
||||||
import LMSLogo from '@/components/Icons/LMSLogo.vue'
|
import LMSLogo from '@/components/Icons/LMSLogo.vue'
|
||||||
@@ -206,6 +214,7 @@ import {
|
|||||||
Users,
|
Users,
|
||||||
BookText,
|
BookText,
|
||||||
Zap,
|
Zap,
|
||||||
|
Check,
|
||||||
} from 'lucide-vue-next'
|
} from 'lucide-vue-next'
|
||||||
import {
|
import {
|
||||||
TrialBanner,
|
TrialBanner,
|
||||||
@@ -217,7 +226,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')
|
||||||
@@ -228,6 +237,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({})
|
||||||
@@ -244,6 +254,7 @@ const iconProps = {
|
|||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
addNotifications()
|
addNotifications()
|
||||||
setSidebarLinks()
|
setSidebarLinks()
|
||||||
|
setUpOnboarding()
|
||||||
socket.on('publish_lms_notifications', (data) => {
|
socket.on('publish_lms_notifications', (data) => {
|
||||||
unreadNotifications.reload()
|
unreadNotifications.reload()
|
||||||
})
|
})
|
||||||
@@ -304,7 +315,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 +331,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,28 +345,28 @@ const addAssignments = () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const addPrograms = () => {
|
const addProgrammingExercises = () => {
|
||||||
let activeFor = ['Programs', 'ProgramForm']
|
if (isInstructor.value || isModerator.value) {
|
||||||
let index = 1
|
sidebarLinks.value.splice(3, 0, {
|
||||||
let canAddProgram = false
|
label: 'Programming Exercises',
|
||||||
|
icon: 'Code',
|
||||||
if (
|
to: 'ProgrammingExercises',
|
||||||
!isInstructor.value &&
|
activeFor: [
|
||||||
!isModerator.value &&
|
'ProgrammingExercises',
|
||||||
settingsStore.learningPaths.data
|
'ProgrammingExerciseForm',
|
||||||
) {
|
'ProgrammingExerciseSubmissions',
|
||||||
sidebarLinks.value = sidebarLinks.value.filter(
|
'ProgrammingExerciseSubmission',
|
||||||
(link) => link.label !== 'Courses'
|
],
|
||||||
)
|
})
|
||||||
activeFor.push('CourseDetail')
|
}
|
||||||
activeFor.push('Lesson')
|
|
||||||
index = 0
|
|
||||||
canAddProgram = true
|
|
||||||
} else if (isInstructor.value || isModerator.value) {
|
|
||||||
canAddProgram = true
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (canAddProgram) {
|
const addPrograms = async () => {
|
||||||
|
let canAddProgram = await checkIfCanAddProgram()
|
||||||
|
if (!canAddProgram) return
|
||||||
|
let activeFor = ['Programs', 'ProgramDetail']
|
||||||
|
let index = 2
|
||||||
|
|
||||||
sidebarLinks.value.splice(index, 0, {
|
sidebarLinks.value.splice(index, 0, {
|
||||||
label: 'Programs',
|
label: 'Programs',
|
||||||
icon: 'Route',
|
icon: 'Route',
|
||||||
@@ -363,6 +374,22 @@ const addPrograms = () => {
|
|||||||
activeFor: activeFor,
|
activeFor: activeFor,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const checkIfCanAddProgram = async () => {
|
||||||
|
if (isModerator.value || isInstructor.value) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
const programs = await call('lms.lms.utils.get_programs')
|
||||||
|
return programs.enrolled.length > 0 || programs.published.length > 0
|
||||||
|
}
|
||||||
|
|
||||||
|
const addHome = () => {
|
||||||
|
sidebarLinks.value.unshift({
|
||||||
|
label: 'Home',
|
||||||
|
icon: 'Home',
|
||||||
|
to: 'Home',
|
||||||
|
activeFor: ['Home'],
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const openPageModal = (link) => {
|
const openPageModal = (link) => {
|
||||||
@@ -388,10 +415,6 @@ const deletePage = (link) => {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const getSidebarFromStorage = () => {
|
|
||||||
return useStorage('sidebar_is_collapsed', false)
|
|
||||||
}
|
|
||||||
|
|
||||||
const toggleSidebar = () => {
|
const toggleSidebar = () => {
|
||||||
sidebarStore.isSidebarCollapsed = !sidebarStore.isSidebarCollapsed
|
sidebarStore.isSidebarCollapsed = !sidebarStore.isSidebarCollapsed
|
||||||
localStorage.setItem(
|
localStorage.setItem(
|
||||||
@@ -438,6 +461,7 @@ const steps = reactive([
|
|||||||
title: __('Add your first chapter'),
|
title: __('Add your first chapter'),
|
||||||
icon: markRaw(h(FolderTree, iconProps)),
|
icon: markRaw(h(FolderTree, iconProps)),
|
||||||
completed: false,
|
completed: false,
|
||||||
|
dependsOn: 'create_first_course',
|
||||||
onClick: async () => {
|
onClick: async () => {
|
||||||
minimize.value = true
|
minimize.value = true
|
||||||
let course = await getFirstCourse()
|
let course = await getFirstCourse()
|
||||||
@@ -453,6 +477,7 @@ const steps = reactive([
|
|||||||
title: __('Add your first lesson'),
|
title: __('Add your first lesson'),
|
||||||
icon: markRaw(h(FileText, iconProps)),
|
icon: markRaw(h(FileText, iconProps)),
|
||||||
completed: false,
|
completed: false,
|
||||||
|
dependsOn: 'create_first_chapter',
|
||||||
onClick: async () => {
|
onClick: async () => {
|
||||||
minimize.value = true
|
minimize.value = true
|
||||||
let course = await getFirstCourse()
|
let course = await getFirstCourse()
|
||||||
@@ -471,6 +496,7 @@ const steps = reactive([
|
|||||||
title: __('Create your first quiz'),
|
title: __('Create your first quiz'),
|
||||||
icon: markRaw(h(CircleHelp, iconProps)),
|
icon: markRaw(h(CircleHelp, iconProps)),
|
||||||
completed: false,
|
completed: false,
|
||||||
|
dependsOn: 'create_first_course',
|
||||||
onClick: () => {
|
onClick: () => {
|
||||||
minimize.value = true
|
minimize.value = true
|
||||||
router.push({ name: 'Quizzes' })
|
router.push({ name: 'Quizzes' })
|
||||||
@@ -502,6 +528,7 @@ const steps = reactive([
|
|||||||
title: __('Add students to your batch'),
|
title: __('Add students to your batch'),
|
||||||
icon: markRaw(h(UserPlus, iconProps)),
|
icon: markRaw(h(UserPlus, iconProps)),
|
||||||
completed: false,
|
completed: false,
|
||||||
|
dependsOn: 'create_first_batch',
|
||||||
onClick: async () => {
|
onClick: async () => {
|
||||||
minimize.value = true
|
minimize.value = true
|
||||||
let batch = await getFirstBatch()
|
let batch = await getFirstBatch()
|
||||||
@@ -522,6 +549,7 @@ const steps = reactive([
|
|||||||
title: __('Add courses to your batch'),
|
title: __('Add courses to your batch'),
|
||||||
icon: markRaw(h(BookText, iconProps)),
|
icon: markRaw(h(BookText, iconProps)),
|
||||||
completed: false,
|
completed: false,
|
||||||
|
dependsOn: 'create_first_batch',
|
||||||
onClick: async () => {
|
onClick: async () => {
|
||||||
minimize.value = true
|
minimize.value = true
|
||||||
let batch = await getFirstBatch()
|
let batch = await getFirstBatch()
|
||||||
@@ -566,6 +594,11 @@ const articles = ref([
|
|||||||
{ name: 'create-a-live-class', title: __('Create a live class') },
|
{ name: 'create-a-live-class', title: __('Create a live class') },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
title: __('Learning Paths'),
|
||||||
|
opened: false,
|
||||||
|
subArticles: [{ name: 'add-a-program', title: __('Add a program') }],
|
||||||
|
},
|
||||||
{
|
{
|
||||||
title: __('Assessments'),
|
title: __('Assessments'),
|
||||||
opened: false,
|
opened: false,
|
||||||
@@ -615,7 +648,9 @@ watch(userResource, () => {
|
|||||||
if (userResource.data) {
|
if (userResource.data) {
|
||||||
isModerator.value = userResource.data.is_moderator
|
isModerator.value = userResource.data.is_moderator
|
||||||
isInstructor.value = userResource.data.is_instructor
|
isInstructor.value = userResource.data.is_instructor
|
||||||
|
addHome()
|
||||||
addPrograms()
|
addPrograms()
|
||||||
|
addProgrammingExercises()
|
||||||
addQuizzes()
|
addQuizzes()
|
||||||
addAssignments()
|
addAssignments()
|
||||||
setUpOnboarding()
|
setUpOnboarding()
|
||||||
@@ -625,4 +660,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>
|
||||||
|
|||||||
@@ -70,6 +70,9 @@
|
|||||||
<FileUploader
|
<FileUploader
|
||||||
v-if="!submissionFile"
|
v-if="!submissionFile"
|
||||||
:fileTypes="getType()"
|
:fileTypes="getType()"
|
||||||
|
:uploadArgs="{
|
||||||
|
private: true,
|
||||||
|
}"
|
||||||
:validateFile="validateFile"
|
:validateFile="validateFile"
|
||||||
@success="(file) => saveSubmission(file)"
|
@success="(file) => saveSubmission(file)"
|
||||||
>
|
>
|
||||||
@@ -191,10 +194,11 @@ import {
|
|||||||
FileUploader,
|
FileUploader,
|
||||||
FormControl,
|
FormControl,
|
||||||
TextEditor,
|
TextEditor,
|
||||||
|
toast,
|
||||||
} from 'frappe-ui'
|
} from 'frappe-ui'
|
||||||
import { computed, inject, onMounted, onBeforeUnmount, ref, watch } from 'vue'
|
import { computed, inject, onMounted, onBeforeUnmount, ref, watch } from 'vue'
|
||||||
import { FileText, X } from 'lucide-vue-next'
|
import { FileText, X } from 'lucide-vue-next'
|
||||||
import { showToast, getFileSize } from '@/utils'
|
import { getFileSize } from '@/utils'
|
||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
|
|
||||||
const submissionFile = ref(null)
|
const submissionFile = ref(null)
|
||||||
@@ -284,7 +288,7 @@ const submissionResource = createDocumentResource({
|
|||||||
doctype: 'LMS Assignment Submission',
|
doctype: 'LMS Assignment Submission',
|
||||||
name: props.submissionName,
|
name: props.submissionName,
|
||||||
onError(err) {
|
onError(err) {
|
||||||
showToast(__('Error'), __(err.messages?.[0] || err), 'x')
|
toast.error(err.messages?.[0] || err)
|
||||||
},
|
},
|
||||||
auto: false,
|
auto: false,
|
||||||
cache: [user.data?.name, props.assignmentID],
|
cache: [user.data?.name, props.assignmentID],
|
||||||
@@ -338,7 +342,7 @@ const submitAssignment = () => {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
onSuccess(data) {
|
onSuccess(data) {
|
||||||
showToast(__('Success'), __('Changes saved successfully'), 'check')
|
toast.success(__('Changes saved successfully'))
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
@@ -352,7 +356,7 @@ const addNewSubmission = () => {
|
|||||||
{},
|
{},
|
||||||
{
|
{
|
||||||
onSuccess(data) {
|
onSuccess(data) {
|
||||||
showToast('Success', 'Assignment submitted successfully.', 'check')
|
toast.success(__('Assignment submitted successfully'))
|
||||||
if (router.currentRoute.value.name == 'AssignmentSubmission') {
|
if (router.currentRoute.value.name == 'AssignmentSubmission') {
|
||||||
router.push({
|
router.push({
|
||||||
name: 'AssignmentSubmission',
|
name: 'AssignmentSubmission',
|
||||||
@@ -370,7 +374,7 @@ const addNewSubmission = () => {
|
|||||||
submissionResource.reload()
|
submissionResource.reload()
|
||||||
},
|
},
|
||||||
onError(err) {
|
onError(err) {
|
||||||
showToast('Error', err.messages?.[0] || err, 'x')
|
toast.error(err.messages?.[0] || err)
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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'
|
||||||
|
|||||||
@@ -86,9 +86,9 @@ import {
|
|||||||
ListRows,
|
ListRows,
|
||||||
ListView,
|
ListView,
|
||||||
ListRowItem,
|
ListRowItem,
|
||||||
|
toast,
|
||||||
} from 'frappe-ui'
|
} from 'frappe-ui'
|
||||||
import { Plus, Trash2 } from 'lucide-vue-next'
|
import { Plus, Trash2 } from 'lucide-vue-next'
|
||||||
import { showToast } from '@/utils'
|
|
||||||
const readOnlyMode = window.read_only_mode
|
const readOnlyMode = window.read_only_mode
|
||||||
|
|
||||||
const showCourseModal = ref(false)
|
const showCourseModal = ref(false)
|
||||||
@@ -106,7 +106,6 @@ const courses = createResource({
|
|||||||
params: {
|
params: {
|
||||||
batch: props.batch,
|
batch: props.batch,
|
||||||
},
|
},
|
||||||
cache: ['batchCourses', props.batchName],
|
|
||||||
auto: true,
|
auto: true,
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -152,7 +151,7 @@ const removeCourses = (selections, unselectAll) => {
|
|||||||
{
|
{
|
||||||
onSuccess(data) {
|
onSuccess(data) {
|
||||||
courses.reload()
|
courses.reload()
|
||||||
showToast(__('Success'), __('Courses deleted successfully'), 'check')
|
toast.success(__('Courses deleted successfully'))
|
||||||
unselectAll()
|
unselectAll()
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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: {
|
||||||
|
|||||||
@@ -1,21 +1,22 @@
|
|||||||
<template>
|
<template>
|
||||||
<div v-if="user.data?.is_student">
|
<div v-if="user.data?.is_student">
|
||||||
<div
|
<div>
|
||||||
v-if="feedbackList.data?.length"
|
<div class="leading-5 mb-4">
|
||||||
class="bg-surface-blue-2 text-blue-700 p-2 rounded-md mb-5"
|
<div v-if="readOnly">
|
||||||
|
{{ __('Thank you for providing your feedback.') }}
|
||||||
|
<span
|
||||||
|
@click="showFeedbackForm = !showFeedbackForm"
|
||||||
|
class="underline cursor-pointer"
|
||||||
|
>{{ __('Click here') }}</span
|
||||||
>
|
>
|
||||||
{{ __('Thank you for providing your feedback!') }}
|
{{ __('to view your feedback.') }}
|
||||||
</div>
|
</div>
|
||||||
<div v-else class="flex justify-between items-center mb-5">
|
<div v-else>
|
||||||
<div class="text-lg font-semibold">
|
{{ __('Help us improve by providing your feedback.') }}
|
||||||
{{ __('Help Us Improve') }}
|
|
||||||
</div>
|
</div>
|
||||||
<Button @click="submitFeedback()">
|
|
||||||
{{ __('Submit') }}
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="space-y-8">
|
<div class="space-y-4" :class="showFeedbackForm ? 'block' : 'hidden'">
|
||||||
<div class="flex items-center justify-between">
|
<div class="space-y-4">
|
||||||
<Rating
|
<Rating
|
||||||
v-for="key in ratingKeys"
|
v-for="key in ratingKeys"
|
||||||
v-model="feedback[key]"
|
v-model="feedback[key]"
|
||||||
@@ -27,18 +28,22 @@
|
|||||||
v-model="feedback.feedback"
|
v-model="feedback.feedback"
|
||||||
type="textarea"
|
type="textarea"
|
||||||
:label="__('Feedback')"
|
:label="__('Feedback')"
|
||||||
:rows="7"
|
:rows="9"
|
||||||
:readonly="readOnly"
|
:readonly="readOnly"
|
||||||
/>
|
/>
|
||||||
|
<Button v-if="!readOnly" @click="submitFeedback">
|
||||||
|
{{ __('Submit Feedback') }}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-else-if="feedbackList.data?.length">
|
<div v-else-if="feedbackList.data?.length">
|
||||||
<div class="text-lg font-semibold mb-5">
|
<div class="leading-5 text-sm mb-2 mt-5">
|
||||||
{{ __('Average of Feedback Received') }}
|
{{ __('Average Feedback Received') }}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex items-center justify-between mb-10">
|
<div class="space-y-4">
|
||||||
<Rating
|
<Rating
|
||||||
v-for="key in ratingKeys"
|
v-for="key in ratingKeys"
|
||||||
v-model="average[key]"
|
v-model="average[key]"
|
||||||
@@ -47,81 +52,32 @@
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="text-lg font-semibold mb-5">
|
<Button variant="outline" class="mt-5" @click="showAllFeedback = true">
|
||||||
{{ __('All Feedback') }}
|
{{ __('View all feedback') }}
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<ListView
|
<div v-else class="text-ink-gray-7 mt-5 leading-5">
|
||||||
:columns="feedbackColumns"
|
|
||||||
:rows="feedbackList.data"
|
|
||||||
row-key="name"
|
|
||||||
:options="{
|
|
||||||
showTooltip: false,
|
|
||||||
rowHeight: 'h-16',
|
|
||||||
selectable: false,
|
|
||||||
}"
|
|
||||||
>
|
|
||||||
<ListHeader
|
|
||||||
class="mb-2 grid items-center space-x-4 rounded bg-surface-gray-2 p-2"
|
|
||||||
></ListHeader>
|
|
||||||
<ListRows>
|
|
||||||
<ListRow
|
|
||||||
:row="row"
|
|
||||||
v-for="row in feedbackList.data"
|
|
||||||
class="group cursor-pointer feedback-list"
|
|
||||||
>
|
|
||||||
<template #default="{ column, item }">
|
|
||||||
<ListRowItem
|
|
||||||
:item="row[column.key]"
|
|
||||||
:align="column.align"
|
|
||||||
class="text-sm"
|
|
||||||
>
|
|
||||||
<template #prefix>
|
|
||||||
<div v-if="column.key == 'member_name'">
|
|
||||||
<Avatar
|
|
||||||
class="flex"
|
|
||||||
:image="row['member_image']"
|
|
||||||
:label="item"
|
|
||||||
size="sm"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
<div v-if="ratingKeys.includes(column.key)">
|
|
||||||
<Rating v-model="row[column.key]" :readonly="true" />
|
|
||||||
</div>
|
|
||||||
<div v-else class="leading-5">
|
|
||||||
{{ row[column.key] }}
|
|
||||||
</div>
|
|
||||||
</ListRowItem>
|
|
||||||
</template>
|
|
||||||
</ListRow>
|
|
||||||
</ListRows>
|
|
||||||
</ListView>
|
|
||||||
</div>
|
|
||||||
<div v-else class="text-sm italic text-center text-ink-gray-7 mt-5">
|
|
||||||
{{ __('No feedback received yet.') }}
|
{{ __('No feedback received yet.') }}
|
||||||
</div>
|
</div>
|
||||||
|
<FeedbackModal
|
||||||
|
v-if="feedbackList.data?.length"
|
||||||
|
v-model="showAllFeedback"
|
||||||
|
:feedbackList="feedbackList.data"
|
||||||
|
/>
|
||||||
</template>
|
</template>
|
||||||
<script setup>
|
<script setup>
|
||||||
import { computed, inject, onMounted, reactive, ref, watch } from 'vue'
|
import { inject, onMounted, reactive, ref, watch } from 'vue'
|
||||||
import { convertToTitleCase } from '@/utils'
|
import { convertToTitleCase } from '@/utils'
|
||||||
import {
|
import { Button, createListResource, FormControl, Rating } from 'frappe-ui'
|
||||||
Avatar,
|
import FeedbackModal from '@/components/Modals/FeedbackModal.vue'
|
||||||
Button,
|
|
||||||
createListResource,
|
|
||||||
FormControl,
|
|
||||||
ListView,
|
|
||||||
ListHeader,
|
|
||||||
ListRows,
|
|
||||||
ListRow,
|
|
||||||
ListRowItem,
|
|
||||||
Rating,
|
|
||||||
} from 'frappe-ui'
|
|
||||||
|
|
||||||
const user = inject('$user')
|
const user = inject('$user')
|
||||||
const ratingKeys = ['content', 'instructors', 'value']
|
const ratingKeys = ['content', 'instructors', 'value']
|
||||||
const readOnly = ref(false)
|
const readOnly = ref(false)
|
||||||
const average = reactive({})
|
const average = reactive({})
|
||||||
const feedback = reactive({})
|
const feedback = reactive({})
|
||||||
|
const showFeedbackForm = ref(true)
|
||||||
|
const showAllFeedback = ref(false)
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
batch: {
|
batch: {
|
||||||
@@ -167,6 +123,7 @@ watch(
|
|||||||
if (feedbackList.data.length) {
|
if (feedbackList.data.length) {
|
||||||
let data = feedbackList.data
|
let data = feedbackList.data
|
||||||
readOnly.value = true
|
readOnly.value = true
|
||||||
|
showFeedbackForm.value = false
|
||||||
|
|
||||||
ratingKeys.forEach((key) => {
|
ratingKeys.forEach((key) => {
|
||||||
average[key] = 0
|
average[key] = 0
|
||||||
@@ -201,40 +158,11 @@ const submitFeedback = () => {
|
|||||||
{
|
{
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
feedbackList.reload()
|
feedbackList.reload()
|
||||||
|
showFeedbackForm.value = false
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const feedbackColumns = computed(() => {
|
|
||||||
return [
|
|
||||||
{
|
|
||||||
label: 'Member',
|
|
||||||
key: 'member_name',
|
|
||||||
width: '10rem',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Feedback',
|
|
||||||
key: 'feedback',
|
|
||||||
width: '15rem',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Content',
|
|
||||||
key: 'content',
|
|
||||||
width: '9rem',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Instructors',
|
|
||||||
key: 'instructors',
|
|
||||||
width: '9rem',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Value',
|
|
||||||
key: 'value',
|
|
||||||
width: '9rem',
|
|
||||||
},
|
|
||||||
]
|
|
||||||
})
|
|
||||||
</script>
|
</script>
|
||||||
<style>
|
<style>
|
||||||
.feedback-list > button > div {
|
.feedback-list > button > div {
|
||||||
|
|||||||
@@ -2,7 +2,12 @@
|
|||||||
<div v-if="batch.data" class="border-2 rounded-md p-5 lg:w-72">
|
<div v-if="batch.data" class="border-2 rounded-md p-5 lg:w-72">
|
||||||
<div
|
<div
|
||||||
v-if="batch.data.seat_count && seats_left > 0"
|
v-if="batch.data.seat_count && seats_left > 0"
|
||||||
class="text-xs bg-green-100 text-green-700 float-right px-2 py-0.5 rounded-md"
|
class="text-sm bg-green-100 text-green-700 px-2 py-1 rounded-md"
|
||||||
|
:class="
|
||||||
|
batch.data.amount || batch.data.courses.length
|
||||||
|
? 'float-right'
|
||||||
|
: 'w-fit mb-4'
|
||||||
|
"
|
||||||
>
|
>
|
||||||
{{ seats_left }}
|
{{ seats_left }}
|
||||||
<span v-if="seats_left > 1">
|
<span v-if="seats_left > 1">
|
||||||
@@ -51,7 +56,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div v-if="!readOnlyMode">
|
<div v-if="!readOnlyMode">
|
||||||
<router-link
|
<router-link
|
||||||
v-if="isModerator || isStudent"
|
v-if="canAccessBatch"
|
||||||
:to="{
|
:to="{
|
||||||
name: 'Batch',
|
name: 'Batch',
|
||||||
params: {
|
params: {
|
||||||
@@ -60,8 +65,12 @@
|
|||||||
}"
|
}"
|
||||||
>
|
>
|
||||||
<Button variant="solid" class="w-full mt-4">
|
<Button variant="solid" class="w-full mt-4">
|
||||||
|
<template #prefix>
|
||||||
|
<LogIn v-if="isStudent" class="size-4 stroke-1.5" />
|
||||||
|
<Settings v-else class="size-4 stroke-1.5" />
|
||||||
|
</template>
|
||||||
<span>
|
<span>
|
||||||
{{ isModerator ? __('Manage Batch') : __('Visit Batch') }}
|
{{ isStudent ? __('Visit Batch') : __('Manage Batch') }}
|
||||||
</span>
|
</span>
|
||||||
</Button>
|
</Button>
|
||||||
</router-link>
|
</router-link>
|
||||||
@@ -80,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>
|
||||||
@@ -95,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
|
||||||
@@ -107,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>
|
||||||
@@ -117,9 +135,18 @@
|
|||||||
</template>
|
</template>
|
||||||
<script setup>
|
<script setup>
|
||||||
import { inject, computed } from 'vue'
|
import { inject, computed } from 'vue'
|
||||||
import { Badge, Button, createResource } from 'frappe-ui'
|
import { Button, createResource, toast } from 'frappe-ui'
|
||||||
import { BookOpen, Clock, Globe } from 'lucide-vue-next'
|
import {
|
||||||
import { formatNumberIntoCurrency, formatTime, showToast } from '@/utils'
|
BookOpen,
|
||||||
|
Clock,
|
||||||
|
CreditCard,
|
||||||
|
Globe,
|
||||||
|
GraduationCap,
|
||||||
|
LogIn,
|
||||||
|
Pencil,
|
||||||
|
Settings,
|
||||||
|
} from 'lucide-vue-next'
|
||||||
|
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'
|
||||||
|
|
||||||
@@ -151,11 +178,7 @@ const enrollInBatch = () => {
|
|||||||
{},
|
{},
|
||||||
{
|
{
|
||||||
onSuccess(data) {
|
onSuccess(data) {
|
||||||
showToast(
|
toast.success(__('You have been enrolled in this batch'))
|
||||||
__('Success'),
|
|
||||||
__('You have been enrolled in this batch'),
|
|
||||||
'check'
|
|
||||||
)
|
|
||||||
router.push({
|
router.push({
|
||||||
name: 'Batch',
|
name: 'Batch',
|
||||||
params: {
|
params: {
|
||||||
@@ -181,4 +204,12 @@ const isStudent = computed(() => {
|
|||||||
const isModerator = computed(() => {
|
const isModerator = computed(() => {
|
||||||
return user.data?.is_moderator
|
return user.data?.is_moderator
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const isEvaluator = computed(() => {
|
||||||
|
return user.data?.is_evaluator
|
||||||
|
})
|
||||||
|
|
||||||
|
const canAccessBatch = computed(() => {
|
||||||
|
return isModerator.value || isStudent.value || isEvaluator.value
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -1,108 +1,64 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="">
|
<div v-if="batch.data" class="">
|
||||||
<div class="w-full flex items-center justify-between pb-4">
|
<div class="w-full flex items-center justify-between pb-4">
|
||||||
<div class="font-medium text-ink-gray-7">
|
<div class="font-medium text-ink-gray-7">
|
||||||
{{ __('Statistics') }}
|
{{ __('Statistics') }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="grid grid-cols-4 gap-5 mb-8">
|
<div class="grid grid-cols-2 md:grid-cols-4 gap-5 mb-8">
|
||||||
<div
|
<NumberChart
|
||||||
class="flex items-center border py-2 px-3 rounded-md text-ink-gray-7"
|
class="border rounded-md"
|
||||||
>
|
:config="{ title: __('Students'), value: students.data?.length || 0 }"
|
||||||
<div class="p-2 rounded-md bg-surface-gray-2 mr-3">
|
/>
|
||||||
<User class="w-5 h-5 stroke-1.5" />
|
|
||||||
</div>
|
<NumberChart
|
||||||
<div class="flex items-center space-x-2">
|
class="border rounded-md"
|
||||||
<span class="font-semibold">
|
:config="{
|
||||||
{{ students.data?.length }}
|
title: __('Certified'),
|
||||||
</span>
|
value: certificationCount.data || 0,
|
||||||
<span class="">
|
}"
|
||||||
{{ __('Students') }}
|
/>
|
||||||
</span>
|
|
||||||
</div>
|
<NumberChart
|
||||||
</div>
|
class="border rounded-md"
|
||||||
|
:config="{
|
||||||
<div
|
title: __('Courses'),
|
||||||
class="flex items-center border py-2 px-3 rounded-md text-ink-gray-7"
|
value: batch.data.courses?.length || 0,
|
||||||
>
|
}"
|
||||||
<div class="p-2 rounded-md bg-surface-gray-2 mr-3">
|
/>
|
||||||
<GraduationCap class="w-5 h-5 stroke-1.5" />
|
|
||||||
</div>
|
<NumberChart
|
||||||
<div class="flex items-center space-x-2">
|
class="border rounded-md"
|
||||||
<span class="font-semibold">
|
:config="{ title: __('Assessments'), value: assessmentCount || 0 }"
|
||||||
{{ certificationCount.data }}
|
|
||||||
</span>
|
|
||||||
<span class="">
|
|
||||||
{{ __('Certified') }}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div
|
|
||||||
class="flex items-center border py-2 px-3 rounded-md text-ink-gray-7"
|
|
||||||
>
|
|
||||||
<div class="p-2 rounded-md bg-surface-gray-2 mr-3">
|
|
||||||
<BookOpen class="w-5 h-5 stroke-1.5" />
|
|
||||||
</div>
|
|
||||||
<div class="flex items-center space-x-2">
|
|
||||||
<span class="font-semibold">
|
|
||||||
{{ batch.courses?.length }}
|
|
||||||
</span>
|
|
||||||
<span>
|
|
||||||
{{ __('Courses') }}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div
|
|
||||||
class="flex items-center border py-2 px-3 rounded-md text-ink-gray-7"
|
|
||||||
>
|
|
||||||
<div class="p-2 rounded-md bg-surface-gray-2 mr-3">
|
|
||||||
<ShieldCheck class="w-5 h-5 stroke-1.5" />
|
|
||||||
</div>
|
|
||||||
<div class="flex items-center space-x-2">
|
|
||||||
<span class="font-semibold">
|
|
||||||
{{ assessmentCount }}
|
|
||||||
</span>
|
|
||||||
<span>
|
|
||||||
{{ __('Assessments') }}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div v-if="showProgressChart" class="mb-8">
|
|
||||||
<div class="text-ink-gray-7 font-medium">
|
|
||||||
{{ __('Progress') }}
|
|
||||||
</div>
|
|
||||||
<ApexChart
|
|
||||||
:options="chartOptions"
|
|
||||||
:series="chartData"
|
|
||||||
type="bar"
|
|
||||||
:height="chartData[0].data.length * 30 + 100"
|
|
||||||
/>
|
/>
|
||||||
<div
|
|
||||||
class="flex items-center justify-center text-sm text-ink-gray-7 space-x-4"
|
|
||||||
>
|
|
||||||
<div class="flex items-center space-x-2">
|
|
||||||
<div
|
|
||||||
class="w-3 h-3 rounded-sm"
|
|
||||||
:style="{ 'background-color': theme.colors.green[600] }"
|
|
||||||
></div>
|
|
||||||
<div>
|
|
||||||
{{ __('Courses') }}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="flex items-center space-x-2">
|
|
||||||
<div
|
|
||||||
class="w-3 h-3 rounded-sm"
|
|
||||||
:style="{ 'background-color': theme.colors.blue[600] }"
|
|
||||||
></div>
|
|
||||||
<div>
|
|
||||||
{{ __('Assessments') }}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<AxisChart
|
||||||
|
v-if="showProgressChart"
|
||||||
|
:config="{
|
||||||
|
data: chartData,
|
||||||
|
title: __('Batch Summary'),
|
||||||
|
subtitle: __('Progress of students in courses and assessments'),
|
||||||
|
xAxis: {
|
||||||
|
key: 'task',
|
||||||
|
title: 'Tasks',
|
||||||
|
type: 'category',
|
||||||
|
},
|
||||||
|
yAxis: {
|
||||||
|
title: __('Number of Students'),
|
||||||
|
echartOptions: {
|
||||||
|
minInterval: 1,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
swapXY: true,
|
||||||
|
series: [
|
||||||
|
{
|
||||||
|
name: 'value',
|
||||||
|
type: 'bar',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
@@ -201,9 +157,10 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<StudentModal
|
<StudentModal
|
||||||
:batch="props.batch.name"
|
:batch="props.batch.data.name"
|
||||||
v-model="showStudentModal"
|
v-model="showStudentModal"
|
||||||
v-model:reloadStudents="students"
|
v-model:reloadStudents="students"
|
||||||
|
v-model:batchModal="props.batch"
|
||||||
/>
|
/>
|
||||||
<BatchStudentProgress
|
<BatchStudentProgress
|
||||||
:student="selectedStudent"
|
:student="selectedStudent"
|
||||||
@@ -213,6 +170,7 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import {
|
import {
|
||||||
Avatar,
|
Avatar,
|
||||||
|
AxisChart,
|
||||||
Button,
|
Button,
|
||||||
createResource,
|
createResource,
|
||||||
FeatherIcon,
|
FeatherIcon,
|
||||||
@@ -223,6 +181,8 @@ import {
|
|||||||
ListRows,
|
ListRows,
|
||||||
ListView,
|
ListView,
|
||||||
ListRowItem,
|
ListRowItem,
|
||||||
|
NumberChart,
|
||||||
|
toast,
|
||||||
} from 'frappe-ui'
|
} from 'frappe-ui'
|
||||||
import {
|
import {
|
||||||
BookOpen,
|
BookOpen,
|
||||||
@@ -234,7 +194,6 @@ import {
|
|||||||
} from 'lucide-vue-next'
|
} from 'lucide-vue-next'
|
||||||
import { ref, watch } from 'vue'
|
import { ref, watch } from 'vue'
|
||||||
import StudentModal from '@/components/Modals/StudentModal.vue'
|
import StudentModal from '@/components/Modals/StudentModal.vue'
|
||||||
import { showToast } from '@/utils'
|
|
||||||
import ProgressBar from '@/components/ProgressBar.vue'
|
import ProgressBar from '@/components/ProgressBar.vue'
|
||||||
import BatchStudentProgress from '@/components/Modals/BatchStudentProgress.vue'
|
import BatchStudentProgress from '@/components/Modals/BatchStudentProgress.vue'
|
||||||
import ApexChart from 'vue3-apexcharts'
|
import ApexChart from 'vue3-apexcharts'
|
||||||
@@ -244,7 +203,6 @@ const showStudentModal = ref(false)
|
|||||||
const showStudentProgressModal = ref(false)
|
const showStudentProgressModal = ref(false)
|
||||||
const selectedStudent = ref(null)
|
const selectedStudent = ref(null)
|
||||||
const chartData = ref(null)
|
const chartData = ref(null)
|
||||||
const chartOptions = ref(null)
|
|
||||||
const showProgressChart = ref(false)
|
const showProgressChart = ref(false)
|
||||||
const assessmentCount = ref(0)
|
const assessmentCount = ref(0)
|
||||||
const readOnlyMode = window.read_only_mode
|
const readOnlyMode = window.read_only_mode
|
||||||
@@ -258,15 +216,15 @@ const props = defineProps({
|
|||||||
|
|
||||||
const students = createResource({
|
const students = createResource({
|
||||||
url: 'lms.lms.utils.get_batch_students',
|
url: 'lms.lms.utils.get_batch_students',
|
||||||
cache: ['students', props.batch.name],
|
|
||||||
params: {
|
params: {
|
||||||
batch: props.batch?.name,
|
batch: props.batch?.data?.name,
|
||||||
},
|
},
|
||||||
auto: true,
|
auto: true,
|
||||||
onSuccess(data) {
|
onSuccess(data) {
|
||||||
chartData.value = getChartData()
|
chartData.value = getChartData()
|
||||||
showProgressChart.value =
|
showProgressChart.value =
|
||||||
data.length && (props.batch?.courses?.length || assessmentCount.value)
|
data.length &&
|
||||||
|
(props.batch?.data?.courses?.length || assessmentCount.value)
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -323,7 +281,8 @@ const removeStudents = (selections, unselectAll) => {
|
|||||||
{
|
{
|
||||||
onSuccess(data) {
|
onSuccess(data) {
|
||||||
students.reload()
|
students.reload()
|
||||||
showToast(__('Success'), __('Students deleted successfully'), 'check')
|
props.batch.reload()
|
||||||
|
toast.success(__('Students deleted successfully'))
|
||||||
unselectAll()
|
unselectAll()
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
@@ -331,96 +290,49 @@ const removeStudents = (selections, unselectAll) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const getChartData = () => {
|
const getChartData = () => {
|
||||||
let categories = {}
|
let tasks = []
|
||||||
|
let data = []
|
||||||
|
|
||||||
if (!students.data?.length) return []
|
students.data.forEach((row) => {
|
||||||
|
tasks = countAssessments(row, tasks)
|
||||||
Object.keys(students.data[0].courses).forEach((course) => {
|
tasks = countCourses(row, tasks)
|
||||||
categories[course] = {
|
|
||||||
value: 0,
|
|
||||||
type: 'course',
|
|
||||||
label: course,
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
|
|
||||||
Object.keys(students.data?.[0].assessments).forEach((assessment) => {
|
tasks.forEach((task) => {
|
||||||
categories[assessment] = {
|
data.push({
|
||||||
value: 0,
|
task: task.label,
|
||||||
type: 'assessment',
|
value: task.value,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
const countAssessments = (row, tasks) => {
|
||||||
|
Object.keys(row.assessments).forEach((assessment) => {
|
||||||
|
if (row.assessments[assessment].result === 'Pass') {
|
||||||
|
tasks.filter((task) => task.label === assessment).length
|
||||||
|
? tasks.filter((task) => task.label === assessment)[0].value++
|
||||||
|
: tasks.push({
|
||||||
|
value: 1,
|
||||||
label: assessment,
|
label: assessment,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
return tasks
|
||||||
|
}
|
||||||
|
|
||||||
students.data.forEach((student) => {
|
const countCourses = (row, tasks) => {
|
||||||
Object.keys(student.courses).forEach((course) => {
|
Object.keys(row.courses).forEach((course) => {
|
||||||
if (student.courses[course] === 100) {
|
if (row.courses[course] === 100) {
|
||||||
categories[course].value += 1
|
tasks.filter((task) => task.label === course).length
|
||||||
|
? tasks.filter((task) => task.label === course)[0].value++
|
||||||
|
: tasks.push({
|
||||||
|
value: 1,
|
||||||
|
label: course,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
return tasks
|
||||||
Object.keys(student.assessments).forEach((assessment) => {
|
|
||||||
if (student.assessments[assessment].result === 'Pass') {
|
|
||||||
categories[assessment].value += 1
|
|
||||||
}
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
chartOptions.value = getChartOptions(categories)
|
|
||||||
return [
|
|
||||||
{
|
|
||||||
name: __('Completed by Students'),
|
|
||||||
data: Object.values(categories).map((item) => item.value),
|
|
||||||
},
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
||||||
const getChartOptions = (categories) => {
|
|
||||||
const courseColor = theme.colors.green[700]
|
|
||||||
const assessmentColor = theme.colors.blue[700]
|
|
||||||
const maxY =
|
|
||||||
students.data?.length % 5
|
|
||||||
? students.data?.length + (5 - (students.data?.length % 5))
|
|
||||||
: students.data?.length
|
|
||||||
|
|
||||||
return {
|
|
||||||
chart: {
|
|
||||||
type: 'bar',
|
|
||||||
toolbar: {
|
|
||||||
show: false,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
plotOptions: {
|
|
||||||
bar: {
|
|
||||||
distributed: true,
|
|
||||||
borderRadius: 3,
|
|
||||||
borderRadiusApplication: 'end',
|
|
||||||
horizontal: true,
|
|
||||||
barHeight: '40%',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
colors: Object.values(categories).map((item) =>
|
|
||||||
item.type === 'course' ? courseColor : assessmentColor
|
|
||||||
),
|
|
||||||
xaxis: {
|
|
||||||
categories: Object.values(categories).map((item) => item.label),
|
|
||||||
labels: {
|
|
||||||
style: {
|
|
||||||
fontSize: '10px',
|
|
||||||
},
|
|
||||||
rotate: 0,
|
|
||||||
formatter: function (value) {
|
|
||||||
return value.length > 30 ? `${value.substring(0, 30)}...` : value
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
yaxis: {
|
|
||||||
max: maxY,
|
|
||||||
min: 0,
|
|
||||||
stepSize: 10,
|
|
||||||
tickAmount: maxY / 5,
|
|
||||||
/* reversed: true */
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
watch(students, () => {
|
watch(students, () => {
|
||||||
@@ -434,14 +346,9 @@ const certificationCount = createResource({
|
|||||||
params: {
|
params: {
|
||||||
doctype: 'LMS Certificate',
|
doctype: 'LMS Certificate',
|
||||||
filters: {
|
filters: {
|
||||||
batch_name: props.batch.name,
|
batch_name: props.batch?.data?.name,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
auto: true,
|
auto: true,
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
<style>
|
|
||||||
.apexcharts-legend {
|
|
||||||
display: none !important;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|||||||
@@ -1,130 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div class="flex flex-col min-h-0">
|
|
||||||
<div class="flex items-center justify-between">
|
|
||||||
<div class="text-xl font-semibold mb-5 text-ink-gray-9">
|
|
||||||
{{ label }}
|
|
||||||
</div>
|
|
||||||
<Button @click="() => showCategoryForm()">
|
|
||||||
<template #icon>
|
|
||||||
<Plus v-if="!showForm" class="h-3 w-3 stroke-1.5" />
|
|
||||||
<X v-else class="h-3 w-3 stroke-1.5" />
|
|
||||||
</template>
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div
|
|
||||||
v-if="showForm"
|
|
||||||
class="flex items-center justify-between my-4 space-x-2"
|
|
||||||
>
|
|
||||||
<FormControl
|
|
||||||
ref="categoryInput"
|
|
||||||
v-model="category"
|
|
||||||
:placeholder="__('Category Name')"
|
|
||||||
class="flex-1"
|
|
||||||
/>
|
|
||||||
<Button @click="addCategory()" variant="subtle">
|
|
||||||
{{ __('Add') }}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="overflow-y-scroll">
|
|
||||||
<div class="text-base divide-y space-y-2">
|
|
||||||
<FormControl
|
|
||||||
:value="cat.category"
|
|
||||||
type="text"
|
|
||||||
v-for="cat in categories.data"
|
|
||||||
class=""
|
|
||||||
@change.stop="(e) => update(cat.name, e.target.value)"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
<script setup>
|
|
||||||
import {
|
|
||||||
Button,
|
|
||||||
FormControl,
|
|
||||||
createListResource,
|
|
||||||
createResource,
|
|
||||||
debounce,
|
|
||||||
} from 'frappe-ui'
|
|
||||||
import { Plus, X } from 'lucide-vue-next'
|
|
||||||
import { ref } from 'vue'
|
|
||||||
|
|
||||||
const showForm = ref(false)
|
|
||||||
const category = ref(null)
|
|
||||||
const categoryInput = ref(null)
|
|
||||||
|
|
||||||
const props = defineProps({
|
|
||||||
label: {
|
|
||||||
type: String,
|
|
||||||
required: true,
|
|
||||||
},
|
|
||||||
description: {
|
|
||||||
type: String,
|
|
||||||
default: '',
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
const categories = createListResource({
|
|
||||||
doctype: 'LMS Category',
|
|
||||||
fields: ['name', 'category'],
|
|
||||||
auto: true,
|
|
||||||
})
|
|
||||||
|
|
||||||
const newCategory = createResource({
|
|
||||||
url: 'frappe.client.insert',
|
|
||||||
makeParams(values) {
|
|
||||||
return {
|
|
||||||
doc: {
|
|
||||||
doctype: 'LMS Category',
|
|
||||||
category: category.value,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
const addCategory = () => {
|
|
||||||
newCategory.submit(
|
|
||||||
{},
|
|
||||||
{
|
|
||||||
onSuccess(data) {
|
|
||||||
categories.reload()
|
|
||||||
category.value = null
|
|
||||||
},
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const showCategoryForm = () => {
|
|
||||||
showForm.value = !showForm.value
|
|
||||||
setTimeout(() => {
|
|
||||||
categoryInput.value.$el.querySelector('input').focus()
|
|
||||||
}, 0)
|
|
||||||
}
|
|
||||||
|
|
||||||
const updateCategory = createResource({
|
|
||||||
url: 'frappe.client.rename_doc',
|
|
||||||
makeParams(values) {
|
|
||||||
return {
|
|
||||||
doctype: 'LMS Category',
|
|
||||||
old_name: values.name,
|
|
||||||
new_name: values.category,
|
|
||||||
}
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
const update = (name, value) => {
|
|
||||||
updateCategory.submit(
|
|
||||||
{
|
|
||||||
name: name,
|
|
||||||
category: value,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
onSuccess() {
|
|
||||||
categories.reload()
|
|
||||||
},
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
@@ -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" />
|
||||||
@@ -27,8 +37,10 @@
|
|||||||
</slot>
|
</slot>
|
||||||
</template>
|
</template>
|
||||||
<template #body="{ isOpen }">
|
<template #body="{ isOpen }">
|
||||||
<div v-show="isOpen">
|
<div v-show="isOpen" class="">
|
||||||
<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"
|
||||||
@@ -92,7 +104,10 @@
|
|||||||
{{ option.label }}
|
{{ option.label }}
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
v-if="option.description"
|
v-if="
|
||||||
|
option.description &&
|
||||||
|
option.description != option.label
|
||||||
|
"
|
||||||
class="text-xs text-ink-gray-7"
|
class="text-xs text-ink-gray-7"
|
||||||
v-html="option.description"
|
v-html="option.description"
|
||||||
></div>
|
></div>
|
||||||
@@ -119,6 +134,7 @@
|
|||||||
</template>
|
</template>
|
||||||
</Popover>
|
</Popover>
|
||||||
</Combobox>
|
</Combobox>
|
||||||
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
@@ -145,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
@@ -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
@@ -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
@@ -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 }" />
|
||||||
@@ -34,7 +35,7 @@
|
|||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
class="w-full !justify-start"
|
class="w-full !justify-start"
|
||||||
label="Create New"
|
:label="__('Create New')"
|
||||||
@click="attrs.onCreate(value, close)"
|
@click="attrs.onCreate(value, close)"
|
||||||
>
|
>
|
||||||
<template #prefix>
|
<template #prefix>
|
||||||
|
|||||||
@@ -4,22 +4,7 @@
|
|||||||
{{ label }}
|
{{ label }}
|
||||||
<span class="text-ink-red-3" v-if="required">*</span>
|
<span class="text-ink-red-3" v-if="required">*</span>
|
||||||
</label>
|
</label>
|
||||||
<div class="grid grid-cols-3 gap-2">
|
<div class="w-full">
|
||||||
<Button
|
|
||||||
ref="emails"
|
|
||||||
v-for="value in values"
|
|
||||||
:key="value"
|
|
||||||
:label="value"
|
|
||||||
theme="gray"
|
|
||||||
variant="subtle"
|
|
||||||
class="rounded-md word-break-all"
|
|
||||||
@keydown.delete.capture.stop="removeLastValue"
|
|
||||||
>
|
|
||||||
<template #suffix>
|
|
||||||
<X @click="removeValue(value)" class="h-4 w-4 stroke-1.5" />
|
|
||||||
</template>
|
|
||||||
</Button>
|
|
||||||
<div class="">
|
|
||||||
<Combobox v-model="selectedValue" nullable>
|
<Combobox v-model="selectedValue" nullable>
|
||||||
<Popover class="w-full" v-model:show="showOptions">
|
<Popover class="w-full" v-model:show="showOptions">
|
||||||
<template #target="{ togglePopover }">
|
<template #target="{ togglePopover }">
|
||||||
@@ -39,13 +24,13 @@
|
|||||||
@keydown.delete.capture.stop="removeLastValue"
|
@keydown.delete.capture.stop="removeLastValue"
|
||||||
/>
|
/>
|
||||||
</template>
|
</template>
|
||||||
<template #body="{ isOpen }">
|
<template #body="{ isOpen, close }">
|
||||||
<div v-show="isOpen">
|
<div v-show="isOpen">
|
||||||
<div
|
<div
|
||||||
class="mt-1 rounded-lg bg-surface-white py-1 text-base border-2"
|
class="mt-1 rounded-lg bg-surface-white py-1 text-base border-2"
|
||||||
>
|
>
|
||||||
<ComboboxOptions
|
<ComboboxOptions
|
||||||
class="my-1 max-h-[12rem] overflow-y-auto px-1.5"
|
class="my-1 min-h-[6rem] max-h-[12rem] overflow-y-auto px-1.5"
|
||||||
static
|
static
|
||||||
>
|
>
|
||||||
<ComboboxOption
|
<ComboboxOption
|
||||||
@@ -70,6 +55,22 @@
|
|||||||
</div>
|
</div>
|
||||||
</li>
|
</li>
|
||||||
</ComboboxOption>
|
</ComboboxOption>
|
||||||
|
<div class="h-10"></div>
|
||||||
|
<div
|
||||||
|
v-if="attrs.onCreate"
|
||||||
|
class="absolute bottom-2 left-1 w-[99%] pt-2 bg-white border-t"
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
class="w-full !justify-start"
|
||||||
|
:label="__('Create New')"
|
||||||
|
@click="attrs.onCreate(close)"
|
||||||
|
>
|
||||||
|
<template #prefix>
|
||||||
|
<Plus class="h-4 w-4 stroke-1.5" />
|
||||||
|
</template>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
</ComboboxOptions>
|
</ComboboxOptions>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -77,6 +78,19 @@
|
|||||||
</Popover>
|
</Popover>
|
||||||
</Combobox>
|
</Combobox>
|
||||||
</div>
|
</div>
|
||||||
|
<div v-if="values.length" class="grid grid-cols-2 gap-2 mt-1">
|
||||||
|
<div
|
||||||
|
v-for="value in values"
|
||||||
|
class="flex items-center justify-between break-all bg-surface-gray-2 text-ink-gray-7 word-wrap p-2 rounded-md mr-2"
|
||||||
|
>
|
||||||
|
<span class="break-all">
|
||||||
|
{{ value }}
|
||||||
|
</span>
|
||||||
|
<X
|
||||||
|
class="size-4 stroke-1.5 cursor-pointer"
|
||||||
|
@click="removeValue(value)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<!-- <ErrorMessage class="mt-2 pl-2" v-if="error" :message="error" /> -->
|
<!-- <ErrorMessage class="mt-2 pl-2" v-if="error" :message="error" /> -->
|
||||||
</div>
|
</div>
|
||||||
@@ -90,9 +104,9 @@ import {
|
|||||||
ComboboxOption,
|
ComboboxOption,
|
||||||
} from '@headlessui/vue'
|
} from '@headlessui/vue'
|
||||||
import { createResource, Popover, Button } from 'frappe-ui'
|
import { createResource, Popover, Button } from 'frappe-ui'
|
||||||
import { ref, computed, nextTick } from 'vue'
|
import { ref, computed, nextTick, useAttrs } from 'vue'
|
||||||
import { watchDebounced } from '@vueuse/core'
|
import { watchDebounced } from '@vueuse/core'
|
||||||
import { X } from 'lucide-vue-next'
|
import { X, Plus } from 'lucide-vue-next'
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
label: {
|
label: {
|
||||||
@@ -124,7 +138,7 @@ const props = defineProps({
|
|||||||
})
|
})
|
||||||
|
|
||||||
const values = defineModel()
|
const values = defineModel()
|
||||||
|
const attrs = useAttrs()
|
||||||
const emails = ref([])
|
const emails = ref([])
|
||||||
const search = ref(null)
|
const search = ref(null)
|
||||||
const error = ref(null)
|
const error = ref(null)
|
||||||
|
|||||||
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,57 @@
|
|||||||
<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 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 border-t border-x rounded-t-md"
|
||||||
: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"
|
||||||
|
class="flex items-center justify-center text-white flex-1 font-extrabold my-auto px-5 text-center leading-6 h-full"
|
||||||
|
:class="
|
||||||
|
course.title.length > 32
|
||||||
|
? 'text-lg'
|
||||||
|
: course.title.length > 20
|
||||||
|
? 'text-xl'
|
||||||
|
: 'text-2xl'
|
||||||
|
"
|
||||||
|
>
|
||||||
|
{{ course.title }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex flex-col flex-auto p-4">
|
<div class="flex flex-col flex-auto p-4 border-x-2 border-b-2 rounded-b-md">
|
||||||
<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 +60,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 +69,27 @@
|
|||||||
|
|
||||||
<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 v-if="course.status != 'Approved'">
|
<Tooltip v-if="course.featured" :text="__('Featured')">
|
||||||
<Badge
|
<Award class="size-4 stroke-2 text-ink-amber-3" />
|
||||||
variant="subtle"
|
</Tooltip>
|
||||||
:theme="course.status === 'Under Review' ? 'orange' : 'blue'"
|
|
||||||
size="sm"
|
|
||||||
>
|
|
||||||
{{ course.status }}
|
|
||||||
</Badge>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="text-xl font-semibold leading-6 text-ink-gray-9">
|
<div
|
||||||
|
v-if="course.image"
|
||||||
|
class="font-semibold leading-6"
|
||||||
|
:class="course.title.length > 32 ? 'text-lg' : 'text-xl'"
|
||||||
|
>
|
||||||
{{ 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 +98,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 +119,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 { Award, 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 +147,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 +178,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 +186,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>
|
||||||
@@ -116,7 +142,7 @@
|
|||||||
v-if="parseInt(course.data.rating) > 0"
|
v-if="parseInt(course.data.rating) > 0"
|
||||||
class="flex items-center text-ink-gray-9"
|
class="flex items-center text-ink-gray-9"
|
||||||
>
|
>
|
||||||
<Star class="h-4 w-4 stroke-1.5 fill-orange-500 text-gray-50" />
|
<Star class="size-4 stroke-1.5 fill-yellow-500 text-transparent" />
|
||||||
<span class="ml-2">
|
<span class="ml-2">
|
||||||
{{ course.data.rating }} {{ __('Rating') }}
|
{{ course.data.rating }} {{ __('Rating') }}
|
||||||
</span>
|
</span>
|
||||||
@@ -142,18 +168,35 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<CourseProgressSummary
|
||||||
|
v-if="user.data?.is_moderator || is_instructor()"
|
||||||
|
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 } from 'frappe-ui'
|
BookText,
|
||||||
import { showToast, formatAmount } from '@/utils/'
|
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 { 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({
|
||||||
@@ -172,31 +215,19 @@ const video_link = computed(() => {
|
|||||||
|
|
||||||
function enrollStudent() {
|
function enrollStudent() {
|
||||||
if (!user.data) {
|
if (!user.data) {
|
||||||
showToast(
|
toast.success(__('You need to login first to enroll for this course'))
|
||||||
__('Please Login'),
|
|
||||||
__('You need to login first to enroll for this course'),
|
|
||||||
'alert-circle'
|
|
||||||
)
|
|
||||||
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(() => {
|
||||||
capture('enrolled_in_course', {
|
capture('enrolled_in_course', {
|
||||||
course: props.course.data.name,
|
course: props.course.data.name,
|
||||||
})
|
})
|
||||||
showToast(
|
toast.success(__('You have been enrolled in this course'))
|
||||||
__('Success'),
|
|
||||||
__('You have been enrolled in this course'),
|
|
||||||
'check'
|
|
||||||
)
|
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
router.push({
|
router.push({
|
||||||
name: 'Lesson',
|
name: 'Lesson',
|
||||||
@@ -206,7 +237,11 @@ function enrollStudent() {
|
|||||||
lessonNumber: 1,
|
lessonNumber: 1,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
}, 2000)
|
}, 1000)
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
toast.warning(__(err.messages?.[0] || err))
|
||||||
|
console.error(err)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -254,4 +289,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"
|
||||||
@@ -147,8 +163,8 @@
|
|||||||
/>
|
/>
|
||||||
</template>
|
</template>
|
||||||
<script setup>
|
<script setup>
|
||||||
import { Button, createResource, Tooltip } 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 {
|
||||||
@@ -162,7 +178,6 @@ import {
|
|||||||
} from 'lucide-vue-next'
|
} from 'lucide-vue-next'
|
||||||
import { useRoute, useRouter } from 'vue-router'
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
import ChapterModal from '@/components/Modals/ChapterModal.vue'
|
import ChapterModal from '@/components/Modals/ChapterModal.vue'
|
||||||
import { showToast } from '@/utils'
|
|
||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
@@ -193,18 +208,38 @@ const props = defineProps({
|
|||||||
type: Boolean,
|
type: Boolean,
|
||||||
default: false,
|
default: false,
|
||||||
},
|
},
|
||||||
|
lessonProgress: {
|
||||||
|
type: Number,
|
||||||
|
default: 0,
|
||||||
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
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()
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.lessonProgress,
|
||||||
|
() => {
|
||||||
|
outline.reload()
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
const deleteLesson = createResource({
|
const deleteLesson = createResource({
|
||||||
url: 'lms.lms.api.delete_lesson',
|
url: 'lms.lms.api.delete_lesson',
|
||||||
makeParams(values) {
|
makeParams(values) {
|
||||||
@@ -215,7 +250,7 @@ const deleteLesson = createResource({
|
|||||||
},
|
},
|
||||||
onSuccess() {
|
onSuccess() {
|
||||||
outline.reload()
|
outline.reload()
|
||||||
showToast('Success', 'Lesson deleted successfully', 'check')
|
toast.success(__('Lesson deleted successfully'))
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -230,7 +265,21 @@ const updateLessonIndex = createResource({
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
onSuccess() {
|
onSuccess() {
|
||||||
showToast('Success', 'Lesson moved successfully', 'check')
|
toast.success(__('Lesson moved successfully'))
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
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'))
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -279,6 +328,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) {
|
||||||
@@ -288,7 +345,7 @@ const deleteChapter = createResource({
|
|||||||
},
|
},
|
||||||
onSuccess() {
|
onSuccess() {
|
||||||
outline.reload()
|
outline.reload()
|
||||||
showToast('Success', 'Chapter deleted successfully', 'check')
|
toast.success(__('Chapter deleted successfully'))
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -317,11 +374,7 @@ const redirectToChapter = (chapter) => {
|
|||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
if (props.allowEdit) return
|
if (props.allowEdit) return
|
||||||
if (!user.data) {
|
if (!user.data) {
|
||||||
showToast(
|
toast.success(__('Please enroll for this course to view this lesson'))
|
||||||
__('You are not enrolled'),
|
|
||||||
__('Please enroll for this course to view this lesson'),
|
|
||||||
'alert-circle'
|
|
||||||
)
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -35,14 +35,14 @@
|
|||||||
<span class="text-ink-gray-7">
|
<span class="text-ink-gray-7">
|
||||||
{{ review.creation }}
|
{{ review.creation }}
|
||||||
</span>
|
</span>
|
||||||
<div class="flex mt-2">
|
<div class="flex mt-2 space-x-1">
|
||||||
<Star
|
<Star
|
||||||
v-for="index in 5"
|
v-for="index in 5"
|
||||||
class="h-5 w-5 text-ink-gray-1 rounded-sm mr-2"
|
class="size-4 text-transparent rounded-sm"
|
||||||
:class="
|
:class="
|
||||||
index <= Math.ceil(review.rating)
|
index <= Math.ceil(review.rating)
|
||||||
? 'fill-orange-500'
|
? 'fill-yellow-500'
|
||||||
: 'fill-gray-600'
|
: 'fill-gray-300'
|
||||||
"
|
"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -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() {
|
||||||
|
|||||||
@@ -1,18 +1,12 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="relative flex h-full flex-col">
|
<div class="flex h-screen w-screen">
|
||||||
<div class="h-full flex-1">
|
<div class="h-full border-r bg-surface-menu-bar">
|
||||||
<div class="flex h-screen text-base bg-surface-white">
|
|
||||||
<div
|
|
||||||
class="relative block min-h-0 flex-shrink-0 overflow-hidden hover:overflow-auto"
|
|
||||||
>
|
|
||||||
<AppSidebar />
|
<AppSidebar />
|
||||||
</div>
|
</div>
|
||||||
<div class="w-full overflow-auto" id="scrollContainer">
|
<div class="flex-1 flex flex-col h-full overflow-auto bg-surface-white">
|
||||||
<slot />
|
<slot />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
</template>
|
||||||
<script setup>
|
<script setup>
|
||||||
import AppSidebar from './AppSidebar.vue'
|
import AppSidebar from './AppSidebar.vue'
|
||||||
|
|||||||
@@ -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)
|
||||||
},
|
},
|
||||||
@@ -93,12 +93,11 @@
|
|||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<script setup>
|
<script setup>
|
||||||
import { createResource, TextEditor, Button, Dropdown } 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'
|
||||||
import { createToast } from '../utils'
|
|
||||||
|
|
||||||
const showTopics = defineModel('showTopics')
|
const showTopics = defineModel('showTopics')
|
||||||
const newReply = ref('')
|
const newReply = ref('')
|
||||||
@@ -192,14 +191,7 @@ const postReply = () => {
|
|||||||
replies.reload()
|
replies.reload()
|
||||||
},
|
},
|
||||||
onError(err) {
|
onError(err) {
|
||||||
createToast({
|
toast.error(err.messages?.[0] || err)
|
||||||
title: 'Error',
|
|
||||||
text: err.messages?.[0] || err,
|
|
||||||
icon: 'x',
|
|
||||||
iconClasses: 'bg-surface-red-5 text-ink-white rounded-md p-px',
|
|
||||||
position: 'top-center',
|
|
||||||
timeout: 10,
|
|
||||||
})
|
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
@@ -259,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>
|
||||||
|
|||||||
24
frontend/src/components/EmptyState.vue
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
<template>
|
||||||
|
<div class="flex flex-col items-center justify-center mt-60">
|
||||||
|
<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 {0}').format(type?.toLowerCase()) }}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
class="leading-5 text-base w-full md:w-2/5 text-base text-center text-ink-gray-7"
|
||||||
|
>
|
||||||
|
{{
|
||||||
|
__(
|
||||||
|
'There are no {0} currently. Keep an eye out, fresh learning experiences are on the way!'
|
||||||
|
).format(type?.toLowerCase())
|
||||||
|
}}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { BookOpen, GraduationCap } from 'lucide-vue-next'
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
type: String,
|
||||||
|
})
|
||||||
|
</script>
|
||||||
@@ -1,129 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div>
|
|
||||||
<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 #icon>
|
|
||||||
<Plus v-if="!showForm" class="h-3 w-3 stroke-1.5" />
|
|
||||||
<X v-else class="h-3 w-3 stroke-1.5" />
|
|
||||||
</template>
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Form to add new member -->
|
|
||||||
<div v-if="showForm" class="flex items-center space-x-2 my-4">
|
|
||||||
<FormControl
|
|
||||||
v-model="email"
|
|
||||||
:placeholder="__('Email')"
|
|
||||||
type="email"
|
|
||||||
class="w-full"
|
|
||||||
/>
|
|
||||||
<Button @click="addEvaluator()" variant="subtle">
|
|
||||||
{{ __('Add') }}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<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>
|
|
||||||
</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>
|
|
||||||
97
frontend/src/components/InstallPrompt.vue
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
<template>
|
||||||
|
<Dialog v-model="showDialog">
|
||||||
|
<template #body-title>
|
||||||
|
<h2 class="text-lg font-bold">{{ __('Install Frappe Learning') }}</h2>
|
||||||
|
</template>
|
||||||
|
<template #body-content>
|
||||||
|
<p>
|
||||||
|
{{
|
||||||
|
__(
|
||||||
|
'Get the app on your device for easy access & a better experience!'
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
</p>
|
||||||
|
</template>
|
||||||
|
<template #actions>
|
||||||
|
<Button variant="solid" class="w-full py-5" @click="install">
|
||||||
|
<template #prefix><FeatherIcon name="download" class="w-4" /></template>
|
||||||
|
{{ __('Install') }}
|
||||||
|
</Button>
|
||||||
|
</template>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
<Popover :show="iosInstallMessage" placement="top">
|
||||||
|
<template #body>
|
||||||
|
<div
|
||||||
|
class="fixed bottom-[4rem] left-1/2 -translate-x-1/2 z-20 w-[90%] flex flex-col gap-3 rounded bg-blue-100 py-5 drop-shadow-xl"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="mb-1 flex flex-row items-center justify-between px-3 text-center"
|
||||||
|
>
|
||||||
|
<span class="text-base font-bold text-gray-900">
|
||||||
|
{{ __('Install Frappe Learning') }}
|
||||||
|
</span>
|
||||||
|
<span class="inline-flex items-baseline">
|
||||||
|
<FeatherIcon
|
||||||
|
name="x"
|
||||||
|
class="ml-auto h-4 w-4 text-gray-700"
|
||||||
|
@click="iosInstallMessage = false"
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="px-3 text-xs text-gray-800">
|
||||||
|
<span class="flex flex-col gap-2">
|
||||||
|
<span>
|
||||||
|
{{
|
||||||
|
__(
|
||||||
|
'Get the app on your iPhone for easy access & a better experience'
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
</span>
|
||||||
|
<span class="inline-flex items-start whitespace-nowrap">
|
||||||
|
<span>{{ __('Tap') }} </span>
|
||||||
|
<FeatherIcon name="share" class="h-4 w-4 text-blue-600" />
|
||||||
|
<span> {{ __("and then 'Add to Home Screen'") }}</span>
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</Popover>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref } from 'vue'
|
||||||
|
import { Button, Dialog, FeatherIcon, Popover } from 'frappe-ui'
|
||||||
|
|
||||||
|
const deferredPrompt = ref(null)
|
||||||
|
const showDialog = ref(false)
|
||||||
|
const iosInstallMessage = ref(false)
|
||||||
|
|
||||||
|
const isIos = () => {
|
||||||
|
const userAgent = window.navigator.userAgent.toLowerCase()
|
||||||
|
return /iphone|ipad|ipod/.test(userAgent)
|
||||||
|
}
|
||||||
|
|
||||||
|
const isInStandaloneMode = () =>
|
||||||
|
'standalone' in window.navigator && window.navigator.standalone
|
||||||
|
|
||||||
|
if (isIos() && !isInStandaloneMode()) iosInstallMessage.value = true
|
||||||
|
|
||||||
|
window.addEventListener('beforeinstallprompt', (e) => {
|
||||||
|
e.preventDefault()
|
||||||
|
deferredPrompt.value = e
|
||||||
|
if (isIos() && !isInStandaloneMode()) iosInstallMessage.value = true
|
||||||
|
else showDialog.value = true
|
||||||
|
})
|
||||||
|
|
||||||
|
window.addEventListener('appinstalled', () => {
|
||||||
|
showDialog.value = false
|
||||||
|
deferredPrompt.value = null
|
||||||
|
})
|
||||||
|
|
||||||
|
const install = () => {
|
||||||
|
deferredPrompt.value.prompt()
|
||||||
|
showDialog.value = false
|
||||||
|
}
|
||||||
|
</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">
|
||||||
@@ -33,6 +33,9 @@
|
|||||||
<Badge>
|
<Badge>
|
||||||
{{ job.type }}
|
{{ job.type }}
|
||||||
</Badge>
|
</Badge>
|
||||||
|
<Badge v-if="job.work_mode">
|
||||||
|
{{ job.work_mode }}
|
||||||
|
</Badge>
|
||||||
<Badge>
|
<Badge>
|
||||||
{{ dayjs(job.creation).fromNow() }}
|
{{ dayjs(job.creation).fromNow() }}
|
||||||
</Badge>
|
</Badge>
|
||||||
|
|||||||
@@ -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,21 @@
|
|||||||
</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-1 sm:grid-cols-2 lg: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 +44,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 +54,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 A') }}
|
||||||
</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 +81,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 +150,8 @@ const liveClasses = createListResource({
|
|||||||
'description',
|
'description',
|
||||||
'time',
|
'time',
|
||||||
'date',
|
'date',
|
||||||
|
'duration',
|
||||||
|
'attendees',
|
||||||
'start_url',
|
'start_url',
|
||||||
'join_url',
|
'join_url',
|
||||||
'owner',
|
'owner',
|
||||||
@@ -120,8 +166,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 {
|
||||||
|
|||||||
@@ -1,16 +1,34 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="flex h-full flex-col">
|
<div class="flex h-full flex-col relative">
|
||||||
<div class="h-full pb-10" id="scrollContainer">
|
<div class="h-full pb-10" id="scrollContainer">
|
||||||
<slot />
|
<slot />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="relative z-20">
|
||||||
|
<!-- Dropdown menu -->
|
||||||
|
<div
|
||||||
|
class="fixed bottom-16 right-2 w-[80%] rounded-md bg-surface-white text-base p-5 space-y-4 shadow-md"
|
||||||
|
v-if="showMenu"
|
||||||
|
ref="menu"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
v-for="link in otherLinks"
|
||||||
|
:key="link.label"
|
||||||
|
class="flex items-center space-x-2 cursor-pointer"
|
||||||
|
@click="handleClick(link)"
|
||||||
|
>
|
||||||
|
<component
|
||||||
|
:is="icons[link.icon]"
|
||||||
|
class="h-4 w-4 stroke-1.5 text-ink-gray-5"
|
||||||
|
/>
|
||||||
|
<div>{{ link.label }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Fixed menu -->
|
||||||
<div
|
<div
|
||||||
v-if="sidebarSettings.data"
|
v-if="sidebarSettings.data"
|
||||||
class="fixed flex items-center justify-around border-t border-outline-gray-2 bottom-0 z-10 w-full bg-surface-white standalone:pb-4"
|
class="fixed bottom-0 left-0 w-full flex items-center justify-around border-t border-outline-gray-2 bg-surface-white standalone:pb-4 z-10"
|
||||||
:style="{
|
|
||||||
gridTemplateColumns: `repeat(${
|
|
||||||
sidebarLinks.length + 1
|
|
||||||
}, minmax(0, 1fr))`,
|
|
||||||
}"
|
|
||||||
>
|
>
|
||||||
<button
|
<button
|
||||||
v-for="tab in sidebarLinks"
|
v-for="tab in sidebarLinks"
|
||||||
@@ -25,60 +43,67 @@
|
|||||||
:class="[isActive(tab) ? 'text-ink-gray-9' : 'text-ink-gray-5']"
|
:class="[isActive(tab) ? 'text-ink-gray-9' : 'text-ink-gray-5']"
|
||||||
/>
|
/>
|
||||||
</button>
|
</button>
|
||||||
<Popover
|
<button @click="toggleMenu">
|
||||||
trigger="hover"
|
|
||||||
popoverClass="bottom-28 mx-2"
|
|
||||||
placement="top-start"
|
|
||||||
>
|
|
||||||
<template #target>
|
|
||||||
<component
|
<component
|
||||||
:is="icons['List']"
|
:is="icons['List']"
|
||||||
class="h-6 w-6 stroke-1.5 text-ink-gray-5"
|
class="h-6 w-6 stroke-1.5 text-ink-gray-5"
|
||||||
/>
|
/>
|
||||||
</template>
|
</button>
|
||||||
<template #body-main>
|
|
||||||
<div class="text-base p-5 space-y-4">
|
|
||||||
<div
|
|
||||||
v-for="link in otherLinks"
|
|
||||||
:key="link.label"
|
|
||||||
class="flex items-center space-x-2"
|
|
||||||
@click="handleClick(link)"
|
|
||||||
>
|
|
||||||
<component
|
|
||||||
:is="icons[link.icon]"
|
|
||||||
class="h-4 w-4 stroke-1.5 text-ink-gray-5"
|
|
||||||
/>
|
|
||||||
<div>
|
|
||||||
{{ link.label }}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</Popover>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { getSidebarLinks } from '../utils'
|
import { getSidebarLinks } from '@/utils'
|
||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
|
import { call } from 'frappe-ui'
|
||||||
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 { Popover } from 'frappe-ui'
|
|
||||||
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())
|
||||||
const otherLinks = ref([])
|
const otherLinks = ref([])
|
||||||
|
const showMenu = ref(false)
|
||||||
|
const menu = ref(null)
|
||||||
|
const isModerator = ref(false)
|
||||||
|
const isInstructor = ref(false)
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
sidebarSettings.reload(
|
sidebarSettings.reload(
|
||||||
{},
|
{},
|
||||||
{
|
{
|
||||||
onSuccess(data) {
|
onSuccess(data) {
|
||||||
|
filterLinksToShow(data)
|
||||||
|
addOtherLinks()
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
const handleOutsideClick = (e) => {
|
||||||
|
if (menu.value && !menu.value.contains(e.target)) {
|
||||||
|
showMenu.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(showMenu, (val) => {
|
||||||
|
if (val) {
|
||||||
|
setTimeout(() => {
|
||||||
|
document.addEventListener('click', handleOutsideClick)
|
||||||
|
}, 0)
|
||||||
|
} else {
|
||||||
|
document.removeEventListener('click', handleOutsideClick)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const filterLinksToShow = (data) => {
|
||||||
Object.keys(data).forEach((key) => {
|
Object.keys(data).forEach((key) => {
|
||||||
if (!parseInt(data[key])) {
|
if (!parseInt(data[key])) {
|
||||||
sidebarLinks.value = sidebarLinks.value.filter(
|
sidebarLinks.value = sidebarLinks.value.filter(
|
||||||
@@ -86,12 +111,7 @@ onMounted(() => {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
addOtherLinks()
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
const addOtherLinks = () => {
|
const addOtherLinks = () => {
|
||||||
if (user) {
|
if (user) {
|
||||||
@@ -117,11 +137,15 @@ const addOtherLinks = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
watch(userResource, () => {
|
watch(userResource, () => {
|
||||||
if (
|
if (userResource.data) {
|
||||||
userResource.data &&
|
isModerator.value = userResource.data.is_moderator
|
||||||
(userResource.data.is_moderator || userResource.data.is_instructor)
|
isInstructor.value = userResource.data.is_instructor
|
||||||
) {
|
addPrograms()
|
||||||
|
if (isModerator.value || isInstructor.value) {
|
||||||
|
addProgrammingExercises()
|
||||||
addQuizzes()
|
addQuizzes()
|
||||||
|
addAssignments()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -133,6 +157,36 @@ const addQuizzes = () => {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const addAssignments = () => {
|
||||||
|
otherLinks.value.push({
|
||||||
|
label: 'Assignments',
|
||||||
|
icon: 'Pencil',
|
||||||
|
to: 'Assignments',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const addPrograms = async () => {
|
||||||
|
let canAddProgram = await checkIfCanAddProgram()
|
||||||
|
if (!canAddProgram) return
|
||||||
|
let activeFor = ['Programs', 'ProgramDetail']
|
||||||
|
let index = 1
|
||||||
|
|
||||||
|
sidebarLinks.value.splice(index, 0, {
|
||||||
|
label: 'Programs',
|
||||||
|
icon: 'Route',
|
||||||
|
to: 'Programs',
|
||||||
|
activeFor: activeFor,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const checkIfCanAddProgram = async () => {
|
||||||
|
if (isModerator.value || isInstructor.value) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
const programs = await call('lms.lms.utils.get_programs')
|
||||||
|
return programs.enrolled.length > 0 || programs.published.length > 0
|
||||||
|
}
|
||||||
|
|
||||||
let isActive = (tab) => {
|
let isActive = (tab) => {
|
||||||
return tab.activeFor?.includes(router.currentRoute.value.name)
|
return tab.activeFor?.includes(router.currentRoute.value.name)
|
||||||
}
|
}
|
||||||
@@ -158,4 +212,8 @@ const isVisible = (tab) => {
|
|||||||
else if (tab.label == 'Log out') return isLoggedIn
|
else if (tab.label == 'Log out') return isLoggedIn
|
||||||
else return true
|
else return true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const toggleMenu = () => {
|
||||||
|
showMenu.value = !showMenu.value
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -25,12 +25,14 @@
|
|||||||
<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>
|
||||||
<div class="mb-4">
|
<div class="mb-4">
|
||||||
<div class="mb-1.5 text-sm text-ink-gray-5">
|
<div class="mb-1.5 text-sm text-ink-gray-5">
|
||||||
{{ __('Announcement') }}
|
{{ __('Announcement') }}
|
||||||
|
<span class="text-ink-red-3">*</span>
|
||||||
</div>
|
</div>
|
||||||
<TextEditor
|
<TextEditor
|
||||||
:fixedMenu="true"
|
:fixedMenu="true"
|
||||||
@@ -43,9 +45,8 @@
|
|||||||
</Dialog>
|
</Dialog>
|
||||||
</template>
|
</template>
|
||||||
<script setup>
|
<script setup>
|
||||||
import { Dialog, Input, TextEditor, createResource } from 'frappe-ui'
|
import { Dialog, Input, TextEditor, createResource, toast } from 'frappe-ui'
|
||||||
import { reactive } from 'vue'
|
import { reactive } from 'vue'
|
||||||
import { showToast } from '@/utils/'
|
|
||||||
|
|
||||||
const show = defineModel()
|
const show = defineModel()
|
||||||
|
|
||||||
@@ -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',
|
||||||
@@ -87,22 +88,24 @@ const makeAnnouncement = (close) => {
|
|||||||
{
|
{
|
||||||
validate() {
|
validate() {
|
||||||
if (!props.students.length) {
|
if (!props.students.length) {
|
||||||
return 'No students in this batch'
|
return __('No students in this batch')
|
||||||
}
|
}
|
||||||
if (!announcement.subject) {
|
if (!announcement.subject) {
|
||||||
return 'Subject is required'
|
return __('Subject is required')
|
||||||
|
}
|
||||||
|
if (!announcement.announcement) {
|
||||||
|
return __('Announcement is required')
|
||||||
|
}
|
||||||
|
if (!announcement.replyTo) {
|
||||||
|
return __('Reply To is required')
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onSuccess() {
|
onSuccess() {
|
||||||
close()
|
close()
|
||||||
showToast(
|
toast.success(__('Announcement has been sent successfully'))
|
||||||
__('Success'),
|
|
||||||
__('Announcement has been sent successfully'),
|
|
||||||
'check'
|
|
||||||
)
|
|
||||||
},
|
},
|
||||||
onError(err) {
|
onError(err) {
|
||||||
showToast(__('Error'), __(err.messages?.[0] || err), 'alert-circle')
|
toast.error(__(err.messages?.[0] || err))
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -25,21 +25,39 @@
|
|||||||
v-model="assessment"
|
v-model="assessment"
|
||||||
:doctype="assessmentType"
|
:doctype="assessmentType"
|
||||||
:label="__('Assessment')"
|
:label="__('Assessment')"
|
||||||
|
:onCreate="
|
||||||
|
(value, close) => {
|
||||||
|
close()
|
||||||
|
if (assessmentType === 'LMS Quiz') {
|
||||||
|
router.push({
|
||||||
|
name: 'QuizForm',
|
||||||
|
params: {
|
||||||
|
quizID: 'new',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
} else if (assessmentType === 'LMS Assignment') {
|
||||||
|
router.push({
|
||||||
|
name: 'Assignments',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
</template>
|
</template>
|
||||||
<script setup>
|
<script setup>
|
||||||
import { Dialog, FormControl, createResource } from 'frappe-ui'
|
import { Dialog, FormControl, createResource, toast } from 'frappe-ui'
|
||||||
import Link from '@/components/Controls/Link.vue'
|
import Link from '@/components/Controls/Link.vue'
|
||||||
import { computed, ref } from 'vue'
|
import { computed, ref } from 'vue'
|
||||||
import { showToast } from '@/utils'
|
import { useRouter } from 'vue-router'
|
||||||
|
|
||||||
const show = defineModel()
|
const show = defineModel()
|
||||||
const assessmentType = ref(null)
|
const assessmentType = ref(null)
|
||||||
const assessment = ref(null)
|
const assessment = ref(null)
|
||||||
const assessments = defineModel('assessments')
|
const assessments = defineModel('assessments')
|
||||||
|
const router = useRouter()
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
batch: {
|
batch: {
|
||||||
@@ -70,7 +88,7 @@ const addAssessment = (close) => {
|
|||||||
{
|
{
|
||||||
onSuccess(data) {
|
onSuccess(data) {
|
||||||
assessments.value.reload()
|
assessments.value.reload()
|
||||||
showToast(__('Success'), __('Assessment added successfully'), 'check')
|
toast.success(__('Assessment added successfully'))
|
||||||
close()
|
close()
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
@@ -81,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>
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
}"
|
}"
|
||||||
>
|
>
|
||||||
<template #body>
|
<template #body>
|
||||||
<div class="p-5 text-base max-h-[75vh] overflow-y-auto">
|
<div class="p-5 text-base">
|
||||||
<div class="text-lg text-ink-gray-9 font-semibold mb-5">
|
<div class="text-lg text-ink-gray-9 font-semibold mb-5">
|
||||||
{{
|
{{
|
||||||
assignmentID === 'new'
|
assignmentID === 'new'
|
||||||
@@ -14,7 +14,7 @@
|
|||||||
: __('Edit Assignment')
|
: __('Edit Assignment')
|
||||||
}}
|
}}
|
||||||
</div>
|
</div>
|
||||||
<div class="space-y-4">
|
<div class="space-y-4 max-h-[75vh] overflow-y-auto">
|
||||||
<FormControl
|
<FormControl
|
||||||
v-model="assignment.title"
|
v-model="assignment.title"
|
||||||
:label="__('Title')"
|
:label="__('Title')"
|
||||||
@@ -37,7 +37,7 @@
|
|||||||
@change="(val) => (assignment.question = val)"
|
@change="(val) => (assignment.question = val)"
|
||||||
:editable="true"
|
:editable="true"
|
||||||
:fixedMenu="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]"
|
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-[18rem] overflow-y-auto"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -64,9 +64,8 @@
|
|||||||
</Dialog>
|
</Dialog>
|
||||||
</template>
|
</template>
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { Button, Dialog, FormControl, TextEditor } from 'frappe-ui'
|
import { Button, Dialog, FormControl, TextEditor, toast } from 'frappe-ui'
|
||||||
import { computed, reactive, watch } from 'vue'
|
import { computed, reactive, watch } from 'vue'
|
||||||
import { showToast } from '@/utils'
|
|
||||||
|
|
||||||
const show = defineModel()
|
const show = defineModel()
|
||||||
const assignments = defineModel<Assignments>('assignments')
|
const assignments = defineModel<Assignments>('assignments')
|
||||||
@@ -123,11 +122,7 @@ const saveAssignment = () => {
|
|||||||
{
|
{
|
||||||
onSuccess() {
|
onSuccess() {
|
||||||
show.value = false
|
show.value = false
|
||||||
showToast(
|
toast.success(__('Assignment created successfully'))
|
||||||
__('Success'),
|
|
||||||
__('Assignment created successfully'),
|
|
||||||
'check'
|
|
||||||
)
|
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
@@ -140,11 +135,7 @@ const saveAssignment = () => {
|
|||||||
{
|
{
|
||||||
onSuccess() {
|
onSuccess() {
|
||||||
show.value = false
|
show.value = false
|
||||||
showToast(
|
toast.success(__('Assignment updated successfully'))
|
||||||
__('Success'),
|
|
||||||
__('Assignment updated successfully'),
|
|
||||||
'check'
|
|
||||||
)
|
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -19,32 +19,43 @@
|
|||||||
v-model="course"
|
v-model="course"
|
||||||
:label="__('Course')"
|
:label="__('Course')"
|
||||||
:required="true"
|
:required="true"
|
||||||
|
:onCreate="
|
||||||
|
(value, close) => {
|
||||||
|
close()
|
||||||
|
router.push({
|
||||||
|
name: 'CourseForm',
|
||||||
|
params: {
|
||||||
|
courseName: 'new',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
"
|
||||||
/>
|
/>
|
||||||
<Link
|
<Link
|
||||||
doctype="Course Evaluator"
|
doctype="Course Evaluator"
|
||||||
v-model="evaluator"
|
v-model="evaluator"
|
||||||
:label="__('Evaluator')"
|
:label="__('Evaluator')"
|
||||||
:onCreate="(value, close) => openSettings(close)"
|
:onCreate="(value, close) => openSettings('Evaluators', close)"
|
||||||
class="mt-4"
|
class="mt-4"
|
||||||
/>
|
/>
|
||||||
</template>
|
</template>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
</template>
|
</template>
|
||||||
<script setup>
|
<script setup>
|
||||||
import { Dialog, createResource } from 'frappe-ui'
|
import { Dialog, createResource, toast } from 'frappe-ui'
|
||||||
import { ref, inject } from 'vue'
|
import { ref, inject } from 'vue'
|
||||||
import Link from '@/components/Controls/Link.vue'
|
import Link from '@/components/Controls/Link.vue'
|
||||||
import { showToast } from '@/utils'
|
|
||||||
import { useOnboarding } from 'frappe-ui/frappe'
|
import { useOnboarding } from 'frappe-ui/frappe'
|
||||||
import { useSettings } from '@/stores/settings'
|
import { openSettings } from '@/utils'
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
|
|
||||||
const show = defineModel()
|
const show = defineModel()
|
||||||
const course = ref(null)
|
const course = ref(null)
|
||||||
const evaluator = ref(null)
|
const evaluator = ref(null)
|
||||||
const user = inject('$user')
|
const user = inject('$user')
|
||||||
const courses = defineModel('courses')
|
const courses = defineModel('courses')
|
||||||
|
const router = useRouter()
|
||||||
const { updateOnboardingStep } = useOnboarding('learning')
|
const { updateOnboardingStep } = useOnboarding('learning')
|
||||||
const settingsStore = useSettings()
|
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
batch: {
|
batch: {
|
||||||
@@ -83,15 +94,9 @@ const addCourse = (close) => {
|
|||||||
evaluator.value = null
|
evaluator.value = null
|
||||||
},
|
},
|
||||||
onError(err) {
|
onError(err) {
|
||||||
showToast('Error', err.message[0] || err, 'x')
|
toast.error(err.messages?.[0] || err)
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const openSettings = (close) => {
|
|
||||||
close()
|
|
||||||
settingsStore.activeTab = 'Evaluators'
|
|
||||||
settingsStore.isSettingsOpen = true
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
|
|||||||