Compare commits
677 Commits
v2.33.0
...
37d408f762
| Author | SHA1 | Date | |
|---|---|---|---|
| 37d408f762 | |||
| 6d7c91ceeb | |||
| 9041101505 | |||
|
|
d3fda0be37 | ||
|
|
41de21201e | ||
|
|
005f85c34f | ||
|
|
6bb6125e81 | ||
|
|
4da2b844e8 | ||
|
|
eba1923b7c | ||
|
|
f28823dbe9 | ||
|
|
5d122bca7d | ||
|
|
ef4321586c | ||
|
|
336511dcd5 | ||
|
|
d73b6f9026 | ||
|
|
e959c0172d | ||
|
|
a15767c14f | ||
|
|
20b1743223 | ||
|
|
a89930fae6 | ||
|
|
60e81a921e | ||
|
|
36f75beea9 | ||
|
|
01a9eab73d | ||
|
|
a4eff5ae38 | ||
|
|
46b5495167 | ||
|
|
7c9ef2a702 | ||
|
|
ee9aed6bbc | ||
|
|
eb4cf6e2db | ||
|
|
c6ad6b495c | ||
|
|
5499a86854 | ||
|
|
627ccd8214 | ||
|
|
e760d59d9f | ||
|
|
e76858121f | ||
|
|
34f1d02803 | ||
|
|
64610050ca | ||
|
|
f5bd52a94d | ||
|
|
ce603cac1e | ||
|
|
3108235521 | ||
|
|
280aaecf76 | ||
|
|
ba0bb1eabc | ||
|
|
73d0755249 | ||
|
|
4d93dcb9b4 | ||
|
|
1fc9b8e279 | ||
|
|
c6d05111cc | ||
|
|
8fa3d8ba4a | ||
|
|
c5317beb3f | ||
|
|
7d82e36790 | ||
|
|
f39867b0e2 | ||
|
|
54cef503ad | ||
|
|
ce51371e62 | ||
|
|
7aabbbd497 | ||
|
|
02b89ea137 | ||
|
|
119a48f3a3 | ||
|
|
3146a0354c | ||
|
|
8e895a9890 | ||
|
|
ac436cbf79 | ||
|
|
4363aa7734 | ||
|
|
a65cb073b5 | ||
|
|
a3b9e4f7b2 | ||
|
|
ebde8a0171 | ||
|
|
684299ac3b | ||
|
|
c449aef7ae | ||
|
|
879a27ed0a | ||
|
|
107e7a4e31 | ||
|
|
fa0325106a | ||
|
|
bbfce9363f | ||
|
|
10a6280b78 | ||
|
|
08e8724b4c | ||
|
|
555c7e4e2d | ||
|
|
3673026a33 | ||
|
|
cd565ec160 | ||
|
|
bdcbae03ef | ||
|
|
296234a093 | ||
|
|
10c0955c6c | ||
|
|
8ba2bfda63 | ||
|
|
cb06cc53c2 | ||
|
|
826828ba30 | ||
|
|
22de38c72b | ||
|
|
0037c01beb | ||
|
|
fb17c666a9 | ||
|
|
2c32fac1f2 | ||
|
|
3f0b00decd | ||
|
|
160c7863f0 | ||
|
|
c4d185f2d6 | ||
|
|
6b13b1231a | ||
|
|
661137d500 | ||
|
|
962dcc1ce9 | ||
|
|
655df62d6c | ||
|
|
d827a10c84 | ||
|
|
25c640fabb | ||
|
|
0cb8d21290 | ||
|
|
7a47591967 | ||
|
|
6931ca27c3 | ||
|
|
d00d2de1cc | ||
|
|
b1be568991 | ||
|
|
28be3891d2 | ||
|
|
27d2297e2b | ||
|
|
7212ddd5c5 | ||
|
|
f4e9ac5bf1 | ||
|
|
8fec484d66 | ||
|
|
bcf781c37b | ||
|
|
d8a8e689d0 | ||
|
|
a844b95de3 | ||
|
|
ece885f973 | ||
|
|
66dd30604b | ||
|
|
d0f0f4905c | ||
|
|
c9cb6702b6 | ||
|
|
1ddb980242 | ||
|
|
94b626a4d2 | ||
|
|
d2a011462d | ||
|
|
4c34926af0 | ||
|
|
ce35cd1009 | ||
|
|
56d072bd06 | ||
|
|
5d336ef669 | ||
|
|
b47c59eac1 | ||
|
|
87285db361 | ||
|
|
84312e498c | ||
|
|
bd763d9462 | ||
|
|
a00e66f786 | ||
|
|
78c7b52088 | ||
|
|
c3a5bee993 | ||
|
|
c2b5b7c3e2 | ||
|
|
3992f00353 | ||
|
|
97d853e0d3 | ||
|
|
f786cec75f | ||
|
|
07cd08b55e | ||
|
|
ca42faf14a | ||
|
|
87f5b68279 | ||
|
|
6b31edb687 | ||
|
|
6a64048bb6 | ||
|
|
6cf069ee6a | ||
|
|
3b74bba6ab | ||
|
|
8689788523 | ||
|
|
1193776d06 | ||
|
|
022514a0a7 | ||
|
|
dc7f8a59ed | ||
|
|
4e5a76a6c1 | ||
|
|
64c4a25ee8 | ||
|
|
47bbdbaa26 | ||
|
|
f8e0c0e19a | ||
|
|
94cdd19224 | ||
|
|
d86d046eb0 | ||
|
|
25ec6b5a3f | ||
|
|
967453a683 | ||
|
|
4c17305c05 | ||
|
|
6092131303 | ||
|
|
35749834d0 | ||
|
|
fe56c7b887 | ||
|
|
2b58a744d2 | ||
|
|
dfb94d05e4 | ||
|
|
987c1790d8 | ||
|
|
0d416b17ce | ||
|
|
473e165c89 | ||
|
|
3d52d15004 | ||
|
|
27278e128c | ||
|
|
13cee3c9b3 | ||
|
|
fd95e42e9b | ||
|
|
65cd2f5d01 | ||
|
|
70759d1888 | ||
|
|
705d6e2f00 | ||
|
|
088591a335 | ||
|
|
3f037e0d17 | ||
|
|
e6884b6c93 | ||
|
|
9943268ca0 | ||
|
|
620e4d20c2 | ||
|
|
fd03033ac6 | ||
|
|
939099b8c8 | ||
|
|
75001b494d | ||
|
|
8749e21744 | ||
|
|
982ac98e27 | ||
|
|
f31bf17a41 | ||
|
|
3425d9118d | ||
|
|
6be49ecdf3 | ||
|
|
ffd6f9578b | ||
|
|
41293130ad | ||
|
|
6cccd28b92 | ||
|
|
384f10a722 | ||
|
|
a603e299f1 | ||
|
|
05822f82da | ||
|
|
0508e718cb | ||
|
|
574913e9e4 | ||
|
|
068adb62a7 | ||
|
|
73fa1f9cfe | ||
|
|
f518882926 | ||
|
|
ed566f9eea | ||
|
|
8ca32e439a | ||
|
|
35b3b11a3c | ||
|
|
57d4a53081 | ||
|
|
6da05961f2 | ||
|
|
7db3b8c5b8 | ||
|
|
50bafb6fa6 | ||
|
|
2b3a9072d1 | ||
|
|
1c08e57086 | ||
|
|
4290ed2f04 | ||
|
|
342512f3e1 | ||
|
|
942c04cb68 | ||
|
|
64bf4ab3f7 | ||
|
|
052e69737e | ||
|
|
02adc4517c | ||
|
|
e36fdd6823 | ||
|
|
d10a7ed57f | ||
|
|
79adf44dfe | ||
|
|
4bc3113f34 | ||
|
|
0826704282 | ||
|
|
52aa5e6954 | ||
|
|
fde85607d9 | ||
|
|
cc087af012 | ||
|
|
2c7da1e32e | ||
|
|
49fe8952ae | ||
|
|
b298cd0509 | ||
|
|
a81fc11e73 | ||
|
|
199fb6229d | ||
|
|
ec6ecee455 | ||
|
|
fa72172b77 | ||
|
|
6789700def | ||
|
|
752744b3a4 | ||
|
|
c24fa85bf4 | ||
|
|
4e0b59f6a9 | ||
|
|
bd20214552 | ||
|
|
4af0ea9e47 | ||
|
|
8651679634 | ||
|
|
99dcac6d12 | ||
|
|
853bf01c9e | ||
|
|
39c5ad7267 | ||
|
|
8daa2948fa | ||
|
|
f2ba25429e | ||
|
|
1fffb4dc67 | ||
|
|
45ce2439fd | ||
|
|
cb2e77e8f6 | ||
|
|
800c0b0336 | ||
|
|
14c23496d5 | ||
|
|
7756a6d593 | ||
|
|
ae7791a204 | ||
|
|
44232c44fc | ||
|
|
142fc99761 | ||
|
|
5e6dc55c76 | ||
|
|
bb2447e821 | ||
|
|
a88d9cd78e | ||
|
|
dab82db693 | ||
|
|
a1183df72c | ||
|
|
5cfa4f173a | ||
|
|
451ef49d98 | ||
|
|
36a8ebdc1b | ||
|
|
27577edb16 | ||
|
|
bd69ab314b | ||
|
|
6abe4ac04a | ||
|
|
1eeb190653 | ||
|
|
23f22d9d9a | ||
|
|
b913f33b3f | ||
|
|
52da1feb91 | ||
|
|
0af9f1bfbf | ||
|
|
3b0e1c3ce7 | ||
|
|
37ea270c56 | ||
|
|
304550dd94 | ||
|
|
69cee24ffe | ||
|
|
8334c06a9b | ||
|
|
3e739f2877 | ||
|
|
5e33ff4a34 | ||
|
|
2b234e5d64 | ||
|
|
c8b328a1c9 | ||
|
|
5e0ac05f90 | ||
|
|
e440097272 | ||
|
|
263c858a66 | ||
|
|
82371fb2a8 | ||
|
|
b6336a4096 | ||
|
|
7acfbbaae7 | ||
|
|
5a76b4eb2d | ||
|
|
8aac88b696 | ||
|
|
d6e71068be | ||
|
|
6b44951ef0 | ||
|
|
5289ebb923 | ||
|
|
263fcda053 | ||
|
|
2e0d26575e | ||
|
|
b9d6670bee | ||
|
|
f20d39a3e7 | ||
|
|
09d948b3a0 | ||
|
|
96941c83f3 | ||
|
|
b8ca0e381a | ||
|
|
4a2c5d77aa | ||
|
|
cf2d29d82e | ||
|
|
fb2ab63550 | ||
|
|
90efc152a8 | ||
|
|
de6ba49409 | ||
|
|
9d4196f15a | ||
|
|
eed7fb970d | ||
|
|
fe67f1ab61 | ||
|
|
78640561f5 | ||
|
|
f72631a262 | ||
|
|
670e5d0202 | ||
|
|
ea59d1158a | ||
|
|
ba23cf9789 | ||
|
|
de585b90ea | ||
|
|
cf40f4e525 | ||
|
|
b273e34ac8 | ||
|
|
1a00d708e1 | ||
|
|
583584d8c5 | ||
|
|
09c087dee7 | ||
|
|
f5cff50674 | ||
|
|
81c48d5182 | ||
|
|
c44414cadb | ||
|
|
85db4be514 | ||
|
|
6526eefaf5 | ||
|
|
bd1fc5d705 | ||
|
|
ca547023b0 | ||
|
|
caf57d355b | ||
|
|
7e6da62480 | ||
|
|
e14e560415 | ||
|
|
0a8ac87cee | ||
|
|
42441aafd6 | ||
|
|
9c3ff958e3 | ||
|
|
efac7af750 | ||
|
|
314935f68e | ||
|
|
1efa857d95 | ||
|
|
a7409b498e | ||
|
|
a9cb0a8c26 | ||
|
|
9333affaf1 | ||
|
|
38a32be503 | ||
|
|
fe5f7daf78 | ||
|
|
3c07c3e1cf | ||
|
|
cb87c75ac0 | ||
|
|
62ead16817 | ||
|
|
6352e4deb1 | ||
|
|
0c4b569be6 | ||
|
|
fe4d7cfb75 | ||
|
|
d3e791b017 | ||
|
|
0849183d26 | ||
|
|
3410af8899 | ||
|
|
81a1e3a4c3 | ||
|
|
c8e18dc445 | ||
|
|
ad21bd6f53 | ||
|
|
781457fce3 | ||
|
|
6662b713f1 | ||
|
|
34c0d16411 | ||
|
|
f7003ecbbe | ||
|
|
134090df5d | ||
|
|
efb4feab2e | ||
|
|
900e959b0b | ||
|
|
946ffeb3ca | ||
|
|
56d32b0674 | ||
|
|
03fc5c084a | ||
|
|
2a32355dd8 | ||
|
|
d80d0e9d9b | ||
|
|
be0388ee6e | ||
|
|
414c41162a | ||
|
|
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 | ||
|
|
39cc83c1b8 | ||
|
|
5dd1bb949c | ||
|
|
b76486e4dc | ||
|
|
2ce2df6390 | ||
|
|
19e5136d64 | ||
|
|
281e155480 | ||
|
|
70b2a11cb7 |
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
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,10 @@
|
||||
exclude: 'node_modules|.git'
|
||||
default_stages: [commit]
|
||||
default_stages: [pre-commit]
|
||||
fail_fast: false
|
||||
|
||||
repos:
|
||||
- repo: https://github.com/pre-commit/pre-commit-hooks
|
||||
rev: v4.3.0
|
||||
rev: v5.0.0
|
||||
hooks:
|
||||
- id: trailing-whitespace
|
||||
files: "lms.*"
|
||||
@@ -16,17 +16,16 @@ repos:
|
||||
- id: check-toml
|
||||
- id: debug-statements
|
||||
|
||||
- repo: https://github.com/asottile/pyupgrade
|
||||
rev: v2.34.0
|
||||
- repo: https://github.com/astral-sh/ruff-pre-commit
|
||||
rev: v0.8.1
|
||||
hooks:
|
||||
- id: pyupgrade
|
||||
args: ['--py310-plus']
|
||||
|
||||
- repo: https://github.com/adityahase/black
|
||||
rev: 9cb0a69f4d0030cdf687eddf314468b39ed54119
|
||||
hooks:
|
||||
- id: black
|
||||
additional_dependencies: ['click==8.0.4']
|
||||
- id: ruff
|
||||
name: "Run ruff import sorter"
|
||||
args: ["--select=I", "--fix"]
|
||||
- id: ruff
|
||||
name: "Run ruff linter"
|
||||
- id: ruff-format
|
||||
name: "Run ruff formatter"
|
||||
|
||||
- repo: https://github.com/pre-commit/mirrors-prettier
|
||||
rev: v2.7.1
|
||||
@@ -44,12 +43,22 @@ repos:
|
||||
lms/public/js/lib/.*
|
||||
)$
|
||||
|
||||
- repo: https://github.com/PyCQA/flake8
|
||||
rev: 5.0.4
|
||||
- repo: https://github.com/pre-commit/mirrors-eslint
|
||||
rev: v8.44.0
|
||||
hooks:
|
||||
- id: flake8
|
||||
additional_dependencies: ['flake8-bugbear',]
|
||||
args: ['--config', '.github/helper/flake8.conf']
|
||||
- id: eslint
|
||||
types_or: [javascript]
|
||||
args: ['--quiet']
|
||||
exclude: |
|
||||
(?x)^(
|
||||
lms/public/dist/.*|
|
||||
cypress/.*|
|
||||
.*node_modules.*|
|
||||
.*boilerplate.*|
|
||||
lms/www/website_script.js|
|
||||
lms/templates/includes/.*|
|
||||
lms/public/js/lib/.*
|
||||
)$
|
||||
|
||||
ci:
|
||||
autoupdate_schedule: weekly
|
||||
|
||||
178
README.md
@@ -1,178 +0,0 @@
|
||||
<div align="center" markdown="1">
|
||||
|
||||
<img src=".github/lms-logo.png" alt="Frappe Learning logo" width="80" height="80"/>
|
||||
<h1>Frappe Learning</h1>
|
||||
|
||||
**Easy to use, open source, Learning Management System**
|
||||
|
||||

|
||||
|
||||
</div>
|
||||
|
||||
|
||||
<div align="center">
|
||||
<img src=".github/hero.png?v=5" alt="Hero Image" width="72%" />
|
||||
</div>
|
||||
<br />
|
||||
<div align="center">
|
||||
<a href="https://frappe.io/learning">Website</a>
|
||||
-
|
||||
<a href="https://docs.frappe.io/learning">Documentation</a>
|
||||
</div>
|
||||
|
||||
## Frappe Learning
|
||||
Frappe Learning is an easy-to-use learning system that helps you bring structure to your content.
|
||||
|
||||
### Motivation
|
||||
In 2021, we were looking for a Learning Management System to launch [Mon.School](https://mon.school) for FOSS United. We checked out Moodle, but it didn’t feel right. The forms were unnecessarily lengthy and the UI was confusing. It shouldn't be this hard to create a course right? So I started making a learning system for Mon.School which soon became a product in itself. The aim is to have a simple platform that anyone can use to launch a course of their own and make knowledge sharing easier.
|
||||
|
||||
### Key Features
|
||||
|
||||
- **Structured Learning**: Design a course with a 3-level hierarchy, where your courses have chapters and you can group your lessons within these chapters. This ensures that the context of the lesson is set by the chapter.
|
||||
|
||||
- **Live Classes**: Group learners into batches based on courses and duration. You can then create Zoom live class for these batches right from the app. Learners get to see the list of live classes they have to take as a part of this batch.
|
||||
|
||||
- **Quizzes and Assignments**: Create quizzes where questions can have single-choice, multiple-choice options, or can be open ended. Instructors can also add assignments which learners can submit as PDF's or Documents.
|
||||
|
||||
- **Getting Certified**: Once a learner has completed the course or batch, you can grant them a certificate. The app provides an inbuilt certificate template. You can use this or else create a template of your own and use that instead.
|
||||
|
||||
<details>
|
||||
<summary>View Screenshots</summary>
|
||||
|
||||
|
||||

|
||||
<div align="center">
|
||||
<sub>
|
||||
Create batches to group your learners
|
||||
</sub>
|
||||
</div>
|
||||
<br>
|
||||
|
||||
|
||||

|
||||
<div align="center">
|
||||
<sub>
|
||||
Evaluate their knowledge by quizzes
|
||||
</sub>
|
||||
</div>
|
||||
<br>
|
||||
|
||||
|
||||

|
||||
<div align="center">
|
||||
<sub>
|
||||
Autenticate their work with certification
|
||||
</sub>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
|
||||
### Under the Hood
|
||||
|
||||
- [**Frappe Framework**](https://github.com/frappe/frappe): A full-stack web application framework.
|
||||
|
||||
- [**Frappe UI**](https://github.com/frappe/frappe-ui): A Vue-based UI library, to provide a modern user interface.
|
||||
|
||||
## Production Setup
|
||||
|
||||
### Managed Hosting
|
||||
|
||||
You can try [Frappe Cloud](https://frappecloud.com), a simple, user-friendly and sophisticated [open-source](https://github.com/frappe/press) platform to host Frappe applications with peace of mind.
|
||||
|
||||
It takes care of installation, setup, upgrades, monitoring, maintenance and support of your Frappe deployments. It is a fully featured developer platform with an ability to manage and control multiple Frappe deployments.
|
||||
|
||||
<div>
|
||||
<a href="https://frappecloud.com/lms/signup" target="_blank">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://frappe.io/files/try-on-fc-white.png">
|
||||
<img src="https://frappe.io/files/try-on-fc-black.png" alt="Try on Frappe Cloud" height="28" />
|
||||
</picture>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
### Self Hosting
|
||||
|
||||
Follow these steps to set up Frappe Learning in production:
|
||||
|
||||
**Step 1**: Download the easy install script
|
||||
|
||||
```bash
|
||||
wget https://frappe.io/easy-install.py
|
||||
```
|
||||
|
||||
**Step 2**: Run the deployment command
|
||||
|
||||
```bash
|
||||
python3 ./easy-install.py deploy \
|
||||
--project=learning_prod_setup \
|
||||
--email=your_email.example.com \
|
||||
--image=ghcr.io/frappe/lms \
|
||||
--version=stable \
|
||||
--app=lms \
|
||||
--sitename subdomain.domain.tld
|
||||
```
|
||||
|
||||
Replace the following parameters with your values:
|
||||
- `your_email.example.com`: Your email address
|
||||
- `subdomain.domain.tld`: Your domain name where Learning will be hosted
|
||||
|
||||
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
|
||||
|
||||
### Docker
|
||||
|
||||
You need Docker, docker-compose and git setup on your machine. Refer [Docker documentation](https://docs.docker.com/). After that, follow below steps:
|
||||
|
||||
**Step 1**: Setup folder and download the required files
|
||||
|
||||
mkdir frappe-learning
|
||||
cd frappe-learning
|
||||
|
||||
# Download the docker-compose file
|
||||
wget -O docker-compose.yml https://raw.githubusercontent.com/frappe/lms/develop/docker/docker-compose.yml
|
||||
|
||||
# Download the setup script
|
||||
wget -O init.sh https://raw.githubusercontent.com/frappe/lms/develop/docker/init.sh
|
||||
|
||||
**Step 2**: Run the container and daemonize it
|
||||
|
||||
docker compose up -d
|
||||
|
||||
**Step 3**: The site [http://lms.localhost:8000/lms](http://lms.localhost:8000/lms) should now be available. The default credentials are:
|
||||
- Username: Administrator
|
||||
- Password: admin
|
||||
|
||||
### Local
|
||||
|
||||
To setup the repository locally follow the steps mentioned below:
|
||||
|
||||
1. Install bench and setup a `frappe-bench` directory by following the [Installation Steps](https://frappeframework.com/docs/user/en/installation)
|
||||
1. Start the server by running `bench start`
|
||||
1. In a separate terminal window, create a new site by running `bench new-site learning.test`
|
||||
1. Map your site to localhost with the command `bench --site learning.test add-to-hosts`
|
||||
1. Get the Learning app. Run `bench get-app https://github.com/frappe/lms`
|
||||
1. Run `bench --site learning.test install-app lms`.
|
||||
1. Now open the URL `http://learning.test:8000/lms` in your browser, you should see the app running
|
||||
|
||||
## Learn and connect
|
||||
|
||||
- [Telegram Public Group](https://t.me/frappelms)
|
||||
- [Discuss Forum](https://discuss.frappe.io/c/lms/70)
|
||||
- [Documentation](https://docs.frappe.io/learning)
|
||||
- [YouTube](https://www.youtube.com/channel/UCn3bV5kx77HsVwtnlCeEi_A)
|
||||
|
||||
<br>
|
||||
<br>
|
||||
<div align="center" style="padding-top: 0.75rem;">
|
||||
<a href="https://frappe.io" target="_blank">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://frappe.io/files/Frappe-white.png">
|
||||
<img src="https://frappe.io/files/Frappe-black.png" alt="Frappe Technologies" height="28"/>
|
||||
</picture>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
@@ -10,11 +10,11 @@ describe("Batch Creation", () => {
|
||||
cy.get("span").contains("Settings").click();
|
||||
|
||||
// Add a new member
|
||||
cy.get('[id^="headlessui-dialog-panel-v-"]')
|
||||
cy.get("[data-dismissable-layer]")
|
||||
.find("span")
|
||||
.contains(/^Members$/)
|
||||
.click();
|
||||
cy.get('[id^="headlessui-dialog-panel-v-"]')
|
||||
cy.get("[data-dismissable-layer]")
|
||||
.find("button")
|
||||
.contains("New")
|
||||
.click();
|
||||
@@ -28,12 +28,12 @@ describe("Batch Creation", () => {
|
||||
cy.get("button").contains("Add").click();
|
||||
|
||||
// Add evaluator
|
||||
cy.get('[id^="headlessui-dialog-panel-v-"]')
|
||||
cy.get("[data-dismissable-layer]")
|
||||
.find("span")
|
||||
.contains(/^Evaluators$/)
|
||||
.click();
|
||||
|
||||
cy.get('[id^="headlessui-dialog-panel-v-"]')
|
||||
cy.get("[data-dismissable-layer]")
|
||||
.find("button")
|
||||
.contains("New")
|
||||
.click();
|
||||
@@ -138,43 +138,31 @@ describe("Batch Creation", () => {
|
||||
.contains("Test Batch Short Description to test the UI")
|
||||
.should("be.visible");
|
||||
cy.get("a").contains("Evaluator").should("be.visible");
|
||||
cy.get("span")
|
||||
cy.get("span:visible")
|
||||
.contains("01 Oct 2030 - 31 Oct 2030")
|
||||
.should("be.visible");
|
||||
cy.get("span").contains("10:00 AM - 11:00 AM").should("be.visible");
|
||||
cy.get("span").contains("IST").should("be.visible");
|
||||
cy.get("div")
|
||||
.contains("10")
|
||||
.should("be.visible")
|
||||
.get("span")
|
||||
.contains("Seats Left")
|
||||
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").contains("Manage Batch").click();
|
||||
cy.get("button:visible").contains("Manage Batch").click();
|
||||
|
||||
/* Add student to batch */
|
||||
cy.get("button").contains("Add").click();
|
||||
cy.get('div[id^="headlessui-dialog-panel-v-"]')
|
||||
.first()
|
||||
.find("button")
|
||||
.eq(1)
|
||||
.click();
|
||||
cy.get('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.get("div")
|
||||
.contains("9")
|
||||
.should("be.visible")
|
||||
.get("span")
|
||||
.contains("Seats Left")
|
||||
.should("be.visible");
|
||||
cy.contains("div:visible", "9 Seats Left").should("be.visible");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -76,7 +76,7 @@ describe("Course Creation", () => {
|
||||
cy.button("Add Chapter").click();
|
||||
|
||||
cy.wait(1000);
|
||||
cy.get("[id^=headlessui-dialog-panel-")
|
||||
cy.get("[data-dismissable-layer]")
|
||||
.should("be.visible")
|
||||
.within(() => {
|
||||
cy.get("label").contains("Title").type("Test Chapter");
|
||||
@@ -98,7 +98,7 @@ describe("Course Creation", () => {
|
||||
|
||||
// View Course
|
||||
cy.wait(1000);
|
||||
cy.visit("/lms");
|
||||
cy.visit("/lms/courses");
|
||||
cy.closeOnboardingModal();
|
||||
|
||||
cy.url().should("include", "/lms/courses");
|
||||
@@ -140,9 +140,10 @@ describe("Course Creation", () => {
|
||||
);
|
||||
|
||||
// Add Discussion
|
||||
cy.get("span").contains("Community").click();
|
||||
cy.button("New Question").click();
|
||||
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("div[contenteditable=true]").invoke(
|
||||
"text",
|
||||
|
||||
1
frontend/.gitignore
vendored
@@ -2,4 +2,5 @@ node_modules
|
||||
.DS_Store
|
||||
dist
|
||||
dist-ssr
|
||||
dev-dist
|
||||
*.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 {
|
||||
|
||||
}
|
||||
9
frontend/components.d.ts
vendored
@@ -41,6 +41,7 @@ declare module 'vue' {
|
||||
CodeEditor: typeof import('./src/components/Controls/CodeEditor.vue')['default']
|
||||
CollapseSidebar: typeof import('./src/components/Icons/CollapseSidebar.vue')['default']
|
||||
ColorSwatches: typeof import('./src/components/Controls/ColorSwatches.vue')['default']
|
||||
ContactUsEmail: typeof import('./src/components/ContactUsEmail.vue')['default']
|
||||
CourseCard: typeof import('./src/components/CourseCard.vue')['default']
|
||||
CourseCardOverlay: typeof import('./src/components/CourseCardOverlay.vue')['default']
|
||||
CourseInstructors: typeof import('./src/components/CourseInstructors.vue')['default']
|
||||
@@ -66,6 +67,8 @@ declare module 'vue' {
|
||||
FrappeCloudIcon: typeof import('./src/components/Icons/FrappeCloudIcon.vue')['default']
|
||||
IconPicker: typeof import('./src/components/Controls/IconPicker.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']
|
||||
JobApplicationModal: typeof import('./src/components/Modals/JobApplicationModal.vue')['default']
|
||||
JobCard: typeof import('./src/components/JobCard.vue')['default']
|
||||
@@ -81,9 +84,11 @@ declare module 'vue' {
|
||||
MultiSelect: typeof import('./src/components/Controls/MultiSelect.vue')['default']
|
||||
NoPermission: typeof import('./src/components/NoPermission.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']
|
||||
PageModal: typeof import('./src/components/Modals/PageModal.vue')['default']
|
||||
PaymentSettings: typeof import('./src/components/Settings/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']
|
||||
ProgressBar: typeof import('./src/components/ProgressBar.vue')['default']
|
||||
Question: typeof import('./src/components/Modals/Question.vue')['default']
|
||||
@@ -102,6 +107,8 @@ declare module 'vue' {
|
||||
StudentHeatmap: typeof import('./src/components/StudentHeatmap.vue')['default']
|
||||
StudentModal: typeof import('./src/components/Modals/StudentModal.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']
|
||||
UpcomingEvaluations: typeof import('./src/components/UpcomingEvaluations.vue')['default']
|
||||
Uploader: typeof import('./src/components/Controls/Uploader.vue')['default']
|
||||
|
||||
@@ -3,6 +3,203 @@
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<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" />
|
||||
<title>{{ title }}</title>
|
||||
<meta name="title" content="{{ meta.title }}" />
|
||||
@@ -16,7 +213,7 @@
|
||||
<meta name="twitter:image" content="{{ meta.image }}" />
|
||||
<meta name="twitter:description" content="{{ meta.description }}" />
|
||||
</head>
|
||||
<body>
|
||||
<body class="sm:overscroll-y-none no-scrollbar">
|
||||
<div id="app">
|
||||
<div id="seo-content">
|
||||
<h1>{{ meta.title }}</h1>
|
||||
|
||||
@@ -30,8 +30,9 @@
|
||||
"chart.js": "^4.4.1",
|
||||
"codemirror": "^6.0.1",
|
||||
"dayjs": "^1.11.6",
|
||||
"dompurify": "^3.2.6",
|
||||
"feather-icons": "^4.28.0",
|
||||
"frappe-ui": "^0.1.172",
|
||||
"frappe-ui": "^0.1.201",
|
||||
"highlight.js": "^11.11.1",
|
||||
"lucide-vue-next": "^0.383.0",
|
||||
"markdown-it": "^14.0.0",
|
||||
@@ -53,6 +54,7 @@
|
||||
"@vitejs/plugin-vue": "^5.0.3",
|
||||
"autoprefixer": "^10.4.2",
|
||||
"postcss": "^8.4.5",
|
||||
"vite": "^5.0.11"
|
||||
"vite": "^5.0.11",
|
||||
"vite-plugin-pwa": "^1.0.2"
|
||||
}
|
||||
}
|
||||
|
||||
BIN
frontend/public/leaderboard/bronze-cup.png
Normal file
|
After Width: | Height: | Size: 30 KiB |
42
frontend/public/leaderboard/dart-board.svg
Normal file
@@ -0,0 +1,42 @@
|
||||
<?xml version="1.0" encoding="iso-8859-1"?>
|
||||
<!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
|
||||
<svg height="800px" width="800px" version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"
|
||||
viewBox="0 0 512.001 512.001" xml:space="preserve">
|
||||
<g>
|
||||
<path style="fill:#A67C52;" d="M84.096,436.178l-49.312,37.686c-7.54,5.762-8.981,16.547-3.219,24.087
|
||||
c3.383,4.425,8.494,6.751,13.666,6.751c3.638,0,7.306-1.151,10.421-3.532l49.312-37.686c7.54-5.762,8.981-16.547,3.219-24.087
|
||||
C102.421,431.858,91.637,430.416,84.096,436.178z"/>
|
||||
<path style="fill:#A67C52;" d="M441.194,473.864l-49.312-37.686c-7.541-5.762-18.325-4.32-24.087,3.219
|
||||
c-5.762,7.541-4.321,18.325,3.219,24.087l49.312,37.686c3.115,2.38,6.782,3.532,10.421,3.532c5.171,0,10.284-2.326,13.665-6.751
|
||||
C450.175,490.411,448.734,479.627,441.194,473.864z"/>
|
||||
</g>
|
||||
<path style="fill:#DBAD75;" d="M237.989,36.024c-131.227,0-237.989,106.761-237.989,237.989s106.761,237.989,237.989,237.989
|
||||
S475.978,405.24,475.978,274.012S369.216,36.024,237.989,36.024z"/>
|
||||
<path style="fill:#EABD81;" d="M237.989,36.024c-131.227,0-237.989,106.761-237.989,237.989s106.761,237.989,237.989,237.989V36.024
|
||||
z"/>
|
||||
<path style="fill:#BC2A46;" d="M237.989,80.411c-106.752,0-193.601,86.849-193.601,193.601s86.849,193.601,193.601,193.601
|
||||
s193.601-86.849,193.601-193.601S344.742,80.411,237.989,80.411z"/>
|
||||
<path style="fill:#D62D46;" d="M237.989,80.411c-106.752,0-193.601,86.849-193.601,193.601s86.849,193.601,193.601,193.601V80.411z"
|
||||
/>
|
||||
<path style="fill:#DBAD75;" d="M237.989,142.771c-72.367,0-131.241,58.874-131.241,131.241s58.874,131.241,131.241,131.241
|
||||
S369.23,346.379,369.23,274.012S310.355,142.771,237.989,142.771z"/>
|
||||
<path style="fill:#EABD81;" d="M237.989,142.771c-72.367,0-131.241,58.874-131.241,131.241s58.874,131.241,131.241,131.241V142.771z
|
||||
"/>
|
||||
<path style="fill:#BC2A46;" d="M237.989,209.763c-35.427,0-64.248,28.821-64.248,64.248s28.821,64.248,64.248,64.248
|
||||
s64.248-28.821,64.248-64.248S273.416,209.763,237.989,209.763z"/>
|
||||
<path style="fill:#D62D46;" d="M237.989,209.763c-35.427,0-64.248,28.821-64.248,64.248s28.821,64.248,64.248,64.248V209.763z"/>
|
||||
<path style="fill:#CFCDD6;" d="M237.989,291.196c-4.398,0-8.796-1.677-12.15-5.034c-6.711-6.711-6.711-17.59,0-24.301
|
||||
L448.687,39.014c6.71-6.711,17.59-6.711,24.301,0s6.711,17.59,0,24.301L250.14,286.162
|
||||
C246.784,289.519,242.386,291.196,237.989,291.196z"/>
|
||||
<path style="fill:#DEE1E7;" d="M237.989,291.196c-4.398,0-8.796-1.677-12.15-5.034c-6.711-6.711-6.711-17.59,0-24.301
|
||||
l106.576-106.576l24.301,24.301L250.14,286.162C246.784,289.519,242.386,291.196,237.989,291.196z"/>
|
||||
<path style="fill:#39B7B6;" d="M457.533,105.266h-33.615c-9.49,0-17.184-7.694-17.184-17.184V54.467
|
||||
c0-9.49,7.694-17.184,17.184-17.184s17.184,7.694,17.184,17.184v16.432h16.431c9.49,0,17.184,7.694,17.184,17.184
|
||||
S467.023,105.266,457.533,105.266z"/>
|
||||
<path style="fill:#FBB03B;" d="M476.175,86.623h-33.614c-9.49,0-17.184-7.694-17.184-17.184V35.825
|
||||
c0-9.49,7.694-17.184,17.184-17.184s17.184,7.694,17.184,17.184v16.431h16.431c9.49,0,17.184,7.694,17.184,17.184
|
||||
S485.665,86.623,476.175,86.623z"/>
|
||||
<path style="fill:#39B7B6;" d="M494.817,67.982h-33.614c-9.49,0-17.184-7.694-17.184-17.184V17.184
|
||||
c0-9.49,7.694-17.184,17.184-17.184s17.184,7.694,17.184,17.184v16.431h16.431c9.49,0,17.184,7.694,17.184,17.184
|
||||
S504.308,67.982,494.817,67.982z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 3.3 KiB |
BIN
frontend/public/leaderboard/gold-cup.png
Normal file
|
After Width: | Height: | Size: 26 KiB |
BIN
frontend/public/leaderboard/silver-cup.png
Normal file
|
After Width: | Height: | Size: 29 KiB |
2
frontend/public/leaderboard/star.svg
Normal file
@@ -0,0 +1,2 @@
|
||||
<?xml version="1.0" encoding="utf-8"?><!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
|
||||
<svg width="800px" height="800px" viewBox="0 0 128 128" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--noto" preserveAspectRatio="xMidYMid meet"><path d="M68.05 7.23l13.46 30.7a7.047 7.047 0 0 0 5.82 4.19l32.79 2.94c3.71.54 5.19 5.09 2.5 7.71l-24.7 20.75c-2 1.68-2.91 4.32-2.36 6.87l7.18 33.61c.63 3.69-3.24 6.51-6.56 4.76L67.56 102a7.033 7.033 0 0 0-7.12 0l-28.62 16.75c-3.31 1.74-7.19-1.07-6.56-4.76l7.18-33.61c.54-2.55-.36-5.19-2.36-6.87L5.37 52.78c-2.68-2.61-1.2-7.17 2.5-7.71l32.79-2.94a7.047 7.047 0 0 0 5.82-4.19l13.46-30.7c1.67-3.36 6.45-3.36 8.11-.01z" fill="#fdd835"></path><path d="M67.07 39.77l-2.28-22.62c-.09-1.26-.35-3.42 1.67-3.42c1.6 0 2.47 3.33 2.47 3.33l6.84 18.16c2.58 6.91 1.52 9.28-.97 10.68c-2.86 1.6-7.08.35-7.73-6.13z" fill="#ffff8d"></path><path d="M95.28 71.51L114.9 56.2c.97-.81 2.72-2.1 1.32-3.57c-1.11-1.16-4.11.51-4.11.51l-17.17 6.71c-5.12 1.77-8.52 4.39-8.82 7.69c-.39 4.4 3.56 7.79 9.16 3.97z" fill="#f4b400"></path></svg>
|
||||
|
After Width: | Height: | Size: 1.1 KiB |
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,10 +1,9 @@
|
||||
<template>
|
||||
<FrappeUIProvider>
|
||||
<Layout>
|
||||
<div class="text-base">
|
||||
<router-view />
|
||||
</div>
|
||||
<Layout class="isolate text-base">
|
||||
<router-view />
|
||||
</Layout>
|
||||
<!--<InstallPrompt v-if="isMobile" />-->
|
||||
<Dialogs />
|
||||
</FrappeUIProvider>
|
||||
</template>
|
||||
@@ -13,14 +12,15 @@ import { FrappeUIProvider } from 'frappe-ui'
|
||||
import { Dialogs } from '@/utils/dialogs'
|
||||
import { computed, onUnmounted, ref, watch } from 'vue'
|
||||
import { useScreenSize } from './utils/composables'
|
||||
import DesktopLayout from './components/DesktopLayout.vue'
|
||||
import MobileLayout from './components/MobileLayout.vue'
|
||||
import NoSidebarLayout from './components/NoSidebarLayout.vue'
|
||||
import { usersStore } from '@/stores/user'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { posthogSettings } from '@/telemetry'
|
||||
import DesktopLayout from './components/DesktopLayout.vue'
|
||||
import MobileLayout from './components/MobileLayout.vue'
|
||||
import NoSidebarLayout from './components/NoSidebarLayout.vue'
|
||||
import InstallPrompt from './components/InstallPrompt.vue'
|
||||
|
||||
const screenSize = useScreenSize()
|
||||
const { isMobile } = useScreenSize()
|
||||
const router = useRouter()
|
||||
const noSidebar = ref(false)
|
||||
const { userResource } = usersStore()
|
||||
@@ -38,10 +38,9 @@ const Layout = computed(() => {
|
||||
if (noSidebar.value) {
|
||||
return NoSidebarLayout
|
||||
}
|
||||
if (screenSize.width < 640) {
|
||||
if (isMobile.value) {
|
||||
return MobileLayout
|
||||
}
|
||||
|
||||
return DesktopLayout
|
||||
})
|
||||
|
||||
|
||||
@@ -9,12 +9,12 @@
|
||||
>
|
||||
<UserDropdown :isCollapsed="sidebarStore.isSidebarCollapsed" />
|
||||
<div class="flex flex-col" v-if="sidebarSettings.data">
|
||||
<SidebarLink
|
||||
v-for="link in sidebarLinks"
|
||||
:link="link"
|
||||
:isCollapsed="sidebarStore.isSidebarCollapsed"
|
||||
class="mx-2 my-0.5"
|
||||
/>
|
||||
<div v-for="link in sidebarLinks" class="mx-2 my-0.5">
|
||||
<SidebarLink
|
||||
:link="link"
|
||||
:isCollapsed="sidebarStore.isSidebarCollapsed"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-if="sidebarSettings.data?.web_pages?.length || isModerator"
|
||||
@@ -54,15 +54,18 @@
|
||||
class="flex flex-col transition-all duration-300 ease-in-out"
|
||||
:class="!sidebarStore.isWebpagesCollapsed ? 'block' : 'hidden'"
|
||||
>
|
||||
<SidebarLink
|
||||
<div
|
||||
v-for="link in sidebarSettings.data.web_pages"
|
||||
:link="link"
|
||||
:isCollapsed="sidebarStore.isSidebarCollapsed"
|
||||
class="mx-2 my-0.5"
|
||||
:showControls="isModerator ? true : false"
|
||||
@openModal="openPageModal"
|
||||
@deletePage="deletePage"
|
||||
/>
|
||||
>
|
||||
<SidebarLink
|
||||
:link="link"
|
||||
:isCollapsed="sidebarStore.isSidebarCollapsed"
|
||||
:showControls="isModerator ? true : false"
|
||||
@openModal="openPageModal"
|
||||
@deletePage="deletePage"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -196,7 +199,7 @@ import { usersStore } from '@/stores/user'
|
||||
import { sessionStore } from '@/stores/session'
|
||||
import { useSidebar } from '@/stores/sidebar'
|
||||
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 { capture } from '@/telemetry'
|
||||
import LMSLogo from '@/components/Icons/LMSLogo.vue'
|
||||
@@ -214,6 +217,7 @@ import {
|
||||
Users,
|
||||
BookText,
|
||||
Zap,
|
||||
Check,
|
||||
} from 'lucide-vue-next'
|
||||
import {
|
||||
TrialBanner,
|
||||
@@ -360,37 +364,49 @@ const addProgrammingExercises = () => {
|
||||
}
|
||||
}
|
||||
|
||||
const addPrograms = () => {
|
||||
let activeFor = ['Programs', 'ProgramForm']
|
||||
let index = 1
|
||||
let canAddProgram = false
|
||||
const addPrograms = async () => {
|
||||
let canAddProgram = await checkIfCanAddProgram()
|
||||
if (!canAddProgram) return
|
||||
let activeFor = ['Programs', 'ProgramDetail']
|
||||
let index = 2
|
||||
|
||||
if (
|
||||
!isInstructor.value &&
|
||||
!isModerator.value &&
|
||||
settingsStore.learningPaths.data
|
||||
) {
|
||||
sidebarLinks.value = sidebarLinks.value.filter(
|
||||
(link) => link.label !== 'Courses'
|
||||
)
|
||||
activeFor.push('CourseDetail')
|
||||
activeFor.push('Lesson')
|
||||
index = 0
|
||||
canAddProgram = true
|
||||
} else if (isInstructor.value || isModerator.value) {
|
||||
canAddProgram = true
|
||||
}
|
||||
sidebarLinks.value.splice(index, 0, {
|
||||
label: 'Programs',
|
||||
icon: 'Route',
|
||||
to: 'Programs',
|
||||
activeFor: activeFor,
|
||||
})
|
||||
}
|
||||
|
||||
if (canAddProgram) {
|
||||
sidebarLinks.value.splice(index, 0, {
|
||||
label: 'Programs',
|
||||
icon: 'Route',
|
||||
to: 'Programs',
|
||||
activeFor: activeFor,
|
||||
const addContactUsDetails = () => {
|
||||
if (settingsStore.contactUsEmail?.data || settingsStore.contactUsURL?.data) {
|
||||
sidebarLinks.value.push({
|
||||
label: 'Contact Us',
|
||||
icon: settingsStore.contactUsURL?.data ? 'Headset' : 'Mail',
|
||||
to: settingsStore.contactUsURL?.data
|
||||
? settingsStore.contactUsURL.data
|
||||
: settingsStore.contactUsEmail?.data,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
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) => {
|
||||
showPageModal.value = true
|
||||
pageToEdit.value = link
|
||||
@@ -593,6 +609,11 @@ const articles = ref([
|
||||
{ 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'),
|
||||
opened: false,
|
||||
@@ -638,15 +659,121 @@ const setUpOnboarding = () => {
|
||||
}
|
||||
}
|
||||
|
||||
const addMyPoints = () => {
|
||||
const roles = userResource.data?.roles || []
|
||||
if (roles.includes('LMS Student') || roles.includes('LMS Schoolchild')) {
|
||||
sidebarLinks.value.push({
|
||||
label: __('My points'),
|
||||
icon: 'Award',
|
||||
to: 'MyPoints',
|
||||
activeFor: [],
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const addLeaderBoard = () => {
|
||||
if (user) {
|
||||
sidebarLinks.value.push({
|
||||
label: __('Leader Board'),
|
||||
icon: 'Trophy',
|
||||
to: 'LeaderBoard',
|
||||
activeFor: [],
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const addChatGPT = () => {
|
||||
const roles = userResource.data?.roles || []
|
||||
let URL = ''
|
||||
let nameLabel = ''
|
||||
if (roles.includes('LMS Schoolchild') || roles.includes('LMS Student') || roles.includes('Course Creator')) {
|
||||
if (roles.includes('LMS Schoolchild')) {
|
||||
URL = 'chatgpt-schoolchild'
|
||||
nameLabel = __('ChatGPT for Schoolers')
|
||||
} else if (roles.includes('LMS Student')) {
|
||||
URL = 'chatgpt-schoolchild'
|
||||
nameLabel = __('ChatGPT for Students')
|
||||
} else if (roles.includes('Course Creator')) {
|
||||
URL = 'ai-teachers'
|
||||
nameLabel = __('ChatGPT for Teachers')
|
||||
}
|
||||
|
||||
sidebarLinks.value.push({
|
||||
label: nameLabel,
|
||||
icon: 'Cpu',
|
||||
to: URL,
|
||||
external: true,
|
||||
activeFor: [],
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const addMyChild = () => {
|
||||
const roles = userResource.data?.roles || []
|
||||
if (roles.includes('Parent')) {
|
||||
sidebarLinks.value.push({
|
||||
label: __('My Child'),
|
||||
icon: 'User',
|
||||
to: 'my-child',
|
||||
activeFor: [],
|
||||
external: true,
|
||||
})
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
//test of new page
|
||||
const addProfile = () => {
|
||||
const roles = userResource.data?.roles || []
|
||||
if (roles.includes('LMS Student')) {
|
||||
sidebarLinks.value.push({
|
||||
label: __('Student Profile'),
|
||||
icon: 'Home',
|
||||
to: 'StudentProfile',
|
||||
activeFor: [],
|
||||
})
|
||||
} else if (roles.includes('LMS Schoolchild')) {
|
||||
sidebarLinks.value.push({
|
||||
label: __('Schoolchildren Profile'),
|
||||
icon: 'Home',
|
||||
to: 'SchoolchildrenProfile',
|
||||
activeFor: [],
|
||||
})
|
||||
} else if (roles.includes('Course Creator')) {
|
||||
sidebarLinks.value.push({
|
||||
label: __('Course Creator Profile'),
|
||||
icon: 'Home',
|
||||
to: 'CourseCreatorProfile',
|
||||
activeFor: [],
|
||||
})
|
||||
} else {
|
||||
sidebarLinks.value.push({
|
||||
label: __('Parent Profile'),
|
||||
icon: 'Home',
|
||||
to: 'ParentProfile',
|
||||
activeFor: [],
|
||||
})
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
watch(userResource, () => {
|
||||
addContactUsDetails()
|
||||
if (userResource.data) {
|
||||
isModerator.value = userResource.data.is_moderator
|
||||
isInstructor.value = userResource.data.is_instructor
|
||||
addHome()
|
||||
addPrograms()
|
||||
addProgrammingExercises()
|
||||
addQuizzes()
|
||||
addAssignments()
|
||||
setUpOnboarding()
|
||||
|
||||
addMyPoints()
|
||||
addLeaderBoard()
|
||||
addChatGPT()
|
||||
addMyChild()
|
||||
addProfile()
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -70,6 +70,9 @@
|
||||
<FileUploader
|
||||
v-if="!submissionFile"
|
||||
:fileTypes="getType()"
|
||||
:uploadArgs="{
|
||||
private: true,
|
||||
}"
|
||||
:validateFile="validateFile"
|
||||
@success="(file) => saveSubmission(file)"
|
||||
>
|
||||
@@ -127,6 +130,9 @@
|
||||
@change="(val) => (answer = val)"
|
||||
:editable="true"
|
||||
:fixedMenu="true"
|
||||
:uploadArgs="{
|
||||
private: 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]"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -56,7 +56,7 @@
|
||||
</div>
|
||||
<div v-if="!readOnlyMode">
|
||||
<router-link
|
||||
v-if="isModerator || isStudent"
|
||||
v-if="canAccessBatch"
|
||||
:to="{
|
||||
name: 'Batch',
|
||||
params: {
|
||||
@@ -66,11 +66,11 @@
|
||||
>
|
||||
<Button variant="solid" class="w-full mt-4">
|
||||
<template #prefix>
|
||||
<Settings v-if="isModerator" class="size-4 stroke-1.5" />
|
||||
<LogIn v-else class="size-4 stroke-1.5" />
|
||||
<LogIn v-if="isStudent" class="size-4 stroke-1.5" />
|
||||
<Settings v-else class="size-4 stroke-1.5" />
|
||||
</template>
|
||||
<span>
|
||||
{{ isModerator ? __('Manage Batch') : __('Visit Batch') }}
|
||||
{{ isStudent ? __('Visit Batch') : __('Manage Batch') }}
|
||||
</span>
|
||||
</Button>
|
||||
</router-link>
|
||||
@@ -204,4 +204,12 @@ const isStudent = computed(() => {
|
||||
const isModerator = computed(() => {
|
||||
return user.data?.is_moderator
|
||||
})
|
||||
|
||||
const isEvaluator = computed(() => {
|
||||
return user.data?.is_evaluator
|
||||
})
|
||||
|
||||
const canAccessBatch = computed(() => {
|
||||
return isModerator.value || isStudent.value || isEvaluator.value
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
{{ __('Statistics') }}
|
||||
</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">
|
||||
<NumberChart
|
||||
class="border rounded-md"
|
||||
:config="{ title: __('Students'), value: students.data?.length || 0 }"
|
||||
|
||||
67
frontend/src/components/ContactUsEmail.vue
Normal file
@@ -0,0 +1,67 @@
|
||||
<template>
|
||||
<Dialog
|
||||
v-model="show"
|
||||
:options="{
|
||||
title: __('Contact Us'),
|
||||
size: 'md',
|
||||
}"
|
||||
>
|
||||
<template #body-content>
|
||||
<div class="flex flex-col gap-4">
|
||||
<FormControl
|
||||
v-model="subject"
|
||||
:label="__('Subject')"
|
||||
type="text"
|
||||
:required="true"
|
||||
/>
|
||||
<div>
|
||||
<div class="mb-1.5 text-sm text-ink-gray-5">
|
||||
{{ __('Message') }}
|
||||
<span class="text-ink-red-3">*</span>
|
||||
</div>
|
||||
<TextEditor
|
||||
:fixedMenu="true"
|
||||
@change="(val) => (message = val)"
|
||||
editorClass="prose-sm py-2 px-2 min-h-[200px] border-outline-gray-2 hover:border-outline-gray-3 rounded-b-md bg-surface-gray-3"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<template #actions="{ close }">
|
||||
<div class="pb-5 float-right">
|
||||
<Button variant="solid" @click="sendMail(close)">
|
||||
{{ __('Send') }}
|
||||
</Button>
|
||||
</div>
|
||||
</template>
|
||||
</Dialog>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import { Button, call, Dialog, FormControl, TextEditor, toast } from 'frappe-ui'
|
||||
import { ref } from 'vue'
|
||||
import { useSettings } from '@/stores/settings'
|
||||
|
||||
const show = defineModel<boolean>({ required: true, default: false })
|
||||
const subject = ref('')
|
||||
const message = ref('')
|
||||
const settingsStore = useSettings()
|
||||
|
||||
const sendMail = (close: Function) => {
|
||||
call('frappe.core.doctype.communication.email.make', {
|
||||
recipients: settingsStore.contactUsEmail?.data,
|
||||
subject: subject.value,
|
||||
content: message.value,
|
||||
send_email: true,
|
||||
})
|
||||
.then(() => {
|
||||
toast.success(__('Email sent successfully'))
|
||||
close()
|
||||
subject.value = ''
|
||||
message.value = ''
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error(__('Failed to send email'))
|
||||
close()
|
||||
})
|
||||
}
|
||||
</script>
|
||||
@@ -37,7 +37,7 @@
|
||||
</slot>
|
||||
</template>
|
||||
<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"
|
||||
>
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
<template>
|
||||
<div
|
||||
v-if="course.title"
|
||||
class="flex flex-col h-full rounded-md border-2 overflow-auto text-ink-gray-9"
|
||||
class="flex flex-col h-full rounded-md overflow-auto text-ink-gray-9"
|
||||
style="min-height: 350px"
|
||||
>
|
||||
<div
|
||||
class="w-[100%] h-[168px] bg-cover bg-center bg-no-repeat"
|
||||
class="w-[100%] h-[168px] bg-cover bg-center bg-no-repeat border-t border-x rounded-t-md"
|
||||
:style="
|
||||
course.image
|
||||
? { backgroundImage: `url('${encodeURI(course.image)}')` }
|
||||
@@ -15,10 +15,10 @@
|
||||
}
|
||||
"
|
||||
>
|
||||
<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">
|
||||
<div
|
||||
v-if="course.featured"
|
||||
class="flex items-center space-x-1 text-xs text-ink-amber-3 bg-surface-white px-2 py-0.5 rounded-md mr-1 mb-1"
|
||||
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"
|
||||
>
|
||||
<Star class="size-3 stroke-2" />
|
||||
<span>
|
||||
@@ -32,16 +32,22 @@
|
||||
>
|
||||
{{ tag }}
|
||||
</div>
|
||||
</div>
|
||||
</div> -->
|
||||
<div
|
||||
v-if="!course.image"
|
||||
class="flex items-center justify-center text-white flex-1 font-extrabold text-2xl my-auto"
|
||||
:class="course.tags ? 'h-[80%]' : 'h-full'"
|
||||
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 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 v-if="course.lessons">
|
||||
<Tooltip :text="__('Lessons')">
|
||||
@@ -56,7 +62,7 @@
|
||||
<Tooltip :text="__('Enrolled Students')">
|
||||
<span class="flex items-center">
|
||||
<Users class="h-4 w-4 stroke-1.5 mr-1" />
|
||||
{{ course.enrollments }}
|
||||
{{ formatAmount(course.enrollments) }}
|
||||
</span>
|
||||
</Tooltip>
|
||||
</div>
|
||||
@@ -70,18 +76,16 @@
|
||||
</Tooltip>
|
||||
</div>
|
||||
|
||||
<!-- <div v-if="course.status != 'Approved'">
|
||||
<Badge
|
||||
variant="subtle"
|
||||
:theme="course.status === 'Under Review' ? 'orange' : 'blue'"
|
||||
size="sm"
|
||||
>
|
||||
{{ course.status }}
|
||||
</Badge>
|
||||
</div> -->
|
||||
<Tooltip v-if="course.featured" :text="__('Featured')">
|
||||
<Award class="size-4 stroke-2 text-ink-amber-3" />
|
||||
</Tooltip>
|
||||
</div>
|
||||
|
||||
<div v-if="course.image" class="text-xl font-semibold leading-6">
|
||||
<div
|
||||
v-if="course.image"
|
||||
class="font-semibold leading-6"
|
||||
:class="course.title.length > 32 ? 'text-lg' : 'text-xl'"
|
||||
>
|
||||
{{ course.title }}
|
||||
</div>
|
||||
|
||||
@@ -112,27 +116,30 @@
|
||||
<CourseInstructors :instructors="course.instructors" />
|
||||
</div>
|
||||
|
||||
<div v-if="course.paid_course" class="font-semibold">
|
||||
{{ course.price }}
|
||||
</div>
|
||||
<div class="flex items-center space-x-2">
|
||||
<div v-if="course.paid_course" class="font-semibold">
|
||||
{{ course.price }}
|
||||
</div>
|
||||
|
||||
<Tooltip
|
||||
v-if="course.paid_certificate || course.enable_certification"
|
||||
:text="__('Get Certified')"
|
||||
>
|
||||
<GraduationCap class="size-5 stroke-1.5" />
|
||||
</Tooltip>
|
||||
<Tooltip
|
||||
v-if="course.paid_certificate || course.enable_certification"
|
||||
:text="__('Get Certified')"
|
||||
>
|
||||
<GraduationCap class="size-5 stroke-1.5 text-ink-gray-7" />
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<script setup>
|
||||
import { BookOpen, GraduationCap, Star, Users } from 'lucide-vue-next'
|
||||
import UserAvatar from '@/components/UserAvatar.vue'
|
||||
import { Award, BookOpen, GraduationCap, Star, Users } from 'lucide-vue-next'
|
||||
import { sessionStore } from '@/stores/session'
|
||||
import { Tooltip } from 'frappe-ui'
|
||||
import { theme } from '@/utils/theme'
|
||||
import { formatAmount } from '@/utils'
|
||||
import CourseInstructors from '@/components/CourseInstructors.vue'
|
||||
import UserAvatar from '@/components/UserAvatar.vue'
|
||||
import ProgressBar from '@/components/ProgressBar.vue'
|
||||
|
||||
const { user } = sessionStore()
|
||||
|
||||
@@ -169,6 +169,7 @@
|
||||
</div>
|
||||
</div>
|
||||
<CourseProgressSummary
|
||||
v-if="user.data?.is_moderator || is_instructor()"
|
||||
v-model="showProgressModal"
|
||||
:courseName="course.data.name"
|
||||
:enrollments="course.data.enrollments"
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
<template>
|
||||
<div class="">
|
||||
<div class="text-ink-gray-7">
|
||||
<span v-if="instructors?.length == 1">
|
||||
<router-link
|
||||
:to="{
|
||||
name: 'Profile',
|
||||
params: { username: instructors[0].username },
|
||||
}"
|
||||
class="text-ink-gray-7 hover:text-ink-gray-9"
|
||||
>
|
||||
{{ instructors[0].full_name }}
|
||||
</router-link>
|
||||
@@ -16,6 +17,7 @@
|
||||
name: 'Profile',
|
||||
params: { username: instructors[0].username },
|
||||
}"
|
||||
class="text-ink-gray-7 hover:text-ink-gray-9"
|
||||
>
|
||||
{{ instructors[0].first_name }}
|
||||
</router-link>
|
||||
@@ -25,6 +27,7 @@
|
||||
name: 'Profile',
|
||||
params: { username: instructors[1].username },
|
||||
}"
|
||||
class="text-ink-gray-7 hover:text-ink-gray-9"
|
||||
>
|
||||
{{ instructors[1].first_name }}
|
||||
</router-link>
|
||||
@@ -35,6 +38,7 @@
|
||||
name: 'Profile',
|
||||
params: { username: instructors[0].username },
|
||||
}"
|
||||
class="text-ink-gray-7 hover:text-ink-gray-9"
|
||||
>
|
||||
{{ instructors[0].first_name }}
|
||||
</router-link>
|
||||
|
||||
@@ -208,6 +208,10 @@ const props = defineProps({
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
lessonProgress: {
|
||||
type: Number,
|
||||
default: 0,
|
||||
},
|
||||
})
|
||||
|
||||
const outline = createResource({
|
||||
@@ -229,6 +233,13 @@ watch(
|
||||
}
|
||||
)
|
||||
|
||||
watch(
|
||||
() => props.lessonProgress,
|
||||
() => {
|
||||
outline.reload()
|
||||
}
|
||||
)
|
||||
|
||||
const deleteLesson = createResource({
|
||||
url: 'lms.lms.api.delete_lesson',
|
||||
makeParams(values) {
|
||||
|
||||
@@ -1,16 +1,10 @@
|
||||
<template>
|
||||
<div class="relative flex h-full flex-col">
|
||||
<div class="h-full flex-1">
|
||||
<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 />
|
||||
</div>
|
||||
<div class="w-full overflow-auto" id="scrollContainer">
|
||||
<slot />
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex h-screen w-screen">
|
||||
<div class="h-full border-r bg-surface-menu-bar">
|
||||
<AppSidebar />
|
||||
</div>
|
||||
<div class="flex-1 flex flex-col h-full overflow-auto bg-surface-white">
|
||||
<slot />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -32,13 +32,13 @@
|
||||
"
|
||||
:options="[
|
||||
{
|
||||
label: 'Edit',
|
||||
label: __('Edit'),
|
||||
onClick() {
|
||||
reply.editable = true
|
||||
},
|
||||
},
|
||||
{
|
||||
label: 'Delete',
|
||||
label: __('Delete'),
|
||||
onClick() {
|
||||
deleteReply(reply)
|
||||
},
|
||||
|
||||
@@ -5,6 +5,9 @@
|
||||
class="float-right"
|
||||
@click="openTopicModal()"
|
||||
>
|
||||
<template #prefix>
|
||||
<Plus class="size-4" />
|
||||
</template>
|
||||
{{ __('New {0}').format(singularize(title)) }}
|
||||
</Button>
|
||||
<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"
|
||||
>
|
||||
<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">
|
||||
{{ __(emptyStateTitle) }}
|
||||
</div>
|
||||
@@ -73,7 +76,7 @@ import { singularize, timeAgo } from '@/utils'
|
||||
import { ref, onMounted, inject, onUnmounted } from 'vue'
|
||||
import DiscussionReplies from '@/components/DiscussionReplies.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'
|
||||
|
||||
const showTopics = ref(true)
|
||||
@@ -102,7 +105,7 @@ const props = defineProps({
|
||||
},
|
||||
emptyStateText: {
|
||||
type: String,
|
||||
default: 'Start a discussion',
|
||||
default: 'Start a Discussion',
|
||||
},
|
||||
singleThread: {
|
||||
type: Boolean,
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
{{ __('No {0}').format(type?.toLowerCase()) }}
|
||||
</div>
|
||||
<div
|
||||
class="leading-5 text-base w-2/5 text-base text-center text-ink-gray-7"
|
||||
class="leading-5 text-base w-full md:w-2/5 text-base text-center text-ink-gray-7"
|
||||
>
|
||||
{{
|
||||
__(
|
||||
|
||||
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-start">
|
||||
<template #body>
|
||||
<div
|
||||
class="fixed top-[20rem] translate-x-1/3 z-20 flex flex-col gap-3 rounded bg-surface-white 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 class="leading-5">
|
||||
{{
|
||||
__(
|
||||
'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>
|
||||
@@ -3,7 +3,7 @@
|
||||
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 flex-col space-y-2 flex-1">
|
||||
<div class="flex flex-col space-y-2 flex-1 break-all">
|
||||
<div class="text-lg font-semibold text-ink-gray-9">
|
||||
{{ job.company_name }}
|
||||
</div>
|
||||
@@ -33,6 +33,9 @@
|
||||
<Badge>
|
||||
{{ job.type }}
|
||||
</Badge>
|
||||
<Badge v-if="job.work_mode">
|
||||
{{ job.work_mode }}
|
||||
</Badge>
|
||||
<Badge>
|
||||
{{ dayjs(job.creation).fromNow() }}
|
||||
</Badge>
|
||||
|
||||
@@ -9,6 +9,16 @@
|
||||
allowfullscreen
|
||||
></iframe>
|
||||
</div>
|
||||
<div v-if="rutube">
|
||||
<iframe
|
||||
class="rutube-video"
|
||||
:src="getRutubeVideoSource(rutube.split('/').pop())"
|
||||
width="100%"
|
||||
:height="screenSize.width < 640 ? 200 : 400"
|
||||
frameborder="0"
|
||||
allowfullscreen
|
||||
></iframe>
|
||||
</div>
|
||||
<div v-for="block in content?.split('\n\n')">
|
||||
<div v-if="block.includes('{{ YouTubeVideo')">
|
||||
<iframe
|
||||
@@ -20,6 +30,16 @@
|
||||
allowfullscreen
|
||||
></iframe>
|
||||
</div>
|
||||
<div v-else-if="block.includes('{{ RutubeVideo')">
|
||||
<iframe
|
||||
class="rutube-video"
|
||||
:src="getRutubeVideoSource(block)"
|
||||
width="100%"
|
||||
:height="screenSize.width < 640 ? 200 : 400"
|
||||
frameborder="0"
|
||||
allowfullscreen
|
||||
></iframe>
|
||||
</div>
|
||||
<div v-else-if="block.includes('{{ Quiz')">
|
||||
<Quiz :quiz="getId(block)" />
|
||||
</div>
|
||||
@@ -97,6 +117,13 @@ const getYouTubeVideoSource = (block) => {
|
||||
return `https://www.youtube.com/embed/${block}`
|
||||
}
|
||||
|
||||
const getRutubeVideoSource = (block) => {
|
||||
if (block.includes('{{')) {
|
||||
block = getId(block)
|
||||
}
|
||||
return `https://rutube.ru/play/embed/${block}`
|
||||
}
|
||||
|
||||
const getPDFSource = (block) => {
|
||||
return `${getId(block)}#toolbar=0`
|
||||
}
|
||||
@@ -105,3 +132,11 @@ const getId = (block) => {
|
||||
return block.match(/\(["']([^"']+?)["']\)/)[1]
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.youtube-video,
|
||||
.rutube-video {
|
||||
display: block;
|
||||
margin: 0 auto;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -52,9 +52,9 @@ const contentMap = {
|
||||
'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?',
|
||||
title: 'How to add a YouTube Video/RuTube?',
|
||||
description:
|
||||
'Copy the URL of the video from YouTube and paste it in the editor.',
|
||||
'Copy the URL of the video from YouTube/RuTube and paste it in the editor.',
|
||||
},
|
||||
remove: {
|
||||
title: 'How to remove an embed?',
|
||||
|
||||
@@ -22,7 +22,10 @@
|
||||
</span>
|
||||
</Button>
|
||||
</div>
|
||||
<div v-if="liveClasses.data?.length" class="grid grid-cols-3 gap-5 mt-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
|
||||
v-for="cls in liveClasses.data"
|
||||
class="flex flex-col border rounded-md h-full text-ink-gray-7 hover:border-outline-gray-3 p-3"
|
||||
@@ -51,8 +54,8 @@
|
||||
<div class="flex items-center space-x-2">
|
||||
<Clock class="w-4 h-4 stroke-1.5" />
|
||||
<span>
|
||||
{{ formatTime(cls.time) }} -
|
||||
{{ dayjs(getClassEnd(cls)).format('HH:mm') }}
|
||||
{{ dayjs(getClassStart(cls)).format('hh:mm A') }} -
|
||||
{{ dayjs(getClassEnd(cls)).format('hh:mm A') }}
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
@@ -178,8 +181,12 @@ const canAccessClass = (cls) => {
|
||||
return true
|
||||
}
|
||||
|
||||
const getClassStart = (cls) => {
|
||||
return new Date(`${cls.date}T${cls.time}`)
|
||||
}
|
||||
|
||||
const getClassEnd = (cls) => {
|
||||
const classStart = new Date(`${cls.date}T${cls.time}`)
|
||||
const classStart = getClassStart(cls)
|
||||
return new Date(classStart.getTime() + cls.duration * 60000)
|
||||
}
|
||||
|
||||
|
||||
@@ -56,6 +56,7 @@
|
||||
<script setup>
|
||||
import { getSidebarLinks } from '@/utils'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { call } from 'frappe-ui'
|
||||
import { watch, ref, onMounted } from 'vue'
|
||||
import { sessionStore } from '@/stores/session'
|
||||
import { useSettings } from '@/stores/settings'
|
||||
@@ -71,17 +72,16 @@ const sidebarLinks = ref(getSidebarLinks())
|
||||
const otherLinks = ref([])
|
||||
const showMenu = ref(false)
|
||||
const menu = ref(null)
|
||||
const isModerator = ref(false)
|
||||
const isInstructor = ref(false)
|
||||
|
||||
onMounted(() => {
|
||||
sidebarSettings.reload(
|
||||
{},
|
||||
{
|
||||
onSuccess(data) {
|
||||
filterLinksToShow(data)
|
||||
addOtherLinks()
|
||||
},
|
||||
}
|
||||
)
|
||||
// Вызываем addSideBar только если userResource уже загружен
|
||||
if (userResource.data) {
|
||||
addSideBar()
|
||||
}
|
||||
addOtherLinks()
|
||||
filterLinksToShow(data)
|
||||
})
|
||||
|
||||
const handleOutsideClick = (e) => {
|
||||
@@ -110,55 +110,196 @@ const filterLinksToShow = (data) => {
|
||||
})
|
||||
}
|
||||
|
||||
const addSideBar = () => {
|
||||
sidebarLinks.value = [] // Очищаем, чтобы избежать дублирования
|
||||
|
||||
// Проверяем роли пользователя
|
||||
const roles = userResource.data?.roles || []
|
||||
|
||||
sidebarLinks.value.push({
|
||||
label: __('Courses'),
|
||||
icon: 'BookOpen',
|
||||
to: 'Courses',
|
||||
activeFor: [
|
||||
'Courses',
|
||||
'CourseDetail',
|
||||
'Lesson',
|
||||
'CourseForm',
|
||||
'LessonForm',
|
||||
],
|
||||
})
|
||||
|
||||
sidebarLinks.value.push({
|
||||
label: __('Leader Board'),
|
||||
icon: 'Trophy',
|
||||
to: 'LeaderBoard',
|
||||
activeFor: [],
|
||||
})
|
||||
|
||||
if (roles.includes('LMS Student') || roles.includes('LMS Schoolchild')) {
|
||||
otherLinks.value.push({
|
||||
label: __('My points'),
|
||||
icon: 'Award',
|
||||
to: 'MyPoints',
|
||||
activeFor: [],
|
||||
})
|
||||
}
|
||||
|
||||
if (roles.includes('Parent')) {
|
||||
otherLinks.value.push({
|
||||
label: __('My Child'),
|
||||
icon: 'User',
|
||||
to: 'my-child',
|
||||
external: true,
|
||||
activeFor: [],
|
||||
})
|
||||
}
|
||||
|
||||
let chatGPTURL = ''
|
||||
let chatGPTLabel = ''
|
||||
|
||||
if (roles.includes('LMS Schoolchild')) {
|
||||
chatGPTURL = 'chatgpt-schoolchild'
|
||||
chatGPTLabel = __('ChatGPT for Schoolers')
|
||||
} else if (roles.includes('LMS Student')) {
|
||||
chatGPTURL = 'chatgpt-schoolchild'
|
||||
chatGPTLabel = __('ChatGPT for Students')
|
||||
} else if (roles.includes('Course Creator')) {
|
||||
chatGPTURL = 'ai-teachers'
|
||||
chatGPTLabel = __('ChatGPT for Teachers')
|
||||
}
|
||||
|
||||
if (chatGPTURL) {
|
||||
sidebarLinks.value.push({
|
||||
label: chatGPTLabel,
|
||||
icon: 'Cpu',
|
||||
to: chatGPTURL,
|
||||
external: true,
|
||||
activeFor: [],
|
||||
})
|
||||
}
|
||||
}
|
||||
const addOtherLinks = () => {
|
||||
otherLinks.value = []
|
||||
|
||||
if (user) {
|
||||
const roles = userResource.data?.roles || []
|
||||
|
||||
if (!userResource.data?.is_instructor && !userResource.data?.is_moderator) {
|
||||
otherLinks.value.push({
|
||||
label: __('Programs'),
|
||||
icon: 'Route',
|
||||
to: 'Programs',
|
||||
activeFor: ['Programs', 'ProgramForm', 'CourseDetail', 'Lesson'],
|
||||
})
|
||||
} else if (userResource.data?.is_instructor || userResource.data?.is_moderator) {
|
||||
otherLinks.value.push({
|
||||
label: __('Programs'),
|
||||
icon: 'Route',
|
||||
to: 'Programs',
|
||||
activeFor: ['Programs', 'ProgramForm'],
|
||||
})
|
||||
}
|
||||
|
||||
if (userResource.data?.is_moderator || userResource.data?.is_instructor) {
|
||||
otherLinks.value.push({
|
||||
label: __('Quizzes'),
|
||||
icon: 'CircleHelp',
|
||||
to: 'Quizzes',
|
||||
activeFor: [
|
||||
'Quizzes',
|
||||
'QuizForm',
|
||||
'QuizSubmissionList',
|
||||
'QuizSubmission',
|
||||
],
|
||||
})
|
||||
|
||||
otherLinks.value.push({
|
||||
label: __('Assignments'),
|
||||
icon: 'Pencil',
|
||||
to: 'Assignments',
|
||||
activeFor: [
|
||||
'Assignments',
|
||||
'AssignmentForm',
|
||||
'AssignmentSubmissionList',
|
||||
'AssignmentSubmission',
|
||||
],
|
||||
}),
|
||||
otherLinks.value.push({
|
||||
label: 'Programming Exercises',
|
||||
icon: 'Code',
|
||||
to: 'ProgrammingExercises',
|
||||
})
|
||||
}
|
||||
|
||||
if (roles.includes('LMS Student') || roles.includes('LMS Schoolchild')) {
|
||||
otherLinks.value.push({
|
||||
label: __('My points'),
|
||||
icon: 'Award',
|
||||
to: 'my_points',
|
||||
external: true,
|
||||
activeFor: [],
|
||||
})
|
||||
}
|
||||
|
||||
let chatGPTURL = ''
|
||||
let chatGPTLabel = ''
|
||||
|
||||
if (roles.includes('LMS Schoolchild')) {
|
||||
chatGPTURL = 'chatgpt-schoolchild'
|
||||
chatGPTLabel = __('ChatGPT for Schoolers')
|
||||
} else if (roles.includes('LMS Student')) {
|
||||
chatGPTURL = 'chatgpt-schoolchild'
|
||||
chatGPTLabel = __('ChatGPT for Students')
|
||||
} else if (roles.includes('Course Creator')) {
|
||||
chatGPTURL = 'ai-teachers'
|
||||
chatGPTLabel = __('ChatGPT for Teachers')
|
||||
}
|
||||
|
||||
if (chatGPTURL) {
|
||||
otherLinks.value.push({
|
||||
label: chatGPTLabel,
|
||||
icon: 'Cpu',
|
||||
to: chatGPTURL,
|
||||
external: true,
|
||||
activeFor: [],
|
||||
})
|
||||
}
|
||||
|
||||
otherLinks.value.push({
|
||||
label: 'Notifications',
|
||||
icon: 'Bell',
|
||||
to: 'Notifications',
|
||||
label: __('Leader Board'),
|
||||
icon: 'Trophy',
|
||||
to: 'leaderboardsample',
|
||||
external: true,
|
||||
activeFor: [],
|
||||
})
|
||||
|
||||
otherLinks.value.push({
|
||||
label: 'Profile',
|
||||
label: __('Profile'),
|
||||
icon: 'UserRound',
|
||||
to: 'Profile',
|
||||
params: { username: userResource.data?.username },
|
||||
})
|
||||
|
||||
otherLinks.value.push({
|
||||
label: 'Log out',
|
||||
label: __('Log out'),
|
||||
icon: 'LogOut',
|
||||
})
|
||||
} else {
|
||||
otherLinks.value.push({
|
||||
label: 'Log in',
|
||||
label: __('Log in'),
|
||||
icon: 'LogIn',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
watch(userResource, () => {
|
||||
if (
|
||||
userResource.data &&
|
||||
(userResource.data.is_moderator || userResource.data.is_instructor)
|
||||
) {
|
||||
addQuizzes()
|
||||
addAssignments()
|
||||
if (userResource.data) {
|
||||
addSideBar() // Обновляем sidebarLinks при изменении userResource
|
||||
addOtherLinks() // Обновляем otherLinks
|
||||
}
|
||||
})
|
||||
|
||||
const addQuizzes = () => {
|
||||
otherLinks.value.push({
|
||||
label: 'Quizzes',
|
||||
icon: 'CircleHelp',
|
||||
to: 'Quizzes',
|
||||
})
|
||||
}
|
||||
|
||||
const addAssignments = () => {
|
||||
otherLinks.value.push({
|
||||
label: 'Assignments',
|
||||
icon: 'Pencil',
|
||||
to: 'Assignments',
|
||||
})
|
||||
}
|
||||
|
||||
let isActive = (tab) => {
|
||||
return tab.activeFor?.includes(router.currentRoute.value.name)
|
||||
}
|
||||
@@ -176,6 +317,7 @@ const handleClick = (tab) => {
|
||||
username: userResource.data?.username,
|
||||
},
|
||||
})
|
||||
else if (tab.external) window.location.href = `/${tab.to}`
|
||||
else router.push({ name: tab.to })
|
||||
}
|
||||
|
||||
|
||||
@@ -25,6 +25,7 @@
|
||||
<div class="">
|
||||
<div class="mb-1.5 text-sm text-ink-gray-5">
|
||||
{{ __('Reply To') }}
|
||||
<span class="text-ink-red-3">*</span>
|
||||
</div>
|
||||
<Input type="text" v-model="announcement.replyTo" />
|
||||
</div>
|
||||
@@ -70,8 +71,8 @@ const announcementResource = createResource({
|
||||
url: 'frappe.core.doctype.communication.email.make',
|
||||
makeParams(values) {
|
||||
return {
|
||||
recipients: props.students.join(', '),
|
||||
cc: announcement.replyTo,
|
||||
recipients: announcement.replyTo,
|
||||
bcc: props.students.join(', '),
|
||||
subject: announcement.subject,
|
||||
content: announcement.announcement,
|
||||
doctype: 'LMS Batch',
|
||||
@@ -95,6 +96,9 @@ const makeAnnouncement = (close) => {
|
||||
if (!announcement.announcement) {
|
||||
return __('Announcement is required')
|
||||
}
|
||||
if (!announcement.replyTo) {
|
||||
return __('Reply To is required')
|
||||
}
|
||||
},
|
||||
onSuccess() {
|
||||
close()
|
||||
|
||||
@@ -113,6 +113,14 @@ watch(
|
||||
{ flush: 'post' }
|
||||
)
|
||||
|
||||
watch(show, (newVal) => {
|
||||
if (newVal && props.assignmentID === 'new') {
|
||||
assignment.title = ''
|
||||
assignment.type = ''
|
||||
assignment.question = ''
|
||||
}
|
||||
})
|
||||
|
||||
const saveAssignment = () => {
|
||||
if (props.assignmentID == 'new') {
|
||||
assignments.value.insert.submit(
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
<Avatar :image="student.user_image" size="3xl" />
|
||||
<div class="space-y-1">
|
||||
<div class="flex items-center space-x-2">
|
||||
<div class="text-xl font-semibold">
|
||||
<div class="text-xl font-semibold text-ink-gray-9">
|
||||
{{ student.full_name }}
|
||||
</div>
|
||||
<Badge
|
||||
@@ -36,7 +36,9 @@
|
||||
v-if="Object.keys(student.assessments).length"
|
||||
class="space-y-2 text-sm"
|
||||
>
|
||||
<div class="flex items-center border-b pb-1 font-medium">
|
||||
<div
|
||||
class="flex items-center border-b pb-1 font-medium text-ink-gray-9"
|
||||
>
|
||||
<span class="flex-1">
|
||||
{{ __('Assessment') }}
|
||||
</span>
|
||||
@@ -86,7 +88,9 @@
|
||||
v-if="Object.keys(student.courses).length"
|
||||
class="space-y-2 text-sm"
|
||||
>
|
||||
<div class="flex items-center border-b pb-1 font-medium">
|
||||
<div
|
||||
class="flex items-center border-b pb-1 font-medium text-ink-gray-9"
|
||||
>
|
||||
<span class="flex-1">
|
||||
{{ __('Courses') }}
|
||||
</span>
|
||||
|
||||
@@ -50,7 +50,7 @@
|
||||
<FileText class="h-5 w-5 stroke-1.5 text-ink-gray-7" />
|
||||
</div>
|
||||
<div class="flex flex-col">
|
||||
<span>
|
||||
<span class="text-ink-gray-9">
|
||||
{{ chapter.scorm_package.file_name }}
|
||||
</span>
|
||||
<span class="text-sm text-ink-gray-4 mt-1">
|
||||
|
||||
@@ -7,12 +7,11 @@
|
||||
}"
|
||||
>
|
||||
<template #body-content>
|
||||
<div class="flex justify-between space-x-10 text-base mt-10">
|
||||
<div
|
||||
class="flex flex-col-reverse md:flex-row justify-between md:space-x-10 text-base mt-10"
|
||||
>
|
||||
<div class="w-full">
|
||||
<div class="flex items-center justify-between space-x-5 mb-4">
|
||||
<!-- <div class="text-xl font-semibold text-ink-gray-6">
|
||||
{{ __('{0} Members').format(memberCount) }}
|
||||
</div> -->
|
||||
<FormControl
|
||||
v-model="searchFilter"
|
||||
:placeholder="__('Search by Member')"
|
||||
@@ -90,7 +89,9 @@
|
||||
</div>
|
||||
</div>
|
||||
<div class="mb-4 self-start w-full space-y-5">
|
||||
<div class="flex items-center space-x-4">
|
||||
<div
|
||||
class="flex flex-col md:flex-row items-center space-y-2 md:space-y-0 md:space-x-4"
|
||||
>
|
||||
<NumberChart
|
||||
class="border rounded-md w-full"
|
||||
:config="{
|
||||
@@ -147,10 +148,11 @@ import {
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { theme } from '@/utils/theme'
|
||||
|
||||
const show = defineModel<boolean | undefined>()
|
||||
const show = defineModel<boolean>({ default: false })
|
||||
const searchFilter = ref<string | null>(null)
|
||||
type Filters = {
|
||||
course: string | undefined
|
||||
|
||||
member_name?: string[]
|
||||
}
|
||||
|
||||
@@ -221,7 +223,6 @@ const progressColumns = computed(() => {
|
||||
{
|
||||
label: __('Progress'),
|
||||
key: 'progress',
|
||||
width: '30%',
|
||||
align: 'right',
|
||||
icon: 'trending-up',
|
||||
},
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
<template>
|
||||
<Dialog
|
||||
:options="{
|
||||
title: 'Edit your profile',
|
||||
size: 'xl',
|
||||
title: __('Edit your profile'),
|
||||
size: '3xl',
|
||||
actions: [
|
||||
{
|
||||
label: 'Save',
|
||||
label: __('Save'),
|
||||
variant: 'solid',
|
||||
onClick: (close) => saveProfile(close),
|
||||
},
|
||||
@@ -13,74 +13,81 @@
|
||||
}"
|
||||
>
|
||||
<template #body-content>
|
||||
<div>
|
||||
<FileUploader
|
||||
v-if="!profile.image"
|
||||
:fileTypes="['image/*']"
|
||||
:validateFile="validateFile"
|
||||
@success="(file) => saveImage(file)"
|
||||
>
|
||||
<template v-slot="{ file, progress, uploading, openFileSelector }">
|
||||
<div class="mb-4">
|
||||
<Button @click="openFileSelector" :loading="uploading">
|
||||
{{
|
||||
uploading
|
||||
? `Uploading ${progress}%`
|
||||
: 'Upload a profile image'
|
||||
}}
|
||||
</Button>
|
||||
<div class="grid grid-cols-2 gap-5">
|
||||
<div class="space-y-4">
|
||||
<!-- <Uploader
|
||||
v-model="profile.image.file_url"
|
||||
label="Profile Image"
|
||||
description="Your profile image to help others recognize you."
|
||||
/> -->
|
||||
<div>
|
||||
<div class="text-xs text-ink-gray-5 mb-1">
|
||||
{{ __('Profile Image') }}
|
||||
</div>
|
||||
<FileUploader
|
||||
v-if="!profile.image"
|
||||
:fileTypes="['image/*']"
|
||||
:validateFile="validateFile"
|
||||
@success="(file) => saveImage(file)"
|
||||
>
|
||||
<template
|
||||
v-slot="{ file, progress, uploading, openFileSelector }"
|
||||
>
|
||||
<div class="mb-4">
|
||||
<Button @click="openFileSelector" :loading="uploading">
|
||||
{{
|
||||
uploading
|
||||
? `Uploading ${progress}%`
|
||||
: 'Upload a profile image'
|
||||
}}
|
||||
</Button>
|
||||
</div>
|
||||
</template>
|
||||
</FileUploader>
|
||||
<div v-else class="mb-4">
|
||||
<div class="flex items-center">
|
||||
<img
|
||||
:src="profile.image.file_url"
|
||||
class="object-cover h-[50px] w-[50px] rounded-full border-4 border-white object-cover"
|
||||
/>
|
||||
|
||||
<div class="text-base flex flex-col ml-2">
|
||||
<span>
|
||||
{{ profile.image.file_name }}
|
||||
</span>
|
||||
<span class="text-sm text-ink-gray-4 mt-1">
|
||||
{{ getFileSize(profile.image.file_size) }}
|
||||
</span>
|
||||
</div>
|
||||
<X
|
||||
@click="removeImage()"
|
||||
class="bg-surface-gray-3 rounded-md cursor-pointer stroke-1.5 w-5 h-5 p-1 ml-4"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</FileUploader>
|
||||
<div v-else class="mb-4">
|
||||
<div class="text-xs text-ink-gray-5 mb-1">
|
||||
{{ __('Profile Image') }}
|
||||
</div>
|
||||
<div class="flex items-center">
|
||||
<div class="border rounded-md p-2 mr-2">
|
||||
<FileText class="h-5 w-5 stroke-1.5 text-ink-gray-7" />
|
||||
<FormControl v-model="profile.first_name" :label="__('First Name')" />
|
||||
<FormControl v-model="profile.last_name" :label="__('Last Name')" />
|
||||
<FormControl v-model="profile.headline" :label="__('Headline')" />
|
||||
<Link
|
||||
:label="__('Language')"
|
||||
v-model="profile.language"
|
||||
doctype="Language"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<div class="mb-4">
|
||||
<div class="mb-1.5 text-sm text-ink-gray-5">
|
||||
{{ __('Bio') }}
|
||||
</div>
|
||||
<div class="text-base flex flex-col">
|
||||
<span>
|
||||
{{ profile.image.file_name }}
|
||||
</span>
|
||||
<span class="text-sm text-ink-gray-4 mt-1">
|
||||
{{ getFileSize(profile.image.file_size) }}
|
||||
</span>
|
||||
</div>
|
||||
<X
|
||||
@click="removeImage()"
|
||||
class="bg-surface-gray-3 rounded-md cursor-pointer stroke-1.5 w-5 h-5 p-1 ml-4"
|
||||
<TextEditor
|
||||
:fixedMenu="true"
|
||||
@change="(val) => (profile.bio = val)"
|
||||
:content="profile.bio"
|
||||
editorClass="prose-sm py-2 px-2 min-h-[200px] border-outline-gray-2 hover:border-outline-gray-3 rounded-b-md bg-surface-gray-3"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<FormControl
|
||||
v-model="profile.first_name"
|
||||
:label="__('First Name')"
|
||||
class="mb-4"
|
||||
/>
|
||||
<FormControl
|
||||
v-model="profile.last_name"
|
||||
:label="__('Last Name')"
|
||||
class="mb-4"
|
||||
/>
|
||||
<FormControl
|
||||
v-model="profile.headline"
|
||||
:label="__('Headline')"
|
||||
class="mb-4"
|
||||
/>
|
||||
|
||||
<div class="mb-4">
|
||||
<div class="mb-1.5 text-sm text-ink-gray-5">
|
||||
{{ __('Bio') }}
|
||||
</div>
|
||||
<TextEditor
|
||||
:fixedMenu="true"
|
||||
@change="(val) => (profile.bio = val)"
|
||||
:content="profile.bio"
|
||||
editorClass="prose-sm py-2 px-2 min-h-[200px] border-outline-gray-2 hover:border-outline-gray-3 rounded-md bg-surface-gray-3"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</Dialog>
|
||||
@@ -95,11 +102,14 @@ import {
|
||||
TextEditor,
|
||||
toast,
|
||||
} from 'frappe-ui'
|
||||
import { reactive, watch } from 'vue'
|
||||
import { FileText, X } from 'lucide-vue-next'
|
||||
import { getFileSize } from '@/utils'
|
||||
import { ref, reactive, watch } from 'vue'
|
||||
import { X } from 'lucide-vue-next'
|
||||
import { getFileSize, decodeEntities } from '@/utils'
|
||||
import Link from '@/components/Controls/Link.vue'
|
||||
import DOMPurify from 'dompurify'
|
||||
|
||||
const reloadProfile = defineModel('reloadProfile')
|
||||
const hasLanguageChanged = ref(false)
|
||||
|
||||
const props = defineProps({
|
||||
profile: {
|
||||
@@ -147,12 +157,32 @@ const updateProfile = createResource({
|
||||
})
|
||||
|
||||
const saveProfile = (close) => {
|
||||
profile.bio = DOMPurify.sanitize(decodeEntities(profile.bio), {
|
||||
ALLOWED_TAGS: [
|
||||
'b',
|
||||
'i',
|
||||
'em',
|
||||
'strong',
|
||||
'a',
|
||||
'p',
|
||||
'br',
|
||||
'ul',
|
||||
'ol',
|
||||
'li',
|
||||
'img',
|
||||
],
|
||||
ALLOWED_ATTR: ['href', 'target', 'src'],
|
||||
})
|
||||
updateProfile.submit(
|
||||
{},
|
||||
{
|
||||
onSuccess() {
|
||||
close()
|
||||
reloadProfile.value.reload()
|
||||
if (hasLanguageChanged.value) {
|
||||
hasLanguageChanged.value = false
|
||||
window.location.reload()
|
||||
}
|
||||
},
|
||||
onError(err) {
|
||||
toast.error(err.messages?.[0] || err)
|
||||
@@ -183,9 +213,19 @@ watch(
|
||||
profile.first_name = newVal.first_name
|
||||
profile.last_name = newVal.last_name
|
||||
profile.headline = newVal.headline
|
||||
profile.language = newVal.language
|
||||
profile.bio = newVal.bio
|
||||
if (newVal.user_image) imageResource.submit({ image: newVal.user_image })
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
watch(
|
||||
() => profile.language,
|
||||
(newVal, oldVal) => {
|
||||
if (newVal !== oldVal) {
|
||||
hasLanguageChanged.value = true
|
||||
}
|
||||
}
|
||||
)
|
||||
</script>
|
||||
|
||||
@@ -66,7 +66,11 @@
|
||||
</template>
|
||||
{{ __('View Certificate') }}
|
||||
</Button>
|
||||
<Button v-else @click="openCallLink(event.venue)" class="w-full">
|
||||
<Button
|
||||
v-else-if="userIsEvaluator()"
|
||||
@click="openCallLink(event.venue)"
|
||||
class="w-full"
|
||||
>
|
||||
<template #prefix>
|
||||
<Video class="h-4 w-4 stroke-1.5" />
|
||||
</template>
|
||||
@@ -83,21 +87,31 @@
|
||||
class="flex flex-col space-y-4 p-5"
|
||||
>
|
||||
<div class="flex items-center justify-between">
|
||||
<Rating v-model="evaluation.rating" :label="__('Rating')" />
|
||||
<Rating
|
||||
v-model="evaluation.rating"
|
||||
:label="__('Rating')"
|
||||
:disabled="!userIsEvaluator()"
|
||||
/>
|
||||
<FormControl
|
||||
type="select"
|
||||
:options="statusOptions"
|
||||
v-model="evaluation.status"
|
||||
:label="__('Status')"
|
||||
class="w-1/2"
|
||||
:disabled="!userIsEvaluator()"
|
||||
/>
|
||||
</div>
|
||||
<Textarea
|
||||
v-model="evaluation.summary"
|
||||
:label="__('Summary')"
|
||||
:rows="7"
|
||||
:disabled="!userIsEvaluator()"
|
||||
/>
|
||||
<Button variant="solid" @click="saveEvaluation()">
|
||||
<Button
|
||||
v-if="userIsEvaluator()"
|
||||
variant="solid"
|
||||
@click="saveEvaluation()"
|
||||
>
|
||||
{{ __('Save') }}
|
||||
</Button>
|
||||
</div>
|
||||
@@ -106,11 +120,13 @@
|
||||
type="checkbox"
|
||||
v-model="certificate.published"
|
||||
:label="__('Published')"
|
||||
:disabled="!userIsEvaluator()"
|
||||
/>
|
||||
<Link
|
||||
v-model="certificate.template"
|
||||
:label="__('Template')"
|
||||
doctype="Print Format"
|
||||
:disabled="!userIsEvaluator()"
|
||||
:filters="{
|
||||
doc_type: 'LMS Certificate',
|
||||
}"
|
||||
@@ -118,14 +134,20 @@
|
||||
<FormControl
|
||||
type="date"
|
||||
v-model="certificate.issue_date"
|
||||
:disabled="!userIsEvaluator()"
|
||||
:label="__('Issue Date')"
|
||||
/>
|
||||
<FormControl
|
||||
type="date"
|
||||
v-model="certificate.expiry_date"
|
||||
:disabled="!userIsEvaluator()"
|
||||
:label="__('Expiry Date')"
|
||||
/>
|
||||
<Button variant="solid" @click="saveCertificate()">
|
||||
<Button
|
||||
v-if="userIsEvaluator()"
|
||||
variant="solid"
|
||||
@click="saveCertificate()"
|
||||
>
|
||||
{{ __('Save') }}
|
||||
</Button>
|
||||
</div>
|
||||
@@ -163,6 +185,7 @@ import Rating from '@/components/Controls/Rating.vue'
|
||||
import Link from '@/components/Controls/Link.vue'
|
||||
|
||||
const show = defineModel()
|
||||
const user = inject('$user')
|
||||
const dayjs = inject('$dayjs')
|
||||
const tabIndex = ref(0)
|
||||
const showCertification = ref(false)
|
||||
@@ -175,9 +198,18 @@ const props = defineProps({
|
||||
})
|
||||
|
||||
const evaluation = reactive({})
|
||||
|
||||
const certificate = reactive({})
|
||||
|
||||
watch(user, () => {
|
||||
if (userIsEvaluator()) {
|
||||
defaultTemplate.reload()
|
||||
}
|
||||
})
|
||||
|
||||
const userIsEvaluator = () => {
|
||||
return user.data && user.data.name == props.event.evaluator
|
||||
}
|
||||
|
||||
const defaultTemplate = createResource({
|
||||
url: 'frappe.client.get_value',
|
||||
makeParams(values) {
|
||||
@@ -190,7 +222,6 @@ const defaultTemplate = createResource({
|
||||
},
|
||||
}
|
||||
},
|
||||
auto: true,
|
||||
onSuccess(data) {
|
||||
certificate.template = data.value
|
||||
},
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
>
|
||||
<template #body-content>
|
||||
<div class="flex flex-col gap-4">
|
||||
<p>
|
||||
<p class="text-ink-gray-9">
|
||||
{{
|
||||
__(
|
||||
'Submit your resume to proceed with your application for this position. Upon submission, it will be shared with the job poster.'
|
||||
@@ -51,7 +51,7 @@
|
||||
<FileText class="h-5 w-5 stroke-1.5 text-ink-gray-7" />
|
||||
</div>
|
||||
<div class="flex flex-col">
|
||||
<span>
|
||||
<span class="text-ink-gray-9">
|
||||
{{ resume.file_name }}
|
||||
</span>
|
||||
<span class="text-sm text-ink-gray-4 mt-1">
|
||||
|
||||
@@ -126,7 +126,7 @@ import {
|
||||
Button,
|
||||
toast,
|
||||
} from 'frappe-ui'
|
||||
import { computed, watch, reactive, ref, inject } from 'vue'
|
||||
import { watch, reactive, ref, inject } from 'vue'
|
||||
import Link from '@/components/Controls/Link.vue'
|
||||
import { useOnboarding } from 'frappe-ui/frappe'
|
||||
|
||||
@@ -141,6 +141,7 @@ const existingQuestion = reactive({
|
||||
question: '',
|
||||
marks: 1,
|
||||
})
|
||||
|
||||
const question = reactive({
|
||||
question: '',
|
||||
type: 'Choices',
|
||||
|
||||
@@ -23,7 +23,9 @@
|
||||
</div>
|
||||
<div v-if="currentTab" class="mt-4">
|
||||
<div class="grid grid-cols-[55%,40%] gap-5">
|
||||
<div class="space-y-5 border rounded-md p-2 pt-4">
|
||||
<div
|
||||
class="space-y-5 border rounded-md p-2 pt-4 h-[70vh] overflow-y-auto"
|
||||
>
|
||||
<div class="grid grid-cols-[70%,30%] text-sm text-ink-gray-5">
|
||||
<div class="px-4">
|
||||
{{ __('Member') }}
|
||||
@@ -59,7 +61,7 @@
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-center text-sm">
|
||||
{{ convertToMinutes(row.watch_time) }}
|
||||
{{ formatTimestamp(row.watch_time) }}
|
||||
</div>
|
||||
</div>
|
||||
</router-link>
|
||||
@@ -69,7 +71,7 @@
|
||||
<NumberChart
|
||||
class="border rounded-md"
|
||||
:config="{
|
||||
title: __('Average Watch Time (minutes)'),
|
||||
title: __('Average Watch Time'),
|
||||
value: averageWatchTime,
|
||||
}"
|
||||
/>
|
||||
@@ -97,7 +99,7 @@ import {
|
||||
TabButtons,
|
||||
} from 'frappe-ui'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { enablePlyr, convertToMinutes } from '@/utils'
|
||||
import { enablePlyr, formatTimestamp } from '@/utils'
|
||||
import VideoBlock from '@/components/VideoBlock.vue'
|
||||
|
||||
const show = defineModel<boolean | undefined>()
|
||||
@@ -185,7 +187,7 @@ const averageWatchTime = computed(() => {
|
||||
totalWatchTime += parseFloat(item.watch_time)
|
||||
})
|
||||
|
||||
return convertToMinutes(totalWatchTime / currentTabData.value.length)
|
||||
return formatTimestamp(totalWatchTime / currentTabData.value.length)
|
||||
})
|
||||
|
||||
const currentTabData = computed(() => {
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
<template>
|
||||
<div class="border rounded-md w-1/3 mx-auto my-32">
|
||||
<div class="border-b px-5 py-3 font-medium">
|
||||
<div class="border-b px-5 py-3 font-medium text-ink-gray-9">
|
||||
<span
|
||||
class="inline-flex items-center before:bg-surface-red-5 before:w-2 before:h-2 before:rounded-md before:mr-2"
|
||||
></span>
|
||||
{{ __('Not Permitted') }}
|
||||
</div>
|
||||
<div v-if="user.data" class="px-5 py-3">
|
||||
<div>
|
||||
<div class="text-ink-gray-7">
|
||||
{{ __('You do not have permission to access this page.') }}
|
||||
</div>
|
||||
<router-link
|
||||
@@ -21,7 +21,7 @@
|
||||
</router-link>
|
||||
</div>
|
||||
<div class="px-5 py-3">
|
||||
<div>
|
||||
<div class="text-ink-gray-7">
|
||||
{{ __('Please login to access this page.') }}
|
||||
</div>
|
||||
<Button @click="redirectToLogin()" class="mt-4">
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
<template>
|
||||
<div class="text-base border rounded-md w-1/3 mx-auto my-32">
|
||||
<div class="border-b px-5 py-3 font-medium">
|
||||
<div class="border-b px-5 py-3 font-medium text-ink-gray-9">
|
||||
<span
|
||||
class="inline-flex items-center before:bg-surface-red-5 before:w-2 before:h-2 before:rounded-md before:mr-2"
|
||||
></span>
|
||||
{{ __(title) }}
|
||||
</div>
|
||||
<div class="px-5 py-3">
|
||||
<div class="mb-4 leading-6">
|
||||
<div class="mb-4 leading-6 text-ink-gray-7">
|
||||
{{ __(text) }}
|
||||
</div>
|
||||
<Button variant="solid" class="w-full" @click="redirect()">
|
||||
|
||||
241
frontend/src/components/Notes/InlineLessonMenu.vue
Normal file
@@ -0,0 +1,241 @@
|
||||
<template>
|
||||
<div
|
||||
class="text-sm absolute bg-white border rounded-md z-10 w-44"
|
||||
:style="{
|
||||
display: top > 0 ? 'block' : 'none',
|
||||
top: top + 'px',
|
||||
left: left + 'px',
|
||||
}"
|
||||
>
|
||||
<div class="space-y-2 py-2">
|
||||
<div class="text-xs text-ink-gray-5 font-medium px-3">
|
||||
{{ __('Highlight') }}
|
||||
</div>
|
||||
<div class="">
|
||||
<div
|
||||
v-for="color in colors"
|
||||
class="flex items-center space-x-2 px-3 py-2 cursor-pointer hover:bg-surface-gray-2"
|
||||
@click="saveHighLight(color)"
|
||||
>
|
||||
<span
|
||||
class="size-3 rounded-full"
|
||||
:style="{
|
||||
backgroundColor: theme.backgroundColor[color.toLowerCase()][400],
|
||||
}"
|
||||
></span>
|
||||
<span>
|
||||
{{ __(color) }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="border-t">
|
||||
<div
|
||||
@click="addToNotes()"
|
||||
class="flex items-center space-x-2 hover:bg-surface-gray-2 cursor-pointer rounded-b-md py-2 px-3"
|
||||
>
|
||||
<NotepadText class="size-3 stroke-1.5" />
|
||||
<span>
|
||||
{{ __('Add to Notes') }}
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
v-if="highlightExists()"
|
||||
@click="deleteHighlight"
|
||||
class="flex items-center space-x-2 hover:bg-surface-gray-2 cursor-pointer rounded-b-md py-2 px-3"
|
||||
>
|
||||
<Trash2 class="size-3 stroke-1.5" />
|
||||
<span>
|
||||
{{ __('Remove Highlight') }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import { computed, inject, ref, watch } from 'vue'
|
||||
import { NotepadText, Trash2 } from 'lucide-vue-next'
|
||||
import { theme } from '@/utils/theme'
|
||||
import type { Note, Notes } from '@/components/Notes/types'
|
||||
import { blockQuotesClick, highlightText } from '@/utils'
|
||||
|
||||
const user = inject<any>('$user')
|
||||
const show = defineModel()
|
||||
const notes = defineModel<Notes>('notes')
|
||||
const top = ref(0)
|
||||
const left = ref(0)
|
||||
const currentSelection = ref<Selection | null>(null)
|
||||
const selectedText = ref('')
|
||||
const emit = defineEmits<{
|
||||
(e: 'updateNotes'): void
|
||||
}>()
|
||||
|
||||
const props = defineProps<{
|
||||
lesson: string
|
||||
}>()
|
||||
|
||||
watch(show, () => {
|
||||
if (!show.value) {
|
||||
return resetMenuPosition()
|
||||
}
|
||||
|
||||
currentSelection.value = window.getSelection()
|
||||
if (!currentSelection.value?.toString()) {
|
||||
return resetMenuPosition()
|
||||
}
|
||||
|
||||
updateMenuPosition()
|
||||
})
|
||||
|
||||
const updateMenuPosition = () => {
|
||||
selectedText.value = currentSelection.value?.toString() || ''
|
||||
const range = currentSelection.value?.getRangeAt(0)
|
||||
const rect = range?.getBoundingClientRect()
|
||||
if (!rect) return
|
||||
|
||||
const offsetY = window.scrollY
|
||||
const offsetX = window.scrollX
|
||||
|
||||
top.value = Math.floor(rect.top + offsetY - 40)
|
||||
left.value = Math.floor(rect.right + offsetX + 10)
|
||||
}
|
||||
|
||||
const resetMenuPosition = () => {
|
||||
top.value = 0
|
||||
left.value = 0
|
||||
}
|
||||
|
||||
const colors = computed(() => {
|
||||
return ['Red', 'Blue', 'Green', 'Yellow', 'Purple']
|
||||
})
|
||||
|
||||
const highlightExists = () => {
|
||||
return notes.value?.data?.some(
|
||||
(note: Note) => note.highlighted_text === selectedText.value
|
||||
)
|
||||
}
|
||||
|
||||
const saveHighLight = (color: string) => {
|
||||
if (!selectedText.value) return
|
||||
|
||||
notes.value?.insert.submit(
|
||||
{
|
||||
lesson: props.lesson,
|
||||
member: user?.data?.name,
|
||||
highlighted_text: selectedText.value,
|
||||
color: color,
|
||||
name: '',
|
||||
},
|
||||
{
|
||||
onSuccess(data: Note) {
|
||||
highlightText(data)
|
||||
resetStates()
|
||||
emit('updateNotes')
|
||||
},
|
||||
onError(err: any) {
|
||||
console.error('Error saving highlight:', err)
|
||||
resetStates()
|
||||
},
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
const deleteHighlight = () => {
|
||||
let notesToDelete = notes.value?.data.find(
|
||||
(note: Note) => note.highlighted_text === selectedText.value
|
||||
)
|
||||
if (!notesToDelete) return
|
||||
notes.value?.delete.submit(notesToDelete.name, {
|
||||
onSuccess() {
|
||||
resetStates()
|
||||
document.querySelectorAll('.highlighted-text').forEach((el) => {
|
||||
const element = el as HTMLElement
|
||||
if (element.dataset.name === notesToDelete.name) {
|
||||
element.style.backgroundColor = 'transparent'
|
||||
}
|
||||
})
|
||||
},
|
||||
onError(err: any) {
|
||||
console.error('Error deleting highlight:', err)
|
||||
resetStates()
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
const addToNotes = () => {
|
||||
if (!selectedText.value) return
|
||||
let noteToUpdate = notes.value?.data.find((note: Note) => {
|
||||
return !note.highlighted_text && note.note !== ''
|
||||
})
|
||||
if (!noteToUpdate) {
|
||||
createNote()
|
||||
} else {
|
||||
updateNote(noteToUpdate)
|
||||
}
|
||||
}
|
||||
|
||||
const createNote = () => {
|
||||
notes.value?.insert.submit(
|
||||
{
|
||||
lesson: props.lesson,
|
||||
member: user?.data?.name,
|
||||
note: `<blockquote><p>${selectedText.value}</p></blockquote><br>`,
|
||||
color: 'Yellow',
|
||||
name: '',
|
||||
},
|
||||
{
|
||||
onSuccess(data: Note) {
|
||||
emit('updateNotes')
|
||||
setTimeout(() => {
|
||||
scrollToText(selectedText.value)
|
||||
blockQuotesClick()
|
||||
resetStates()
|
||||
}, 100)
|
||||
},
|
||||
onError(err: any) {
|
||||
console.error('Error creating note:', err)
|
||||
resetStates()
|
||||
},
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
const updateNote = (noteToUpdate: Note) => {
|
||||
notes.value?.setValue.submit(
|
||||
{
|
||||
name: noteToUpdate.name,
|
||||
note: `${noteToUpdate.note}\n\n<blockquote><p>${selectedText.value}</p></blockquote><br>`,
|
||||
},
|
||||
{
|
||||
onSuccess(data: Note) {
|
||||
emit('updateNotes')
|
||||
setTimeout(() => {
|
||||
scrollToText(selectedText.value)
|
||||
blockQuotesClick()
|
||||
resetStates()
|
||||
}, 100)
|
||||
},
|
||||
onError(err: any) {
|
||||
console.error('Error updating note:', err)
|
||||
resetStates()
|
||||
},
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
const scrollToText = (text: string) => {
|
||||
const elements = document.querySelectorAll('blockquote p')
|
||||
Array.from(elements).forEach((el) => {
|
||||
const element = el as HTMLElement
|
||||
if (element.textContent?.toLowerCase().includes(text.toLowerCase())) {
|
||||
element.scrollIntoView({ behavior: 'smooth', block: 'center' })
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const resetStates = () => {
|
||||
selectedText.value = ''
|
||||
show.value = false
|
||||
resetMenuPosition()
|
||||
}
|
||||
</script>
|
||||
115
frontend/src/components/Notes/Notes.vue
Normal file
@@ -0,0 +1,115 @@
|
||||
<template>
|
||||
<div class="text-lg font-semibold mb-4 text-ink-gray-9">
|
||||
{{ __('My Notes') }}
|
||||
</div>
|
||||
<TextEditor
|
||||
:content="note"
|
||||
:placeholder="__('Make notes for quick revision. Press / for menu.')"
|
||||
@change="(val: string) => updateNoteText(val)"
|
||||
:editable="true"
|
||||
editorClass="prose prose-sm min-h-[200px] max-w-none"
|
||||
/>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import { TextEditor } from 'frappe-ui'
|
||||
import { useDebounceFn } from '@vueuse/core'
|
||||
import { inject, ref, onMounted, watch } from 'vue'
|
||||
import type { Note, Notes } from '@/components/Notes/types'
|
||||
import { blockQuotesClick } from '@/utils/'
|
||||
|
||||
const note = ref<string | null>(null)
|
||||
const currentNoteName = ref<string | null>(null)
|
||||
const user = inject<any>('$user')
|
||||
const notes = defineModel<Notes>('notes')
|
||||
const emit = defineEmits<{
|
||||
(e: 'updateNotes'): void
|
||||
}>()
|
||||
|
||||
const props = defineProps<{
|
||||
lesson: string
|
||||
}>()
|
||||
|
||||
onMounted(() => {
|
||||
updateCurrentNote()
|
||||
})
|
||||
|
||||
watch(
|
||||
() => notes.value?.data,
|
||||
() => {
|
||||
updateCurrentNote()
|
||||
blockQuotesClick()
|
||||
}
|
||||
)
|
||||
|
||||
const updateCurrentNote = () => {
|
||||
const currentNote = notes.value?.data?.filter((row: Note) => {
|
||||
return !row.highlighted_text && row.note !== ''
|
||||
})
|
||||
if (currentNote?.length === 0) {
|
||||
note.value = null
|
||||
currentNoteName.value = null
|
||||
return
|
||||
} else if (currentNote && currentNote.length > 0) {
|
||||
currentNoteName.value = currentNote[0].name
|
||||
note.value = currentNote[0].note || null
|
||||
}
|
||||
}
|
||||
|
||||
const updateNoteText = (val: string) => {
|
||||
note.value = val
|
||||
debouncedSave()
|
||||
}
|
||||
|
||||
const debouncedSave = useDebounceFn(() => {
|
||||
saveNotes()
|
||||
}, 2000)
|
||||
|
||||
const saveNotes = () => {
|
||||
if (currentNoteName.value) {
|
||||
updateNote()
|
||||
} else {
|
||||
createNote()
|
||||
}
|
||||
}
|
||||
|
||||
const createNote = () => {
|
||||
notes.value?.insert.submit(
|
||||
{
|
||||
lesson: props.lesson,
|
||||
member: user?.data?.name,
|
||||
note: note.value,
|
||||
color: 'Yellow',
|
||||
name: '',
|
||||
},
|
||||
{
|
||||
onSuccess(data: Note) {
|
||||
currentNoteName.value = data.name || null
|
||||
emit('updateNotes')
|
||||
},
|
||||
onError(err: any) {
|
||||
console.error('Error creating note:', err)
|
||||
},
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
const updateNote = () => {
|
||||
if (!currentNoteName.value) return
|
||||
notes.value?.setValue.submit(
|
||||
{
|
||||
name: currentNoteName.value,
|
||||
lesson: props.lesson,
|
||||
member: user?.data?.name,
|
||||
note: note.value,
|
||||
},
|
||||
{
|
||||
onSuccess(data: Note) {
|
||||
emit('updateNotes')
|
||||
},
|
||||
onError(err: any) {
|
||||
console.error('Error updating note:', err)
|
||||
},
|
||||
}
|
||||
)
|
||||
}
|
||||
</script>
|
||||
32
frontend/src/components/Notes/types.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
|
||||
export type Note = {
|
||||
highlighted_text?: string
|
||||
color?: string
|
||||
name: string
|
||||
note?: string | null
|
||||
lesson?: string
|
||||
member?: string
|
||||
}
|
||||
|
||||
export type Notes = {
|
||||
data: Note[]
|
||||
reload: () => void
|
||||
insert: {
|
||||
submit: (
|
||||
data: Note,
|
||||
options: { onSuccess: (data: Note) => void; onError: (err: any) => void }
|
||||
) => void
|
||||
}
|
||||
setValue: {
|
||||
submit: (
|
||||
data: Note,
|
||||
options: { onSuccess: (data: Note) => void; onError: (err: any) => void }
|
||||
) => void
|
||||
},
|
||||
delete: {
|
||||
submit: (
|
||||
data: Note | string,
|
||||
options?: { onSuccess: () => void; onError: (err: any) => void }
|
||||
) => void
|
||||
}
|
||||
}
|
||||
@@ -55,8 +55,8 @@
|
||||
|
||||
<div v-if="quiz.data.duration" class="flex flex-col space-x-1 my-4">
|
||||
<div class="mb-2">
|
||||
<span class=""> {{ __('Time') }}: </span>
|
||||
<span class="font-semibold">
|
||||
<span class="text-ink-gray-9"> {{ __('Time') }}: </span>
|
||||
<span class="font-semibold text-ink-gray-9">
|
||||
{{ formatTimer(timer) }}
|
||||
</span>
|
||||
</div>
|
||||
@@ -165,14 +165,14 @@
|
||||
</div>
|
||||
</div>
|
||||
<span
|
||||
class="ml-2"
|
||||
class="ml-2 text-ink-gray-9"
|
||||
v-html="questionDetails.data[`option_${index}`]"
|
||||
>
|
||||
</span>
|
||||
</label>
|
||||
<div
|
||||
v-if="questionDetails.data[`explanation_${index}`]"
|
||||
class="mt-2 text-xs"
|
||||
class="mt-2 text-xs text-ink-gray-7"
|
||||
v-show="showAnswers.length"
|
||||
>
|
||||
{{ questionDetails.data[`explanation_${index}`] }}
|
||||
@@ -260,7 +260,7 @@
|
||||
)
|
||||
}}
|
||||
</div>
|
||||
<div v-else>
|
||||
<div v-else class="text-ink-gray-7">
|
||||
{{
|
||||
__(
|
||||
'You got {0}% correct answers with a score of {1} out of {2}'
|
||||
@@ -574,7 +574,17 @@ const addToLocalStorage = () => {
|
||||
return answer != undefined
|
||||
}),
|
||||
}
|
||||
quizData ? quizData.push(questionData) : (quizData = [questionData])
|
||||
|
||||
if (quizData) {
|
||||
let existingQuestion = quizData.find(
|
||||
(q) => q.question_name == questionData.question_name
|
||||
)
|
||||
if (!existingQuestion) {
|
||||
quizData.push(questionData)
|
||||
}
|
||||
} else {
|
||||
quizData = [questionData]
|
||||
}
|
||||
localStorage.setItem(quiz.data.title, JSON.stringify(quizData))
|
||||
}
|
||||
|
||||
|
||||
@@ -69,9 +69,12 @@ const update = () => {
|
||||
let imageFields = ['favicon', 'banner_image']
|
||||
props.fields.forEach((f) => {
|
||||
if (imageFields.includes(f.name)) {
|
||||
fieldsToSave[f.name] = f.value ? f.value.file_url : null
|
||||
fieldsToSave[f.name] =
|
||||
branding.data[f.name] && branding.data[f.name].file_url
|
||||
? branding.data[f.name].file_url
|
||||
: null
|
||||
} else {
|
||||
fieldsToSave[f.name] = f.value
|
||||
fieldsToSave[f.name] = branding.data[f.name]
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -141,9 +141,6 @@ const props = defineProps({
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
show: {
|
||||
type: Boolean,
|
||||
},
|
||||
})
|
||||
|
||||
const evaluators = createListResource({
|
||||
|
||||
@@ -117,14 +117,7 @@
|
||||
</Dialog>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
Avatar,
|
||||
Badge,
|
||||
Button,
|
||||
createResource,
|
||||
Dialog,
|
||||
FormControl,
|
||||
} from 'frappe-ui'
|
||||
import { Avatar, Button, createResource, Dialog, FormControl } from 'frappe-ui'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { ref, watch, reactive, inject } from 'vue'
|
||||
import { RefreshCw, Plus, Search, Shield } from 'lucide-vue-next'
|
||||
@@ -146,7 +139,6 @@ const start = ref(0)
|
||||
const memberList = ref<Member[]>([])
|
||||
const hasNextPage = ref(false)
|
||||
const showForm = ref(false)
|
||||
const dayjs = inject('$dayjs')
|
||||
const user = inject<User | null>('$user')
|
||||
const { updateOnboardingStep } = useOnboarding('learning')
|
||||
|
||||
@@ -164,9 +156,6 @@ const props = defineProps({
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
show: {
|
||||
type: Boolean,
|
||||
},
|
||||
})
|
||||
|
||||
const members = createResource({
|
||||
|
||||
233
frontend/src/components/Settings/PaymentGatewayDetails.vue
Normal file
@@ -0,0 +1,233 @@
|
||||
<template>
|
||||
<Dialog
|
||||
v-model="show"
|
||||
:options="{
|
||||
title:
|
||||
gatewayID === 'new'
|
||||
? __('New Payment Gateway')
|
||||
: __('Edit Payment Gateway'),
|
||||
size: '3xl',
|
||||
}"
|
||||
>
|
||||
<template #body-content>
|
||||
<SettingFields
|
||||
v-if="gatewayID != 'new' && paymentGateway.data"
|
||||
:fields="paymentGateway.data.fields"
|
||||
:data="paymentGateway.data.data"
|
||||
class="pt-5 my-0"
|
||||
/>
|
||||
<div v-else>
|
||||
<FormControl
|
||||
v-model="newGateway"
|
||||
:label="__('Select Payment Gateway')"
|
||||
type="select"
|
||||
:options="allGatewayOptions"
|
||||
:required="true"
|
||||
/>
|
||||
<SettingFields
|
||||
v-if="newGateway"
|
||||
:fields="newGatewayFields"
|
||||
:data="newGatewayData"
|
||||
class="pt-5 my-0"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
<template #actions="{ close }">
|
||||
<div class="pb-5 float-right">
|
||||
<Button variant="solid" @click="saveSettings(close)">
|
||||
{{ __('Save') }}
|
||||
</Button>
|
||||
</div>
|
||||
</template>
|
||||
</Dialog>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
Button,
|
||||
call,
|
||||
createListResource,
|
||||
createResource,
|
||||
Dialog,
|
||||
FormControl,
|
||||
} from 'frappe-ui'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import SettingFields from '@/components/Settings/SettingFields.vue'
|
||||
|
||||
const show = defineModel<boolean>({ required: true, default: false })
|
||||
const paymentGateways = defineModel<any>('paymentGateways')
|
||||
const newGateway = ref(null)
|
||||
const newGatewayFields = ref([])
|
||||
const newGatewayData = ref<Record<string, any>>({})
|
||||
|
||||
const props = defineProps<{
|
||||
gatewayID: string | null
|
||||
}>()
|
||||
|
||||
const paymentGateway = createResource({
|
||||
url: 'lms.lms.api.get_payment_gateway_details',
|
||||
makeParams(values: any) {
|
||||
return {
|
||||
payment_gateway: props.gatewayID,
|
||||
}
|
||||
},
|
||||
transform(data: any) {
|
||||
arrangeFields(data.fields)
|
||||
return data
|
||||
},
|
||||
})
|
||||
|
||||
const allGateways = createListResource({
|
||||
doctype: 'DocType',
|
||||
filters: {
|
||||
module: 'Payment Gateways',
|
||||
},
|
||||
fields: ['name', 'issingle'],
|
||||
})
|
||||
|
||||
const gatewayFields = createResource({
|
||||
url: 'lms.lms.api.get_new_gateway_fields',
|
||||
makeParams(values: any) {
|
||||
return {
|
||||
doctype: values.doctype,
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
const arrangeFields = (fields: any[]) => {
|
||||
fields = fields.sort((a, b) => {
|
||||
if (a.type === 'Upload' && b.type !== 'Upload') {
|
||||
return 1
|
||||
} else if (a.type !== 'Upload' && b.type === 'Upload') {
|
||||
return -1
|
||||
}
|
||||
return 0
|
||||
})
|
||||
|
||||
fields.splice(3, 0, {
|
||||
type: 'Column Break',
|
||||
})
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.gatewayID,
|
||||
() => {
|
||||
if (props.gatewayID && props.gatewayID !== 'new') {
|
||||
paymentGateway.reload()
|
||||
} else if (props.gatewayID == 'new') {
|
||||
allGateways.reload()
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
const getNewGateway = () => {
|
||||
return allGateways.data?.find((gateway: any) =>
|
||||
gateway.name.includes(newGateway.value)
|
||||
)
|
||||
}
|
||||
|
||||
watch(newGateway, () => {
|
||||
let gatewayDoc = getNewGateway()
|
||||
gatewayFields.reload({ doctype: gatewayDoc.name }).then(() => {
|
||||
let fields = gatewayFields.data || []
|
||||
arrangeFields(fields)
|
||||
newGatewayFields.value = fields
|
||||
prepareGatewayData()
|
||||
})
|
||||
})
|
||||
|
||||
const saveSettings = (close: () => void) => {
|
||||
if (props.gatewayID === 'new') {
|
||||
saveNewGateway(close)
|
||||
} else {
|
||||
saveExistingGateway(
|
||||
paymentGateway.data.doctype,
|
||||
paymentGateway.data.docname,
|
||||
close
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const saveNewGateway = (close: () => void) => {
|
||||
let gatewayDoc = getNewGateway()
|
||||
if (gatewayDoc.issingle) {
|
||||
saveExistingGateway(gatewayDoc.name, gatewayDoc.name, close)
|
||||
} else {
|
||||
call('frappe.client.insert', {
|
||||
doc: {
|
||||
doctype: gatewayDoc.name,
|
||||
...newGatewayData.value,
|
||||
},
|
||||
}).then((data: any) => {
|
||||
paymentGateways.value.reload()
|
||||
close()
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const saveExistingGateway = (
|
||||
doctype: string,
|
||||
docname: string,
|
||||
close: () => void
|
||||
) => {
|
||||
call('frappe.client.set_value', {
|
||||
doctype: doctype,
|
||||
name: docname,
|
||||
fieldname: getGatewayFields(),
|
||||
}).then(() => {
|
||||
paymentGateways.value?.reload()
|
||||
close()
|
||||
})
|
||||
}
|
||||
|
||||
const getGatewayFields = () => {
|
||||
let data =
|
||||
props.gatewayID == 'new' ? newGatewayData.value : paymentGateway.data.data
|
||||
return Object.keys(data).reduce((fields: any, key: string) => {
|
||||
if (data[key] && typeof data[key] === 'object') {
|
||||
fields[key] = data[key].file_url
|
||||
} else {
|
||||
fields[key] = data[key]
|
||||
}
|
||||
return fields
|
||||
}, {})
|
||||
}
|
||||
|
||||
const createGatewayRecord = (gatewayDoc: any, data: any = {}) => {
|
||||
call('frappe.client.insert', {
|
||||
doc: {
|
||||
doctype: 'Payment Gateway',
|
||||
gateway: newGateway.value,
|
||||
gateway_controller: gatewayDoc.issingle ? '' : gatewayDoc.name,
|
||||
gateway_settings: gatewayDoc.issingle ? '' : data.name,
|
||||
},
|
||||
}).then(() => {
|
||||
paymentGateways.value?.reload()
|
||||
})
|
||||
}
|
||||
|
||||
const allGatewayOptions = computed(() => {
|
||||
let options: string[] = []
|
||||
let gatewayList = allGateways.data?.map((gateway: any) => gateway.name) || []
|
||||
gatewayList.forEach((gateway: any) => {
|
||||
let gatewayName = gateway.split(' ')[0]
|
||||
let existingGateways =
|
||||
paymentGateways.value?.data?.map((pg: any) => pg.name) || []
|
||||
if (
|
||||
!options.includes(gatewayName) &&
|
||||
!existingGateways.includes(gatewayName)
|
||||
) {
|
||||
options.push(gatewayName)
|
||||
}
|
||||
})
|
||||
return options.map((gateway: string) => ({ label: gateway, value: gateway }))
|
||||
})
|
||||
|
||||
const prepareGatewayData = () => {
|
||||
newGatewayData.value = {}
|
||||
if (newGatewayFields.value.length) {
|
||||
newGatewayFields.value.forEach((field: any) => {
|
||||
newGatewayData.value[field.fieldname] = field.default || ''
|
||||
})
|
||||
}
|
||||
}
|
||||
</script>
|
||||
140
frontend/src/components/Settings/PaymentGateways.vue
Normal file
@@ -0,0 +1,140 @@
|
||||
<template>
|
||||
<div class="flex min-h-0 flex-col text-base">
|
||||
<div class="flex items-center justify-between mb-5">
|
||||
<div>
|
||||
<div class="text-xl font-semibold mb-1 text-ink-gray-9">
|
||||
{{ __(label) }}
|
||||
</div>
|
||||
<div class="text-ink-gray-6 leading-5">
|
||||
{{ __(description) }}
|
||||
</div>
|
||||
</div>
|
||||
<Button @click="openForm('new')">
|
||||
<template #prefix>
|
||||
<Plus class="h-3 w-3 stroke-1.5" />
|
||||
</template>
|
||||
{{ __('New') }}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div v-if="paymentGateways.data?.length" class="overflow-y-scroll">
|
||||
<ListView
|
||||
:columns="columns"
|
||||
:rows="paymentGateways.data"
|
||||
row-key="name"
|
||||
:options="{
|
||||
showTooltip: false,
|
||||
onRowClick: (row) => {
|
||||
openForm(row.name)
|
||||
},
|
||||
}"
|
||||
>
|
||||
<ListHeader
|
||||
class="mb-2 grid items-center space-x-4 rounded bg-surface-gray-2 p-2"
|
||||
>
|
||||
<ListHeaderItem :item="item" v-for="item in columns">
|
||||
<template #prefix="{ item }">
|
||||
<FeatherIcon
|
||||
v-if="item.icon"
|
||||
:name="item.icon"
|
||||
class="h-4 w-4 stroke-1.5"
|
||||
/>
|
||||
</template>
|
||||
</ListHeaderItem>
|
||||
</ListHeader>
|
||||
|
||||
<ListRows>
|
||||
<ListRow :row="row" v-for="row in paymentGateways.data">
|
||||
<template #default="{ column, item }">
|
||||
<ListRowItem :item="row[column.key]" :align="column.align">
|
||||
<div v-if="column.key == 'enabled'">
|
||||
<Badge v-if="row[column.key]" theme="green">
|
||||
{{ __('Enabled') }}
|
||||
</Badge>
|
||||
<Badge v-else theme="gray">
|
||||
{{ __('Disabled') }}
|
||||
</Badge>
|
||||
</div>
|
||||
<div v-else class="leading-5 text-sm">
|
||||
{{ row[column.key] }}
|
||||
</div>
|
||||
</ListRowItem>
|
||||
</template>
|
||||
</ListRow>
|
||||
</ListRows>
|
||||
|
||||
<ListSelectBanner>
|
||||
<template #actions="{ unselectAll, selections }">
|
||||
<div class="flex gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
@click="removeAccount(selections, unselectAll)"
|
||||
>
|
||||
<Trash2 class="h-4 w-4 stroke-1.5" />
|
||||
</Button>
|
||||
</div>
|
||||
</template>
|
||||
</ListSelectBanner>
|
||||
</ListView>
|
||||
</div>
|
||||
</div>
|
||||
<PaymentGatewayDetails
|
||||
v-model="showForm"
|
||||
:gatewayID="currentGateway"
|
||||
v-model:paymentGateways="paymentGateways"
|
||||
/>
|
||||
</template>
|
||||
<script setup>
|
||||
import {
|
||||
Badge,
|
||||
Button,
|
||||
createListResource,
|
||||
FeatherIcon,
|
||||
ListView,
|
||||
ListHeader,
|
||||
ListHeaderItem,
|
||||
ListRows,
|
||||
ListRow,
|
||||
ListRowItem,
|
||||
ListSelectBanner,
|
||||
} from 'frappe-ui'
|
||||
import { computed, ref } from 'vue'
|
||||
import { Plus, Trash2 } from 'lucide-vue-next'
|
||||
import PaymentGatewayDetails from '@/components/Settings/PaymentGatewayDetails.vue'
|
||||
|
||||
const showForm = ref(false)
|
||||
const currentGateway = ref(null)
|
||||
|
||||
const props = defineProps({
|
||||
label: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
description: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
})
|
||||
|
||||
const paymentGateways = createListResource({
|
||||
doctype: 'Payment Gateway',
|
||||
fields: ['name', 'gateway_settings', 'gateway_controller'],
|
||||
auto: true,
|
||||
orderBy: 'modified desc',
|
||||
})
|
||||
|
||||
const openForm = (gatewayID) => {
|
||||
currentGateway.value = gatewayID
|
||||
showForm.value = true
|
||||
}
|
||||
|
||||
const columns = computed(() => {
|
||||
return [
|
||||
{
|
||||
label: __('Gateway'),
|
||||
key: 'name',
|
||||
icon: 'credit-card',
|
||||
},
|
||||
]
|
||||
})
|
||||
</script>
|
||||
@@ -1,128 +0,0 @@
|
||||
<template>
|
||||
<div class="flex flex-col h-full">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="text-xl font-semibold mb-1 text-ink-gray-9">
|
||||
{{ label }}
|
||||
</div>
|
||||
<!-- <Badge
|
||||
v-if="isDirty"
|
||||
:label="__('Not Saved')"
|
||||
variant="subtle"
|
||||
theme="orange"
|
||||
/> -->
|
||||
</div>
|
||||
<div class="overflow-y-scroll">
|
||||
<div class="flex flex-col divide-y">
|
||||
<SettingFields :fields="fields" :data="data.doc" />
|
||||
<SettingFields
|
||||
v-if="paymentGateway.data"
|
||||
:fields="paymentGateway.data.fields"
|
||||
:data="paymentGateway.data.data"
|
||||
class="pt-5 my-0"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-row-reverse mt-auto">
|
||||
<Button variant="solid" @click="update">
|
||||
{{ __('Update') }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<script setup>
|
||||
import SettingFields from '@/components/Settings/SettingFields.vue'
|
||||
import { createResource, Badge, Button } from 'frappe-ui'
|
||||
import { watch } from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
label: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
description: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
data: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
fields: {
|
||||
type: Array,
|
||||
required: true,
|
||||
},
|
||||
})
|
||||
|
||||
const paymentGateway = createResource({
|
||||
url: 'lms.lms.api.get_payment_gateway_details',
|
||||
makeParams(values) {
|
||||
return {
|
||||
payment_gateway: props.data.doc.payment_gateway,
|
||||
}
|
||||
},
|
||||
transform(data) {
|
||||
arrangeFields(data.fields)
|
||||
return data
|
||||
},
|
||||
auto: true,
|
||||
})
|
||||
|
||||
const arrangeFields = (fields) => {
|
||||
fields = fields.sort((a, b) => {
|
||||
if (a.type === 'Upload' && b.type !== 'Upload') {
|
||||
return 1
|
||||
} else if (a.type !== 'Upload' && b.type === 'Upload') {
|
||||
return -1
|
||||
}
|
||||
return 0
|
||||
})
|
||||
|
||||
fields.splice(3, 0, {
|
||||
type: 'Column Break',
|
||||
})
|
||||
}
|
||||
|
||||
const saveSettings = createResource({
|
||||
url: 'frappe.client.set_value',
|
||||
makeParams(values) {
|
||||
let fields = {}
|
||||
Object.keys(paymentGateway.data.data).forEach((key) => {
|
||||
if (
|
||||
paymentGateway.data.data[key] &&
|
||||
typeof paymentGateway.data.data[key] === 'object'
|
||||
) {
|
||||
fields[key] = paymentGateway.data.data[key].file_url
|
||||
} else {
|
||||
fields[key] = paymentGateway.data.data[key]
|
||||
}
|
||||
})
|
||||
|
||||
return {
|
||||
doctype: paymentGateway.data.doctype,
|
||||
name: paymentGateway.data.docname,
|
||||
fieldname: fields,
|
||||
}
|
||||
},
|
||||
auto: false,
|
||||
onSuccess(data) {
|
||||
paymentGateway.reload()
|
||||
},
|
||||
})
|
||||
|
||||
const update = () => {
|
||||
props.fields.forEach((f) => {
|
||||
if (f.type != 'Column Break') {
|
||||
props.data.doc[f.name] = f.value
|
||||
}
|
||||
})
|
||||
props.data.save.submit()
|
||||
saveSettings.submit()
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.data.doc.payment_gateway,
|
||||
() => {
|
||||
paymentGateway.reload()
|
||||
}
|
||||
)
|
||||
</script>
|
||||