Compare commits
879 Commits
feat-reply
...
v2.13.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1ecdbd9e06 | ||
|
|
a90e3d611c | ||
|
|
d49d638253 | ||
|
|
83338a56c0 | ||
|
|
562020de70 | ||
|
|
044907edeb | ||
|
|
cfa1aa87fc | ||
|
|
0ac32ee474 | ||
|
|
de0675f850 | ||
|
|
1c529790f2 | ||
|
|
40bcc4d572 | ||
|
|
58f109e79c | ||
|
|
cb324f6269 | ||
|
|
7cafaf5cbc | ||
|
|
a394952630 | ||
|
|
68e87f20aa | ||
|
|
64ed0b3e94 | ||
|
|
fcaaee958d | ||
|
|
29e356ff86 | ||
|
|
460edc7bc7 | ||
|
|
582c7af12d | ||
|
|
af533a7a2c | ||
|
|
acbede157f | ||
|
|
f63a627ff2 | ||
|
|
b1a0556c12 | ||
|
|
0097ede6ed | ||
|
|
b72774e54d | ||
|
|
3027a9e523 | ||
|
|
c3995952b3 | ||
|
|
ff1642382c | ||
|
|
cfe35e40da | ||
|
|
c3238a9f91 | ||
|
|
58f08bf065 | ||
|
|
d3ac6ea337 | ||
|
|
6649b7955f | ||
|
|
15a53d33e0 | ||
|
|
57f09542a2 | ||
|
|
fa384b391d | ||
|
|
12b138c39f | ||
|
|
420a5f39eb | ||
|
|
12c2666bd1 | ||
|
|
1ecbc2e3f9 | ||
|
|
e1a78382c3 | ||
|
|
dcf5c72cad | ||
|
|
2ebf6be609 | ||
|
|
4ce7019ce6 | ||
|
|
3faf814162 | ||
|
|
52bd9825d8 | ||
|
|
b6028e741c | ||
|
|
4ee1693434 | ||
|
|
cbc7892b25 | ||
|
|
a4fa2ef0b3 | ||
|
|
96de90cb5f | ||
|
|
dfb22c81c3 | ||
|
|
6a70ed18d8 | ||
|
|
629c237349 | ||
|
|
cf014bca3c | ||
|
|
9323d8e17d | ||
|
|
1ba63a2175 | ||
|
|
b5551fd8ba | ||
|
|
fac0038af8 | ||
|
|
ee6685e324 | ||
|
|
0fb18f995c | ||
|
|
61e13aa7cd | ||
|
|
acb8c6c500 | ||
|
|
f504841a5c | ||
|
|
fb3d8e4f7d | ||
|
|
be49ba6d04 | ||
|
|
24ffed11fb | ||
|
|
73754bd104 | ||
|
|
0c6029cbe8 | ||
|
|
a643e9ae83 | ||
|
|
08ac3948c3 | ||
|
|
78d289b9c0 | ||
|
|
3473bdb527 | ||
|
|
a7f8835222 | ||
|
|
d6441955fc | ||
|
|
67d265e864 | ||
|
|
17031f1df0 | ||
|
|
234a24baa2 | ||
|
|
9a58f4688b | ||
|
|
87c1c928ba | ||
|
|
493b8297ea | ||
|
|
4d16602190 | ||
|
|
89222b23c3 | ||
|
|
89a181c7d5 | ||
|
|
c0aecf30c1 | ||
|
|
fc8ef21802 | ||
|
|
2e1aac4931 | ||
|
|
c45da4313e | ||
|
|
3a1a843747 | ||
|
|
5e6160149f | ||
|
|
be66c563a8 | ||
|
|
92c380c74b | ||
|
|
c51e7b0037 | ||
|
|
e25f161980 | ||
|
|
000d9dbcef | ||
|
|
0dcfd7e482 | ||
|
|
e933012a34 | ||
|
|
71db3ae6da | ||
|
|
c5f091fae8 | ||
|
|
4e61d569ac | ||
|
|
2d5c76e106 | ||
|
|
2e0abad61c | ||
|
|
3ea52a4e41 | ||
|
|
c05e253b8d | ||
|
|
08b2063e45 | ||
|
|
4a8c8185c2 | ||
|
|
74ed7b3160 | ||
|
|
38e6e4345f | ||
|
|
8004982e2e | ||
|
|
e6a532a870 | ||
|
|
f90465210e | ||
|
|
4b3a71e424 | ||
|
|
5499e7294d | ||
|
|
619262aa97 | ||
|
|
693d2942aa | ||
|
|
b4cf62920c | ||
|
|
03636d6930 | ||
|
|
7c1e1c86c7 | ||
|
|
8a5eceaf05 | ||
|
|
720425d1fb | ||
|
|
1f105b9ae5 | ||
|
|
d43442be5c | ||
|
|
3360b114b4 | ||
|
|
94835b4117 | ||
|
|
e6ed0b21e5 | ||
|
|
37db021682 | ||
|
|
6014a5ccce | ||
|
|
c07207b564 | ||
|
|
fe1f78f8aa | ||
|
|
1709c6b658 | ||
|
|
d3583a2cfb | ||
|
|
634035fbc0 | ||
|
|
3c5b18411b | ||
|
|
82bb45a9ef | ||
|
|
373f3df196 | ||
|
|
6021f15bac | ||
|
|
da71fb2c23 | ||
|
|
8f6f35d7c1 | ||
|
|
7aa5f4d20b | ||
|
|
64b54b05a6 | ||
|
|
22b1f22df4 | ||
|
|
ae4e5539d7 | ||
|
|
dbd96329b5 | ||
|
|
c118ec7c4a | ||
|
|
7aab449502 | ||
|
|
cf166b3a57 | ||
|
|
da5910d40d | ||
|
|
8640ecf9be | ||
|
|
c4faceff30 | ||
|
|
01bd017bda | ||
|
|
d76357981b | ||
|
|
19b759e9fb | ||
|
|
df3bca6405 | ||
|
|
5cde79b5eb | ||
|
|
9b35cdbddc | ||
|
|
70ec22004a | ||
|
|
95ed77421a | ||
|
|
d64ec9817c | ||
|
|
ce01b7634f | ||
|
|
e0819f83bc | ||
|
|
f87d28c2f5 | ||
|
|
544b59744b | ||
|
|
467dfb831d | ||
|
|
4c4b4eaf55 | ||
|
|
227e5d00e5 | ||
|
|
73e9e384c8 | ||
|
|
5bebdcba68 | ||
|
|
1c2e52ae4b | ||
|
|
9377e89561 | ||
|
|
4cae05ecbe | ||
|
|
909dcfd51e | ||
|
|
2bd96a1f2a | ||
|
|
aca41080ee | ||
|
|
1c351696a9 | ||
|
|
51a8958aa6 | ||
|
|
777b8aed02 | ||
|
|
3672b90075 | ||
|
|
92c7e613db | ||
|
|
5c58b85a00 | ||
|
|
8af82daa37 | ||
|
|
224bb18d3e | ||
|
|
aab7bdcc20 | ||
|
|
c5ca428d98 | ||
|
|
af0cc7126b | ||
|
|
a085050d27 | ||
|
|
2442f35f56 | ||
|
|
ed79ea536b | ||
|
|
b3d0aecd14 | ||
|
|
5f43e67c0b | ||
|
|
49a765a9a6 | ||
|
|
4d82bc86e8 | ||
|
|
8fe02b83b8 | ||
|
|
9c9075606b | ||
|
|
53285a0d19 | ||
|
|
9cdeaebb47 | ||
|
|
a9cb52c68b | ||
|
|
f33e950e83 | ||
|
|
9c9b5963fe | ||
|
|
1597054cc9 | ||
|
|
deba6aa845 | ||
|
|
2d8ba3b84e | ||
|
|
e56b28abad | ||
|
|
eb350c5a20 | ||
|
|
961d5ec77b | ||
|
|
fa566514aa | ||
|
|
6e97449bf7 | ||
|
|
016dafb3c3 | ||
|
|
675bcc8956 | ||
|
|
aba4c034fc | ||
|
|
c76d8c582f | ||
|
|
f1cb0e6f3c | ||
|
|
d296687456 | ||
|
|
5b68001c94 | ||
|
|
736d79b8c9 | ||
|
|
98c0bd5f3e | ||
|
|
8b1d9bb5a9 | ||
|
|
289a0f9122 | ||
|
|
3cd08c80c8 | ||
|
|
3d82c36250 | ||
|
|
9b9af0215a | ||
|
|
2e4cf02737 | ||
|
|
438e9e1c47 | ||
|
|
36ded70eef | ||
|
|
ba78a15a1f | ||
|
|
93061194bb | ||
|
|
6d41e4e552 | ||
|
|
3b06968d0a | ||
|
|
fc81f1aa26 | ||
|
|
59d8848125 | ||
|
|
a067695f71 | ||
|
|
be870e8145 | ||
|
|
8a17dca351 | ||
|
|
1c9f636ad1 | ||
|
|
008cc66cdd | ||
|
|
b6bf9c0032 | ||
|
|
d295898674 | ||
|
|
4fdca4691a | ||
|
|
7c055af496 | ||
|
|
60a3da283e | ||
|
|
576258ec6e | ||
|
|
01120fbc48 | ||
|
|
ad07f883b5 | ||
|
|
bb9b179e05 | ||
|
|
11a9bff57d | ||
|
|
e18f0c9dad | ||
|
|
41ad3d00de | ||
|
|
b74c1670ca | ||
|
|
33c76e842f | ||
|
|
35a7cce283 | ||
|
|
e0f569c382 | ||
|
|
d8ab88be28 | ||
|
|
04552bdef6 | ||
|
|
ad5bf89b35 | ||
|
|
88b38dfd83 | ||
|
|
75e9ca395f | ||
|
|
6fb206cc4e | ||
|
|
62cb198492 | ||
|
|
9609329f01 | ||
|
|
c93808af94 | ||
|
|
58866260ec | ||
|
|
e6157ff411 | ||
|
|
8cca8920ee | ||
|
|
ab039dbd46 | ||
|
|
9853ab3fd9 | ||
|
|
dc2bf9f13e | ||
|
|
7c90ca4040 | ||
|
|
75a90e1f39 | ||
|
|
bc4b17cc3d | ||
|
|
8c454a333e | ||
|
|
cef4b70182 | ||
|
|
3cda563583 | ||
|
|
545326a02a | ||
|
|
14ce5d7e23 | ||
|
|
b6422d1046 | ||
|
|
7196bbe221 | ||
|
|
bed16c3726 | ||
|
|
d18ca232e3 | ||
|
|
d1200d0fa9 | ||
|
|
d1c88b306f | ||
|
|
7f2723f9cb | ||
|
|
8df4bef71a | ||
|
|
aa87622606 | ||
|
|
b91339fe28 | ||
|
|
17d4973ab8 | ||
|
|
3c12548420 | ||
|
|
20c10f1645 | ||
|
|
a7843e0e3a | ||
|
|
169ea4385f | ||
|
|
9549f3a3ed | ||
|
|
ba66c2549f | ||
|
|
76c3e630cc | ||
|
|
7a0b952638 | ||
|
|
5966a3edad | ||
|
|
d44c7cd9fc | ||
|
|
46553987ac | ||
|
|
45725f1f6e | ||
|
|
58369ba65e | ||
|
|
5ce67dda2e | ||
|
|
237ff8db07 | ||
|
|
7da608ed44 | ||
|
|
60f2e86b42 | ||
|
|
b5e67a25d2 | ||
|
|
9d2ef4929c | ||
|
|
050084e552 | ||
|
|
86e9739218 | ||
|
|
bd94890da7 | ||
|
|
965f6adb90 | ||
|
|
4979569cf3 | ||
|
|
5c21a0532a | ||
|
|
a2025c0571 | ||
|
|
e07aae3fb0 | ||
|
|
65d628ffc0 | ||
|
|
bf290bbf0a | ||
|
|
3c9059025b | ||
|
|
4b0413720b | ||
|
|
f8b4ff4bd3 | ||
|
|
3b8ff171f4 | ||
|
|
dec270a10b | ||
|
|
152a339c4e | ||
|
|
395fe700e0 | ||
|
|
ec25e895dc | ||
|
|
e02e4c7ab4 | ||
|
|
e69cc9af1a | ||
|
|
98b8464e1a | ||
|
|
0170fcc111 | ||
|
|
0be5439e81 | ||
|
|
63f857b8fc | ||
|
|
a3b8ed8f91 | ||
|
|
cdd46667f3 | ||
|
|
2f8acea988 | ||
|
|
75f0e5b9f1 | ||
|
|
ce51129e84 | ||
|
|
86aa8b0a2a | ||
|
|
aeae62a45c | ||
|
|
6b12df44a0 | ||
|
|
a710183bc7 | ||
|
|
669316ba14 | ||
|
|
6c18f9a02f | ||
|
|
363edb9a50 | ||
|
|
afbf64170a | ||
|
|
14f36d0c64 | ||
|
|
ceecab395b | ||
|
|
b8eb9fd717 | ||
|
|
230a52f06b | ||
|
|
3e82608d5f | ||
|
|
cf2c2345c3 | ||
|
|
05ebe4b787 | ||
|
|
a744a43d14 | ||
|
|
5abdbfec1f | ||
|
|
0335b3b4d0 | ||
|
|
703fafd6c3 | ||
|
|
b956c4e383 | ||
|
|
d0d1fb2c8c | ||
|
|
d18a6f6e73 | ||
|
|
2994144718 | ||
|
|
62ab853605 | ||
|
|
7f7986d77a | ||
|
|
61f01cc51b | ||
|
|
86af8c6301 | ||
|
|
f1b0fcfbfc | ||
|
|
ab5ce39645 | ||
|
|
685e09ce4b | ||
|
|
8ed4f775e5 | ||
|
|
a3a3085b1f | ||
|
|
ed97640107 | ||
|
|
a9e93a679b | ||
|
|
418c36c09f | ||
|
|
935f7f1f7b | ||
|
|
9a0056b6ca | ||
|
|
cd56da5d85 | ||
|
|
97d5d853fc | ||
|
|
8adfe247b2 | ||
|
|
afe7df2989 | ||
|
|
cdb028c69c | ||
|
|
eed330662b | ||
|
|
26db10bbe0 | ||
|
|
14230bd588 | ||
|
|
699c821edd | ||
|
|
27ca13ece6 | ||
|
|
6820dfc820 | ||
|
|
471e7d9229 | ||
|
|
e0855a2c1b | ||
|
|
6a0b37a4d4 | ||
|
|
f7fd6916e2 | ||
|
|
30e61f4b7c | ||
|
|
48b37d58d8 | ||
|
|
b8c3bdc0b4 | ||
|
|
e96f18df7c | ||
|
|
7d15527831 | ||
|
|
794c0e760b | ||
|
|
e46a60d00a | ||
|
|
819aac70fd | ||
|
|
ed7db2d7c5 | ||
|
|
a450c846a6 | ||
|
|
fa774b0db2 | ||
|
|
98a56f9117 | ||
|
|
cbc4b8c59d | ||
|
|
69d266e018 | ||
|
|
4bc3ac1665 | ||
|
|
e0de9d70de | ||
|
|
493bab8163 | ||
|
|
25a2d82e82 | ||
|
|
0183677494 | ||
|
|
7ae9244896 | ||
|
|
15330cb41d | ||
|
|
166996d77a | ||
|
|
4943e0e902 | ||
|
|
1db6a8bfda | ||
|
|
57f43b256a | ||
|
|
23b2e8d682 | ||
|
|
6e1d62340f | ||
|
|
63d613a88e | ||
|
|
70a4d16a8a | ||
|
|
9960507318 | ||
|
|
a84b225247 | ||
|
|
a1f938eaaf | ||
|
|
f7027e9cfd | ||
|
|
8164526763 | ||
|
|
2ecc07ee58 | ||
|
|
8edfe041c3 | ||
|
|
e3e54b0188 | ||
|
|
602d457212 | ||
|
|
8bd4a5448b | ||
|
|
2257c09228 | ||
|
|
dac8a3ecf2 | ||
|
|
288f85b2f3 | ||
|
|
d5d9e5e6e8 | ||
|
|
e194f2efea | ||
|
|
686839adc1 | ||
|
|
0c52b5a8ec | ||
|
|
f80a139c93 | ||
|
|
eeadd6910e | ||
|
|
eed16d9604 | ||
|
|
3745db6da4 | ||
|
|
4ef694a2ed | ||
|
|
279bb89ca9 | ||
|
|
0bc5714392 | ||
|
|
764f358708 | ||
|
|
bf43fd5079 | ||
|
|
6adc62c72a | ||
|
|
2cf894df59 | ||
|
|
72053dbf56 | ||
|
|
0cd50ff1b6 | ||
|
|
d3f443014c | ||
|
|
ecd56609a0 | ||
|
|
1af10b7f96 | ||
|
|
55170361c1 | ||
|
|
daa42f146d | ||
|
|
67ebd30836 | ||
|
|
ac282cebfb | ||
|
|
4aea074041 | ||
|
|
888ea5a911 | ||
|
|
f02b9c09e6 | ||
|
|
5e91553190 | ||
|
|
326c77cdb9 | ||
|
|
e10af413b2 | ||
|
|
d4a15ade98 | ||
|
|
b18a3cb5e1 | ||
|
|
96028c9f42 | ||
|
|
8625ac048a | ||
|
|
30934f9eba | ||
|
|
8349f47cbe | ||
|
|
9d3d93443f | ||
|
|
4b6d1c296c | ||
|
|
0a3a48759f | ||
|
|
407bea4ab9 | ||
|
|
3ba34f36eb | ||
|
|
8d1c03d4c1 | ||
|
|
ed739b25e2 | ||
|
|
807c9b2225 | ||
|
|
7d3dc8df90 | ||
|
|
aafb8948d2 | ||
|
|
4f2dd7654c | ||
|
|
9c2bebb3d9 | ||
|
|
e93e82b56c | ||
|
|
f783b981e5 | ||
|
|
b5a904354a | ||
|
|
ed7c30057c | ||
|
|
05e8513ad1 | ||
|
|
775f8db31e | ||
|
|
4b6d3fe968 | ||
|
|
e397295b5e | ||
|
|
1d16c46003 | ||
|
|
3ba8805413 | ||
|
|
63f4dc0caa | ||
|
|
965bdd7890 | ||
|
|
a99c41a07b | ||
|
|
81feee887c | ||
|
|
77433ebb7c | ||
|
|
d5614322c5 | ||
|
|
479ff037c6 | ||
|
|
8a74f495e7 | ||
|
|
231f2cbc14 | ||
|
|
1f466482f8 | ||
|
|
103ecef9f4 | ||
|
|
2744002390 | ||
|
|
fd26d2bcd1 | ||
|
|
a55da8149a | ||
|
|
631f69bd75 | ||
|
|
621556263b | ||
|
|
6fdd1a5f09 | ||
|
|
a2b3bc8c1f | ||
|
|
d932baf896 | ||
|
|
a9b469d3bf | ||
|
|
e2bd9401a6 | ||
|
|
c6ada95b9d | ||
|
|
330a2f632a | ||
|
|
bf6a7a85a7 | ||
|
|
e609153f4f | ||
|
|
bedb1dc8d3 | ||
|
|
7f2821f639 | ||
|
|
844f4b5e8d | ||
|
|
1e69ff7de8 | ||
|
|
e6d58721f0 | ||
|
|
7c077ace95 | ||
|
|
d03dd3d20d | ||
|
|
a780668aac | ||
|
|
c0998ca8b3 | ||
|
|
b7dd488886 | ||
|
|
cb9125632a | ||
|
|
9a776dabed | ||
|
|
180c8941a4 | ||
|
|
f6b83d3518 | ||
|
|
97712dbdc0 | ||
|
|
febf38a47a | ||
|
|
753ae01efc | ||
|
|
cef638e37a | ||
|
|
850069d380 | ||
|
|
a748e2c2db | ||
|
|
f38aebbc9c | ||
|
|
8e1b871f87 | ||
|
|
76ea4fc1ae | ||
|
|
36c7c10d94 | ||
|
|
5148fcf25b | ||
|
|
d00d152fdc | ||
|
|
7123736707 | ||
|
|
0ad685c262 | ||
|
|
f2bef08568 | ||
|
|
7651eb5f97 | ||
|
|
a0fc1b0a9e | ||
|
|
a62fd757d4 | ||
|
|
0094809273 | ||
|
|
2ea5858716 | ||
|
|
753e62b441 | ||
|
|
68a1d1e436 | ||
|
|
0d89f51d78 | ||
|
|
9670dfa916 | ||
|
|
2a19fbc3d2 | ||
|
|
e98d7d29f7 | ||
|
|
3c5918d485 | ||
|
|
65e9d164f5 | ||
|
|
9d0b120cde | ||
|
|
10ed37ec67 | ||
|
|
528ab8f796 | ||
|
|
30973eb78d | ||
|
|
2be7645c0c | ||
|
|
0075c44918 | ||
|
|
4a321440d9 | ||
|
|
b4cc0c6807 | ||
|
|
98c748359a | ||
|
|
5c51e01c78 | ||
|
|
650f81c22b | ||
|
|
3478f278ff | ||
|
|
d53123cf07 | ||
|
|
cf5a088f5e | ||
|
|
7d937eb024 | ||
|
|
4edaad53a1 | ||
|
|
8a2991c4fb | ||
|
|
0c9fdc6534 | ||
|
|
889bc2b1c7 | ||
|
|
7355be2a8b | ||
|
|
0f64da69c0 | ||
|
|
d9ad642a31 | ||
|
|
180140c13f | ||
|
|
e7d7cffbc5 | ||
|
|
29ccbddea8 | ||
|
|
944020ca6e | ||
|
|
4bc07100f5 | ||
|
|
7dec8f019f | ||
|
|
1a2cb0fc3c | ||
|
|
5dc3b62b94 | ||
|
|
4a4c8b4e7a | ||
|
|
c3390b9005 | ||
|
|
804fc8e391 | ||
|
|
5b5d779f25 | ||
|
|
3fcf037db2 | ||
|
|
324ede5523 | ||
|
|
e6e8718bb4 | ||
|
|
93e42fbd86 | ||
|
|
8a6adae89c | ||
|
|
e3ad0baeb7 | ||
|
|
953eb74235 | ||
|
|
b3c76e311c | ||
|
|
f41eb30f3c | ||
|
|
dbe236f75e | ||
|
|
7d4a9eaf45 | ||
|
|
4f7c3f14df | ||
|
|
f15fdcc42e | ||
|
|
44b36599c3 | ||
|
|
86713db75e | ||
|
|
d2491b81c0 | ||
|
|
b98f6369ae | ||
|
|
a2a210d82b | ||
|
|
5b120ad248 | ||
|
|
4ec57349f8 | ||
|
|
f48f437075 | ||
|
|
255990b022 | ||
|
|
ac44c59e50 | ||
|
|
9252920a79 | ||
|
|
719e471678 | ||
|
|
39bc141133 | ||
|
|
78698cfcbe | ||
|
|
64c02f14a9 | ||
|
|
2f68fc0d6e | ||
|
|
16570469e6 | ||
|
|
f2c14d09d4 | ||
|
|
7e81b9d45d | ||
|
|
a62b754d28 | ||
|
|
93859d6635 | ||
|
|
20fab8dbd3 | ||
|
|
e56c8cc5f8 | ||
|
|
13d0621881 | ||
|
|
13536aac51 | ||
|
|
ccb8721674 | ||
|
|
fbe1423edd | ||
|
|
d2713d7824 | ||
|
|
f24a15b4f8 | ||
|
|
bf74868bd4 | ||
|
|
6d75b8a3a2 | ||
|
|
055f917c61 | ||
|
|
d892a28069 | ||
|
|
838bc1fac2 | ||
|
|
a98d36513b | ||
|
|
2cfa6771ac | ||
|
|
4960c47377 | ||
|
|
a46306720b | ||
|
|
efcdba3a29 | ||
|
|
e7263d0566 | ||
|
|
5de7f5e283 | ||
|
|
c7bdf68bc6 | ||
|
|
998ff51c58 | ||
|
|
4eb9d84d8b | ||
|
|
5f000d8017 | ||
|
|
0cf9ad5228 | ||
|
|
71a526e7aa | ||
|
|
c5d9adb7fd | ||
|
|
05fdf163a9 | ||
|
|
de0a983033 | ||
|
|
44ee9b644f | ||
|
|
bd116c3e7b | ||
|
|
02e8a97f85 | ||
|
|
3525e4c90b | ||
|
|
e6d3819092 | ||
|
|
f15862cef4 | ||
|
|
86748b301d | ||
|
|
cc07dd849c | ||
|
|
63bcbb6506 | ||
|
|
83a1b03bb7 | ||
|
|
2905a6af1a | ||
|
|
4cc27adb8b | ||
|
|
2126b4f657 | ||
|
|
0ce7c74778 | ||
|
|
b9f6a23412 | ||
|
|
9ae96bd1fa | ||
|
|
e863abe37c | ||
|
|
80e9984db0 | ||
|
|
8f504a8043 | ||
|
|
60a917e60c | ||
|
|
a5d000f702 | ||
|
|
5317aa8fb5 | ||
|
|
39aa1d443d | ||
|
|
36c4e2f4dc | ||
|
|
8e7c1da7af | ||
|
|
4f1f7c3fc0 | ||
|
|
084eeba2ed | ||
|
|
4b4086afb3 | ||
|
|
6401497422 | ||
|
|
684601f31b | ||
|
|
d7d222842b | ||
|
|
b0bb7d32ca | ||
|
|
ff1bd91223 | ||
|
|
f59f6c617a | ||
|
|
af48ccfb57 | ||
|
|
4b9d3bd996 | ||
|
|
3dad3580bb | ||
|
|
53eb95612c | ||
|
|
8f687145be | ||
|
|
9c405edd09 | ||
|
|
fe791dc478 | ||
|
|
8f317d2f44 | ||
|
|
f4e581f6cb | ||
|
|
9671c4d63f | ||
|
|
42417621fa | ||
|
|
d3b3d85c84 | ||
|
|
b700013704 | ||
|
|
bac229c731 | ||
|
|
28043e634b | ||
|
|
b672108155 | ||
|
|
5e569ab0e6 | ||
|
|
b07940951c | ||
|
|
1f18ef4362 | ||
|
|
bf57a19e2c | ||
|
|
43a07e53a6 | ||
|
|
fbd83196fc | ||
|
|
465f4e1e96 | ||
|
|
43d409ce64 | ||
|
|
a5fc52ec29 | ||
|
|
a9b06575d0 | ||
|
|
3070cbed3c | ||
|
|
0845a6e2a3 | ||
|
|
041bae16e0 | ||
|
|
3313db844c | ||
|
|
3a5977a718 | ||
|
|
bcee74ce77 | ||
|
|
1a6a119f35 | ||
|
|
09ae61492f | ||
|
|
3a33f047f5 | ||
|
|
10cdd712d2 | ||
|
|
21959eef7b | ||
|
|
41c3522285 | ||
|
|
d712881e16 | ||
|
|
991dc7f8c8 | ||
|
|
7087fde686 | ||
|
|
a6ae5e0675 | ||
|
|
cbd5ae9969 | ||
|
|
7389d080b6 | ||
|
|
8defd664c5 | ||
|
|
6b6c8da785 | ||
|
|
e1d61c9eb9 | ||
|
|
afcb15148f | ||
|
|
f40fbaed3e | ||
|
|
5adb36deaf | ||
|
|
4973386dd0 | ||
|
|
13536b8bad | ||
|
|
caea7e334c | ||
|
|
4065b1b8cc | ||
|
|
b248774774 | ||
|
|
7a9d6325d5 | ||
|
|
b0d0b41502 | ||
|
|
30c89cb13c | ||
|
|
eb3afbbad1 | ||
|
|
9175737b9c | ||
|
|
7ae772205a | ||
|
|
00b0a20c83 | ||
|
|
6604866342 | ||
|
|
881c3d943a | ||
|
|
fbe219a888 | ||
|
|
5928b8e5f9 | ||
|
|
372425bed2 | ||
|
|
d2922fd361 | ||
|
|
d5118cc91f | ||
|
|
ac74cbdf72 | ||
|
|
01f7fc3cff | ||
|
|
85c850e5bf | ||
|
|
e7b6001e5f | ||
|
|
4053984ca2 | ||
|
|
a1e06bf316 | ||
|
|
67dfffdd58 | ||
|
|
c50f2147fd | ||
|
|
d4671fb888 | ||
|
|
ae4aadb8d3 | ||
|
|
e5dc2bad6a | ||
|
|
77cda10419 | ||
|
|
6de879cd2a | ||
|
|
0e2fabf139 | ||
|
|
c45a372e83 | ||
|
|
25f24b98c6 | ||
|
|
98ecb4c27c | ||
|
|
9023094326 | ||
|
|
497de05db2 | ||
|
|
11079dae00 | ||
|
|
d00da31f84 | ||
|
|
644fb698d8 | ||
|
|
92edb3a1bf | ||
|
|
cb3224664e | ||
|
|
9b532a5470 | ||
|
|
f1f9d9790b | ||
|
|
96190910a7 | ||
|
|
6484763d37 | ||
|
|
0e2feac81e | ||
|
|
6f1e7624ec | ||
|
|
eef5bd6062 | ||
|
|
63bcf15900 | ||
|
|
25bcd10e93 | ||
|
|
de60fbb25a | ||
|
|
fd9a638879 | ||
|
|
ddcb718a3a | ||
|
|
a17a7453e7 | ||
|
|
479be0b8ee | ||
|
|
6f40c357b3 | ||
|
|
81db6c544d | ||
|
|
be4e3aa963 | ||
|
|
6da0c07a3d | ||
|
|
b4ad10ca35 | ||
|
|
2388b878dc | ||
|
|
8cdaa7877a | ||
|
|
d314287883 | ||
|
|
b70dfc8e82 | ||
|
|
0a784766b4 | ||
|
|
a5a7184f9a | ||
|
|
4e019d0a43 | ||
|
|
8453b54360 | ||
|
|
9f9dfdb26d | ||
|
|
9fd4984247 | ||
|
|
9ebd64f47d | ||
|
|
4316a37ed6 | ||
|
|
2d745460e8 | ||
|
|
b5258b6d9f | ||
|
|
41b076c0db | ||
|
|
9d65e5e398 | ||
|
|
7250bf7d65 | ||
|
|
4d7b247378 | ||
|
|
0aaa58cd54 | ||
|
|
014b85f12c | ||
|
|
929f97cb72 | ||
|
|
de9cb935ee | ||
|
|
9aafc176e4 | ||
|
|
0488ae8305 | ||
|
|
60fd317d98 | ||
|
|
e54435d85d | ||
|
|
3a23b91c90 | ||
|
|
69591577bf | ||
|
|
e56afba6d3 | ||
|
|
98536ce4c7 | ||
|
|
05282178dd | ||
|
|
1af547288c | ||
|
|
b4af82acbc | ||
|
|
50fbe00d23 | ||
|
|
b44428677e | ||
|
|
d67faa1610 | ||
|
|
7b3f4c29d8 | ||
|
|
a49871c5b1 | ||
|
|
e4005792af | ||
|
|
8c0c09a21b | ||
|
|
a9b05f4256 | ||
|
|
cb6013a7a6 | ||
|
|
bb23b78a4f | ||
|
|
243277012f | ||
|
|
c9ed8a4b03 | ||
|
|
d413acaef3 | ||
|
|
d6aad6cd74 | ||
|
|
ca45e43003 | ||
|
|
ad39530705 | ||
|
|
a6c2378b56 | ||
|
|
c073d2201d | ||
|
|
6d70de2eb1 | ||
|
|
48982e8f4a | ||
|
|
397128f980 | ||
|
|
1d77fd3f94 | ||
|
|
60e78e8e74 | ||
|
|
4a9ccc6fde | ||
|
|
a707095fae | ||
|
|
d4f662f65e | ||
|
|
509b1365d9 | ||
|
|
d0b236e381 | ||
|
|
fe98265636 | ||
|
|
3f7d1b1e83 | ||
|
|
52cde329c1 | ||
|
|
68b2dd6147 | ||
|
|
5fa0d022dc | ||
|
|
d996a5c53f | ||
|
|
b6dfc6ed4d | ||
|
|
c7c2ba83f3 | ||
|
|
2bffabff05 | ||
|
|
697e81df10 | ||
|
|
f1b791845b | ||
|
|
6310845cdd | ||
|
|
230cca63f3 | ||
|
|
af9f4d4b1e | ||
|
|
0111ff9c99 | ||
|
|
12bec14c92 | ||
|
|
174ea1ddd4 | ||
|
|
038a7463e1 | ||
|
|
a702909216 | ||
|
|
8effd5614f | ||
|
|
1046d28092 | ||
|
|
7678b89995 |
40
.github/helper/update_pot_file.sh
vendored
Normal file
40
.github/helper/update_pot_file.sh
vendored
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
set -e
|
||||||
|
cd ~ || exit
|
||||||
|
|
||||||
|
echo "Setting Up Bench..."
|
||||||
|
|
||||||
|
pip install frappe-bench
|
||||||
|
bench -v init frappe-bench --skip-assets --skip-redis-config-generation --python "$(which python)" --frappe-branch "${BASE_BRANCH}"
|
||||||
|
cd ./frappe-bench || exit
|
||||||
|
|
||||||
|
echo "Get LMS..."
|
||||||
|
bench get-app --skip-assets lms "${GITHUB_WORKSPACE}"
|
||||||
|
|
||||||
|
echo "Generating POT file..."
|
||||||
|
bench generate-pot-file --app lms
|
||||||
|
|
||||||
|
cd ./apps/lms || exit
|
||||||
|
|
||||||
|
echo "Configuring git user..."
|
||||||
|
git config user.email "developers@erpnext.com"
|
||||||
|
git config user.name "frappe-pr-bot"
|
||||||
|
|
||||||
|
echo "Setting the correct git remote..."
|
||||||
|
# Here, the git remote is a local file path by default. Let's change it to the upstream repo.
|
||||||
|
git remote set-url upstream https://github.com/frappe/lms.git
|
||||||
|
|
||||||
|
echo "Creating a new branch..."
|
||||||
|
isodate=$(date -u +"%Y-%m-%d")
|
||||||
|
branch_name="pot_${BASE_BRANCH}_${isodate}"
|
||||||
|
git checkout -b "${branch_name}"
|
||||||
|
|
||||||
|
echo "Commiting changes..."
|
||||||
|
git add lms/locale/main.pot
|
||||||
|
git commit -m "chore: update POT file"
|
||||||
|
|
||||||
|
gh auth setup-git
|
||||||
|
git push -u upstream "${branch_name}"
|
||||||
|
|
||||||
|
echo "Creating a PR..."
|
||||||
|
gh pr create --fill --base "${BASE_BRANCH}" --head "${branch_name}" -R frappe/lms
|
||||||
34
.github/workflows/generate-pot-file.yml
vendored
Normal file
34
.github/workflows/generate-pot-file.yml
vendored
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
name: Regenerate POT file (translatable strings)
|
||||||
|
on:
|
||||||
|
schedule:
|
||||||
|
- cron: "00 16 * * 5"
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
regenerate-pot-file:
|
||||||
|
name: Release
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
strategy:
|
||||||
|
fail-fast: false
|
||||||
|
matrix:
|
||||||
|
branch: ["develop"]
|
||||||
|
permissions:
|
||||||
|
contents: write
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
ref: ${{ matrix.branch }}
|
||||||
|
|
||||||
|
- name: Setup Python
|
||||||
|
uses: actions/setup-python@v5
|
||||||
|
with:
|
||||||
|
python-version: "3.12"
|
||||||
|
|
||||||
|
- name: Run script to update POT file
|
||||||
|
run: |
|
||||||
|
bash ${GITHUB_WORKSPACE}/.github/helper/update_pot_file.sh
|
||||||
|
env:
|
||||||
|
GH_TOKEN: ${{ secrets.RELEASE_TOKEN }}
|
||||||
|
BASE_BRANCH: ${{ matrix.branch }}
|
||||||
2
.github/workflows/linters.yml
vendored
2
.github/workflows/linters.yml
vendored
@@ -30,4 +30,4 @@ jobs:
|
|||||||
run: pip install semgrep
|
run: pip install semgrep
|
||||||
|
|
||||||
- name: Run Semgrep rules
|
- name: Run Semgrep rules
|
||||||
run: semgrep ci --config ./frappe-semgrep-rules/rules
|
run: semgrep ci --config ./frappe-semgrep-rules/rules
|
||||||
27
.github/workflows/make_release_pr.yml
vendored
Normal file
27
.github/workflows/make_release_pr.yml
vendored
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
name: Create weekly release
|
||||||
|
on:
|
||||||
|
schedule:
|
||||||
|
# 13:00 UTC -> 7pm IST on every Wednesday
|
||||||
|
- cron: '30 4 * * 3'
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
release:
|
||||||
|
name: Release
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
strategy:
|
||||||
|
fail-fast: false
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- uses: octokit/request-action@v2.x
|
||||||
|
with:
|
||||||
|
route: POST /repos/{owner}/{repo}/pulls
|
||||||
|
owner: frappe
|
||||||
|
repo: lms
|
||||||
|
title: |-
|
||||||
|
"chore: merge 'develop' into 'main'"
|
||||||
|
body: "Automated weekly release"
|
||||||
|
base: main
|
||||||
|
head: develop
|
||||||
|
env:
|
||||||
|
GITHUB_TOKEN: ${{ secrets.RELEASE_TOKEN }}
|
||||||
32
.github/workflows/on_release.yml
vendored
Normal file
32
.github/workflows/on_release.yml
vendored
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
name: Generate Semantic Release
|
||||||
|
on:
|
||||||
|
workflow_dispatch:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
jobs:
|
||||||
|
release:
|
||||||
|
name: Release
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout Entire Repository
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
persist-credentials: false
|
||||||
|
- name: Setup Node.js
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: 20
|
||||||
|
- name: Setup dependencies
|
||||||
|
run: |
|
||||||
|
npm install @semantic-release/git @semantic-release/exec --no-save
|
||||||
|
- name: Create Release
|
||||||
|
env:
|
||||||
|
GH_TOKEN: ${{ secrets.RELEASE_TOKEN }}
|
||||||
|
GITHUB_TOKEN: ${{ secrets.RELEASE_TOKEN }}
|
||||||
|
GIT_AUTHOR_NAME: "Frappe PR Bot"
|
||||||
|
GIT_AUTHOR_EMAIL: "developers@frappe.io"
|
||||||
|
GIT_COMMITTER_NAME: "Frappe PR Bot"
|
||||||
|
GIT_COMMITTER_EMAIL: "developers@frappe.io"
|
||||||
|
run: npx semantic-release
|
||||||
39
.github/workflows/release_notes.yml
vendored
Normal file
39
.github/workflows/release_notes.yml
vendored
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
# This action:
|
||||||
|
#
|
||||||
|
# 1. Generates release notes using github API.
|
||||||
|
# 2. Strips unnecessary info like chore/style etc from notes.
|
||||||
|
# 3. Updates release info.
|
||||||
|
|
||||||
|
name: 'Release Notes'
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_dispatch:
|
||||||
|
inputs:
|
||||||
|
tag_name:
|
||||||
|
description: 'Tag of release like v2.0.0'
|
||||||
|
required: true
|
||||||
|
type: string
|
||||||
|
release:
|
||||||
|
types: [released]
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
regen-notes:
|
||||||
|
name: 'Regenerate release notes'
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Update notes
|
||||||
|
run: |
|
||||||
|
NEW_NOTES=$(gh api --method POST -H "Accept: application/vnd.github+json" /repos/frappe/lms/releases/generate-notes -f tag_name=$RELEASE_TAG \
|
||||||
|
| jq -r '.body' \
|
||||||
|
| sed -E '/^\* (chore|ci|test|docs|style)/d' \
|
||||||
|
| sed -E 's/by @mergify //'
|
||||||
|
)
|
||||||
|
RELEASE_ID=$(gh api -H "Accept: application/vnd.github+json" /repos/frappe/lms/releases/tags/$RELEASE_TAG | jq -r '.id')
|
||||||
|
gh api --method PATCH -H "Accept: application/vnd.github+json" /repos/frappe/lms/releases/$RELEASE_ID -f body="$NEW_NOTES"
|
||||||
|
env:
|
||||||
|
GH_TOKEN: ${{ secrets.RELEASE_TOKEN }}
|
||||||
|
RELEASE_TAG: ${{ github.event.inputs.tag_name || github.event.release.tag_name }}
|
||||||
1
.github/workflows/ui-tests.yml
vendored
1
.github/workflows/ui-tests.yml
vendored
@@ -99,6 +99,7 @@ jobs:
|
|||||||
cd ~/frappe-bench/
|
cd ~/frappe-bench/
|
||||||
bench --site lms.test execute frappe.utils.install.complete_setup_wizard
|
bench --site lms.test execute frappe.utils.install.complete_setup_wizard
|
||||||
bench --site lms.test execute frappe.tests.ui_test_helpers.create_test_user
|
bench --site lms.test execute frappe.tests.ui_test_helpers.create_test_user
|
||||||
|
bench --site lms.test set-password frappe@example.com admin
|
||||||
|
|
||||||
- name: cypress pre-requisites
|
- name: cypress pre-requisites
|
||||||
run: |
|
run: |
|
||||||
|
|||||||
5
.gitignore
vendored
5
.gitignore
vendored
@@ -9,4 +9,7 @@ __pycache__/
|
|||||||
*.py[cod]
|
*.py[cod]
|
||||||
*$py.class
|
*$py.class
|
||||||
node_modules
|
node_modules
|
||||||
package-lock.json
|
package-lock.json
|
||||||
|
lms/public/frontend
|
||||||
|
lms/www/lms.html
|
||||||
|
frappe-ui
|
||||||
3
.gitmodules
vendored
Normal file
3
.gitmodules
vendored
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
[submodule "frappe-ui"]
|
||||||
|
path = frappe-ui
|
||||||
|
url = https://github.com/frappe/frappe-ui
|
||||||
@@ -32,7 +32,7 @@ repos:
|
|||||||
rev: v2.7.1
|
rev: v2.7.1
|
||||||
hooks:
|
hooks:
|
||||||
- id: prettier
|
- id: prettier
|
||||||
types_or: [javascript]
|
types_or: [javascript, vue]
|
||||||
# Ignore any files that might contain jinja / bundles
|
# Ignore any files that might contain jinja / bundles
|
||||||
exclude: |
|
exclude: |
|
||||||
(?x)^(
|
(?x)^(
|
||||||
|
|||||||
21
.releaserc
Normal file
21
.releaserc
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
{
|
||||||
|
"branches": ["develop"],
|
||||||
|
"plugins": [
|
||||||
|
"@semantic-release/commit-analyzer", {
|
||||||
|
"preset": "angular"
|
||||||
|
},
|
||||||
|
"@semantic-release/release-notes-generator",
|
||||||
|
[
|
||||||
|
"@semantic-release/exec", {
|
||||||
|
"prepareCmd": 'sed -ir "s/[0-9]*\.[0-9]*\.[0-9]*/${nextRelease.version}/" lms/__init__.py'
|
||||||
|
}
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"@semantic-release/git", {
|
||||||
|
"assets": ["lms/__init__.py"],
|
||||||
|
"message": "chore(release): Bumped to Version ${nextRelease.version}"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"@semantic-release/github"
|
||||||
|
]
|
||||||
|
}
|
||||||
10
README.md
10
README.md
@@ -1,6 +1,6 @@
|
|||||||
<p align="center">
|
<p align="center">
|
||||||
<a href="https://www.frappelms.com/">
|
<a href="https://www.frappelms.com/">
|
||||||
<img src="https://frappelms.com/files/lms-logo-medium.png" alt="Frappe LMS" width="120px" height="25px">
|
<img src="https://frappe.io/files/lms.png" alt="Frappe LMS" width="50px" height="50px">
|
||||||
</a>
|
</a>
|
||||||
<p align="center">Easy to use, open source, learning management system.</p>
|
<p align="center">Easy to use, open source, learning management system.</p>
|
||||||
</p>
|
</p>
|
||||||
@@ -75,7 +75,13 @@ cd apps/lms/docker
|
|||||||
docker-compose up
|
docker-compose up
|
||||||
```
|
```
|
||||||
|
|
||||||
Wait for some time until the setup script creates a site. After that, you can access `http://localhost:8000` in your browser and the app's login screen should show up.
|
Wait for some time until the setup script creates a site. After that, you can access `http://localhost:8000` in your browser and the app's login screen should appear.
|
||||||
|
You'll have to go through the setup wizard to set up the website the first time you access it. Log in using the following credentials to complete the setup wizard.
|
||||||
|
|
||||||
|
```
|
||||||
|
Username: Administrator
|
||||||
|
password: admin
|
||||||
|
```
|
||||||
|
|
||||||
### Frappe Bench
|
### Frappe Bench
|
||||||
|
|
||||||
|
|||||||
5
SECURITY.md
Normal file
5
SECURITY.md
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
# Security Policy
|
||||||
|
|
||||||
|
The Frappe team and community take security issues seriously. To report a security issue, please go through the information mentioned [here](https://frappe.io/security).
|
||||||
|
|
||||||
|
We appreciate your efforts to responsibly disclose your findings. We'll endeavor to respond quickly and will keep you updated throughout the process.
|
||||||
8
crowdin.yml
Normal file
8
crowdin.yml
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
files:
|
||||||
|
- source: /lms/locale/main.pot
|
||||||
|
translation: /lms/locale/%two_letters_code%.po
|
||||||
|
pull_request_title: "chore: sync translations from crowdin"
|
||||||
|
pull_request_labels:
|
||||||
|
- translation
|
||||||
|
commit_message: "chore: %language% translations"
|
||||||
|
append_commit_message: false
|
||||||
@@ -13,6 +13,6 @@ module.exports = defineConfig({
|
|||||||
openMode: 0,
|
openMode: 0,
|
||||||
},
|
},
|
||||||
e2e: {
|
e2e: {
|
||||||
baseUrl: "http://dd1:8000",
|
baseUrl: "http://lms1:8000",
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,133 +1,159 @@
|
|||||||
describe("Course Creation", () => {
|
describe("Course Creation", () => {
|
||||||
it("creates a new course", () => {
|
it("creates a new course", () => {
|
||||||
cy.login();
|
cy.login();
|
||||||
cy.visit("/courses");
|
cy.wait(1000);
|
||||||
|
cy.visit("/lms/courses");
|
||||||
|
|
||||||
// Create a course
|
// Create a course
|
||||||
cy.get("a.btn").contains("Create a Course").click();
|
cy.get("header").children().last().children().last().click();
|
||||||
cy.wait(1000);
|
|
||||||
cy.url().should("include", "/courses/new-course/edit");
|
|
||||||
cy.get("#title").type("Test Course");
|
|
||||||
cy.get("#intro").type("Test Course Short Introduction");
|
|
||||||
cy.get("#description").type("Test Course Description");
|
|
||||||
cy.get("#video-link").type("-LPmw2Znl2c");
|
|
||||||
cy.get("#tags-input").type("Test");
|
|
||||||
cy.get("#published").check();
|
|
||||||
cy.wait(1000);
|
cy.wait(1000);
|
||||||
|
cy.url().should("include", "/courses/new/edit");
|
||||||
|
|
||||||
|
cy.get("label").contains("Title").type("Test Course");
|
||||||
|
cy.get("label")
|
||||||
|
.contains("Short Introduction")
|
||||||
|
.type("Test Course Short Introduction to test the UI");
|
||||||
|
cy.get("div[contenteditable=true").invoke(
|
||||||
|
"text",
|
||||||
|
"Test Course Description. I need a very big description to test the UI. This is a very big description. It contains more than once sentence. Its meant to be this long as this is a UI test. Its unbearably long and I'm not sure why I'm typing this much. I'm just going to keep typing until I feel like its long enough. I think its long enough now. I'm going to stop typing now."
|
||||||
|
);
|
||||||
|
|
||||||
|
cy.fixture("profile.png", "base64").then((fileContent) => {
|
||||||
|
cy.get('input[type="file"]').attachFile({
|
||||||
|
fileContent,
|
||||||
|
fileName: "profile.png",
|
||||||
|
mimeType: "image/png",
|
||||||
|
encoding: "base64",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
cy.get("label")
|
||||||
|
.contains("Preview Video")
|
||||||
|
.type("https://www.youtube.com/embed/-LPmw2Znl2c");
|
||||||
|
cy.get("[id=tags]").type("Learning{enter}Frappe{enter}ERPNext{enter}");
|
||||||
|
cy.get("label")
|
||||||
|
.contains("Category")
|
||||||
|
.parent()
|
||||||
|
.within(() => {
|
||||||
|
cy.get("button").click();
|
||||||
|
});
|
||||||
|
cy.get("[id^=headlessui-combobox-option-")
|
||||||
|
.should("be.visible")
|
||||||
|
.first()
|
||||||
|
.click();
|
||||||
|
|
||||||
|
/* Instructor */
|
||||||
|
cy.get("label")
|
||||||
|
.contains("Instructors")
|
||||||
|
.parent()
|
||||||
|
.within(() => {
|
||||||
|
cy.get("input").click().type("frappe");
|
||||||
|
cy.get("input")
|
||||||
|
.invoke("attr", "aria-controls")
|
||||||
|
.as("instructor_list_id");
|
||||||
|
});
|
||||||
|
cy.get("@instructor_list_id").then((instructor_list_id) => {
|
||||||
|
cy.get(`[id^=${instructor_list_id}`)
|
||||||
|
.should("be.visible")
|
||||||
|
.within(() => {
|
||||||
|
cy.get("[id^=headlessui-combobox-option-").first().click();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
cy.get("label").contains("Published").click();
|
||||||
|
cy.get("label").contains("Published On").type("2021-01-01");
|
||||||
cy.button("Save").click();
|
cy.button("Save").click();
|
||||||
|
|
||||||
// Add Chapter
|
// Add Chapter
|
||||||
cy.wait(1000);
|
cy.wait(1000);
|
||||||
cy.link("Course Outline").click();
|
cy.button("Add Chapter").click();
|
||||||
|
|
||||||
cy.wait(1000);
|
cy.wait(1000);
|
||||||
cy.get(".edit-header .btn-add-chapter").click();
|
cy.get("[id^=headlessui-dialog-panel-")
|
||||||
cy.wait(500);
|
.should("be.visible")
|
||||||
cy.get("#chapter-title").type("Test Chapter");
|
.within(() => {
|
||||||
cy.get("#chapter-description").type("Test Chapter Description");
|
cy.get("label").contains("Title").type("Test Chapter");
|
||||||
cy.button("Save").click();
|
cy.button("Create").click();
|
||||||
|
});
|
||||||
|
|
||||||
// Add Lesson
|
// Add Lesson
|
||||||
cy.wait(1000);
|
cy.wait(1000);
|
||||||
cy.link("Add Lesson").click();
|
cy.button("Add Lesson").click();
|
||||||
|
cy.wait(1000);
|
||||||
|
cy.url().should("include", "/learn/1-1/edit");
|
||||||
cy.wait(1000);
|
cy.wait(1000);
|
||||||
cy.get("#lesson-title").type("Test Lesson");
|
|
||||||
|
|
||||||
// Content
|
cy.get("label").contains("Title").type("Test Lesson");
|
||||||
cy.get(".collapse-section.collapsed:first").click();
|
|
||||||
cy.get("#lesson-content .ce-block")
|
cy.get("#content .ce-block").type(
|
||||||
.click()
|
"This is an extremely big paragraph that is meant to test the UI. This is a very long paragraph. It contains more than once sentence. Its meant to be this long as this is a UI test. Its unbearably long and I'm not sure why I'm typing this much. I'm just going to keep typing until I feel like its long enough. I think its long enough now. I'm going to stop typing now."
|
||||||
.type(
|
);
|
||||||
"This is an extremely big paragraph that is meant to test the UI. This is a very long paragraph. It contains more than once sentence. Its meant to be this long as this is a UI test. Its unbearably long and I'm not sure why I'm typing this much. I'm just going to keep typing until I feel like its long enough. I think its long enough now. I'm going to stop typing now. {enter}"
|
|
||||||
);
|
|
||||||
cy.get("#lesson-content .ce-toolbar__plus").click();
|
|
||||||
cy.get('#lesson-content [data-item-name="youtube"]').click();
|
|
||||||
cy.get('input[data-fieldname="youtube"]').type("GoDtyItReto");
|
|
||||||
cy.button("Insert").click();
|
|
||||||
cy.wait(1000);
|
|
||||||
cy.button("Save").click();
|
cy.button("Save").click();
|
||||||
|
|
||||||
// View Course
|
// View Course
|
||||||
cy.wait(1000);
|
cy.wait(1000);
|
||||||
cy.visit("/courses");
|
cy.visit("/lms");
|
||||||
cy.get(".course-card-title:first").contains("Test Course");
|
cy.wait(500);
|
||||||
cy.get(".course-card:first").click();
|
cy.url().should("include", "/lms/courses");
|
||||||
cy.url().should("include", "/courses/test-course");
|
cy.get(".grid a:first").within(() => {
|
||||||
cy.get("#title").contains("Test Course");
|
cy.get("div").contains("Test Course");
|
||||||
cy.get(".preview-video").should(
|
cy.get("div").contains(
|
||||||
|
"Test Course Short Introduction to test the UI"
|
||||||
|
);
|
||||||
|
cy.get(".course-image")
|
||||||
|
.invoke("css", "background-image")
|
||||||
|
.should("include", "/files/profile");
|
||||||
|
});
|
||||||
|
cy.get(".grid a:first").click();
|
||||||
|
cy.url().should("include", "/lms/courses/test-course");
|
||||||
|
cy.get("div").contains("Test Course");
|
||||||
|
cy.get("div").contains("Test Course Short Introduction to test the UI");
|
||||||
|
cy.get("div").contains("Learning");
|
||||||
|
cy.get("div").contains("Frappe");
|
||||||
|
cy.get("div").contains("ERPNext");
|
||||||
|
cy.get("iframe").should(
|
||||||
"have.attr",
|
"have.attr",
|
||||||
"src",
|
"src",
|
||||||
"https://www.youtube.com/embed/-LPmw2Znl2c"
|
"https://www.youtube.com/embed/-LPmw2Znl2c"
|
||||||
);
|
);
|
||||||
cy.get("#intro").contains("Test Course Short Introduction");
|
|
||||||
|
|
||||||
// View Chapter
|
// View Chapter
|
||||||
cy.get(".chapter-title-main:first").contains("Test Chapter");
|
cy.get("div").contains("Test Chapter");
|
||||||
cy.get(".chapter-description:first").contains(
|
cy.get("[id^=headlessui-disclosure-panel-").within(() => {
|
||||||
"Test Chapter Description"
|
cy.get("div").contains("Test Lesson").click();
|
||||||
);
|
});
|
||||||
cy.get(".lesson-info:first").contains("Test Lesson");
|
cy.wait(3000);
|
||||||
cy.get(".lesson-info:first").click();
|
|
||||||
|
|
||||||
// View Lesson
|
// View Lesson
|
||||||
cy.wait(1000);
|
cy.url().should("include", "/learn/1-1");
|
||||||
cy.url().should("include", "learn/1.1");
|
cy.get("div").contains("Test Lesson");
|
||||||
cy.get("#title").contains("Test Lesson");
|
|
||||||
cy.get(".lesson-video iframe").should(
|
cy.get("div").contains(
|
||||||
"have.attr",
|
|
||||||
"src",
|
|
||||||
"https://www.youtube.com/embed/GoDtyItReto"
|
|
||||||
);
|
|
||||||
cy.get(".lesson-content-card").contains(
|
|
||||||
"This is an extremely big paragraph that is meant to test the UI. This is a very long paragraph. It contains more than once sentence. Its meant to be this long as this is a UI test. Its unbearably long and I'm not sure why I'm typing this much. I'm just going to keep typing until I feel like its long enough. I think its long enough now. I'm going to stop typing now."
|
"This is an extremely big paragraph that is meant to test the UI. This is a very long paragraph. It contains more than once sentence. Its meant to be this long as this is a UI test. Its unbearably long and I'm not sure why I'm typing this much. I'm just going to keep typing until I feel like its long enough. I think its long enough now. I'm going to stop typing now."
|
||||||
);
|
);
|
||||||
|
|
||||||
// Add Discussion
|
// Add Discussion
|
||||||
cy.get(".reply").click();
|
cy.button("New Question").click();
|
||||||
cy.wait(500);
|
cy.wait(500);
|
||||||
cy.get(".discussion-modal").should("be.visible");
|
cy.get("[id^=headlessui-dialog-panel-").within(() => {
|
||||||
|
cy.get("label").contains("Title").type("Test Discussion");
|
||||||
// Enter title
|
cy.get("div[contenteditable=true]").invoke(
|
||||||
cy.get(".modal .topic-title")
|
"text",
|
||||||
.type("Discussion from tests")
|
"This is a test discussion. This will check if the UI is working properly."
|
||||||
.should("have.value", "Discussion from tests");
|
|
||||||
|
|
||||||
// Enter comment
|
|
||||||
cy.get(".modal .discussions-comment").type(
|
|
||||||
"This is a discussion from the cypress ui tests."
|
|
||||||
);
|
|
||||||
|
|
||||||
// Submit
|
|
||||||
cy.get(".modal .submit-discussion").click();
|
|
||||||
cy.wait(2000);
|
|
||||||
|
|
||||||
// Check if discussion is added to page and content is visible
|
|
||||||
cy.get(".sidebar-parent:first .discussion-topic-title").should(
|
|
||||||
"have.text",
|
|
||||||
"Discussion from tests"
|
|
||||||
);
|
|
||||||
cy.get(".sidebar-parent:first .discussion-topic-title").click();
|
|
||||||
cy.get(".discussion-on-page:visible").should("have.class", "show");
|
|
||||||
cy.get(
|
|
||||||
".discussion-on-page:visible .reply-card .reply-text .ql-editor p"
|
|
||||||
).should(
|
|
||||||
"have.text",
|
|
||||||
"This is a discussion from the cypress ui tests."
|
|
||||||
);
|
|
||||||
|
|
||||||
cy.get(".discussion-form:visible .discussions-comment").type(
|
|
||||||
"This is a discussion from the cypress ui tests. \n\nThis comment was entered through the commentbox on the page."
|
|
||||||
);
|
|
||||||
|
|
||||||
cy.get(".discussion-form:visible .submit-discussion").click();
|
|
||||||
cy.wait(3000);
|
|
||||||
cy.get(".discussion-on-page:visible").should("have.class", "show");
|
|
||||||
cy.get(".discussion-on-page:visible")
|
|
||||||
.children(".reply-card")
|
|
||||||
.eq(1)
|
|
||||||
.find(".reply-text")
|
|
||||||
.should(
|
|
||||||
"have.text",
|
|
||||||
"This is a discussion from the cypress ui tests. This comment was entered through the commentbox on the page.\n"
|
|
||||||
);
|
);
|
||||||
|
cy.button("Post").click();
|
||||||
|
});
|
||||||
|
|
||||||
|
// View Discussion
|
||||||
|
cy.wait(500);
|
||||||
|
cy.get("div").contains("Test Discussion").click();
|
||||||
|
cy.get("div[contenteditable=true").invoke(
|
||||||
|
"text",
|
||||||
|
"This is a test comment. This will check if the UI is working properly."
|
||||||
|
);
|
||||||
|
|
||||||
|
cy.get("div").contains(
|
||||||
|
"This is a test comment. This will check if the UI is working properly."
|
||||||
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
BIN
cypress/fixtures/Youtube.mov
Normal file
BIN
cypress/fixtures/Youtube.mov
Normal file
Binary file not shown.
BIN
cypress/fixtures/profile.png
Normal file
BIN
cypress/fixtures/profile.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.2 MiB |
@@ -24,6 +24,8 @@
|
|||||||
// -- This will overwrite an existing command --
|
// -- This will overwrite an existing command --
|
||||||
// Cypress.Commands.overwrite('visit', (originalFn, url, options) => { ... })
|
// Cypress.Commands.overwrite('visit', (originalFn, url, options) => { ... })
|
||||||
|
|
||||||
|
import "cypress-file-upload";
|
||||||
|
|
||||||
Cypress.Commands.add("login", (email, password) => {
|
Cypress.Commands.add("login", (email, password) => {
|
||||||
if (!email) {
|
if (!email) {
|
||||||
email = Cypress.config("testUser") || "Administrator";
|
email = Cypress.config("testUser") || "Administrator";
|
||||||
@@ -53,3 +55,13 @@ Cypress.Commands.add("iconButton", (text) => {
|
|||||||
Cypress.Commands.add("dialog", (selector) => {
|
Cypress.Commands.add("dialog", (selector) => {
|
||||||
return cy.get(`[role=dialog] ${selector}`);
|
return cy.get(`[role=dialog] ${selector}`);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
Cypress.Commands.add("paste", { prevSubject: true }, (subject, text) => {
|
||||||
|
cy.wrap(subject).then(($element) => {
|
||||||
|
const element = $element[0];
|
||||||
|
element.focus();
|
||||||
|
element.textContent = text;
|
||||||
|
const event = new Event("paste", { bubbles: true });
|
||||||
|
element.dispatchEvent(event);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -4,6 +4,8 @@
|
|||||||
$ git clone https://github.com/frappe/lms.git
|
$ git clone https://github.com/frappe/lms.git
|
||||||
|
|
||||||
$ cd lms
|
$ cd lms
|
||||||
|
|
||||||
|
$ cd docker
|
||||||
```
|
```
|
||||||
|
|
||||||
**Step 2:** Run docker-compose
|
**Step 2:** Run docker-compose
|
||||||
|
|||||||
@@ -35,7 +35,6 @@ bench new-site lms.localhost \
|
|||||||
bench --site lms.localhost install-app lms
|
bench --site lms.localhost install-app lms
|
||||||
bench --site lms.localhost set-config developer_mode 1
|
bench --site lms.localhost set-config developer_mode 1
|
||||||
bench --site lms.localhost clear-cache
|
bench --site lms.localhost clear-cache
|
||||||
bench --site lms.localhost set-config mute_emails 1
|
|
||||||
bench use lms.localhost
|
bench use lms.localhost
|
||||||
|
|
||||||
bench start
|
bench start
|
||||||
|
|||||||
1
frappe-ui
Submodule
1
frappe-ui
Submodule
Submodule frappe-ui added at 8cd9b06a5e
5
frontend/.gitignore
vendored
Normal file
5
frontend/.gitignore
vendored
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
node_modules
|
||||||
|
.DS_Store
|
||||||
|
dist
|
||||||
|
dist-ssr
|
||||||
|
*.local
|
||||||
4
frontend/.prettierrc.json
Normal file
4
frontend/.prettierrc.json
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
{
|
||||||
|
"semi": false,
|
||||||
|
"singleQuote": true
|
||||||
|
}
|
||||||
42
frontend/README.md
Normal file
42
frontend/README.md
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
# Frappe UI Starter
|
||||||
|
|
||||||
|
This template should help get you started developing custom frontend for Frappe
|
||||||
|
apps with Vue 3 and the Frappe UI package.
|
||||||
|
|
||||||
|
This boilerplate sets up Vue 3, Vue Router, TailwindCSS, and Frappe UI out of
|
||||||
|
the box.
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
This template is meant to be cloned inside an existing Frappe App. Assuming your
|
||||||
|
apps name is `todo`. Clone this template in the root folder of your app using `degit`.
|
||||||
|
|
||||||
|
```
|
||||||
|
cd apps/todo
|
||||||
|
npx degit netchampfaris/frappe-ui-starter frontend
|
||||||
|
cd frontend
|
||||||
|
yarn
|
||||||
|
yarn dev
|
||||||
|
```
|
||||||
|
|
||||||
|
In a development environment, you need to put the below key-value pair in your `site_config.json` file:
|
||||||
|
|
||||||
|
```
|
||||||
|
"ignore_csrf": 1
|
||||||
|
```
|
||||||
|
|
||||||
|
This will prevent `CSRFToken` errors while using the vite dev server. In production environment, the `csrf_token` is attached to the `window` object in `index.html` for you.
|
||||||
|
|
||||||
|
The Vite dev server will start on the port `8080`. This can be changed from `vite.config.js`.
|
||||||
|
The development server is configured to proxy your frappe app (usually running on port `8000`). If you have a site named `todo.test`, open `http://todo.test:8080` in your browser. If you see a button named "Click to send 'ping' request", congratulations!
|
||||||
|
|
||||||
|
If you notice the browser URL is `/frontend`, this is the base URL where your frontend app will run in production.
|
||||||
|
To change this, open `src/router.js` and change the base URL passed to `createWebHistory`.
|
||||||
|
|
||||||
|
## Resources
|
||||||
|
|
||||||
|
- [Vue 3](https://v3.vuejs.org/guide/introduction.html)
|
||||||
|
- [Vue Router](https://next.router.vuejs.org/guide/)
|
||||||
|
- [Frappe UI](https://github.com/frappe/frappe-ui)
|
||||||
|
- [TailwindCSS](https://tailwindcss.com/docs/utility-first)
|
||||||
|
- [Vite](https://vitejs.dev/guide/)
|
||||||
49
frontend/index.html
Normal file
49
frontend/index.html
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<link rel="icon" href="/favicon.png" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>Frappe Learning</title>
|
||||||
|
<meta name="title" content="{{ meta.title }}" />
|
||||||
|
<meta name="image" content="{{ meta.image }}" />
|
||||||
|
<meta name="description" content="{{ meta.description }}" />
|
||||||
|
<meta name="keywords" content="{{ meta.keywords }}" />
|
||||||
|
<meta property="og:title" content="{{ meta.title }}" />
|
||||||
|
<meta property="og:image" content="{{ meta.image }}" />
|
||||||
|
<meta property="og:description" content="{{ meta.description }}" />
|
||||||
|
<meta name="twitter:title" content="{{ meta.title }}" />
|
||||||
|
<meta name="twitter:image" content="{{ meta.image }}" />
|
||||||
|
<meta name="twitter:description" content="{{ meta.description }}" />
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="app">
|
||||||
|
<div id="seo-content">
|
||||||
|
<h1>{{ meta.title }}</h1>
|
||||||
|
<p>
|
||||||
|
{{ meta.description }}
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
The content here is just for seo purposes. The actual content will be loaded in a few seconds.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
Seo checks if a page has more than 300 words. So, here are some more words to make it more than 300 words.
|
||||||
|
Page descriptions are the HTML meta tags that provide a brief summary of a web page.
|
||||||
|
Search engines use meta descriptions to help identify the page's topic - they don't use them to rank the page, but they do use them to determine whether or not to display the page in search results.
|
||||||
|
Meta descriptions are important because they're often the first thing people see when they're deciding which search result to click on.
|
||||||
|
They're also important because they can help improve your click-through rate (CTR) from search results.
|
||||||
|
A good meta description can entice people to click on your page instead of someone else's.
|
||||||
|
</p>
|
||||||
|
<a href="{{ meta.link }}">Know More</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div id="modals"></div>
|
||||||
|
<div id="popovers"></div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
window.csrf_token = '{{ csrf_token }}'
|
||||||
|
document.getElementById('seo-content').style.display = 'none';
|
||||||
|
</script>
|
||||||
|
<script type="module" src="/src/main.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
44
frontend/package.json
Normal file
44
frontend/package.json
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
{
|
||||||
|
"name": "frappe-ui-frontend",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.0.0",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"serve": "vite preview",
|
||||||
|
"build": "vite build --base=/assets/lms/frontend/ && yarn copy-html-entry",
|
||||||
|
"copy-html-entry": "cp ../lms/public/frontend/index.html ../lms/www/lms.html"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@editorjs/checklist": "^1.6.0",
|
||||||
|
"@editorjs/code": "^2.9.0",
|
||||||
|
"@editorjs/editorjs": "^2.29.0",
|
||||||
|
"@editorjs/embed": "^2.7.0",
|
||||||
|
"@editorjs/header": "^2.8.1",
|
||||||
|
"@editorjs/inline-code": "^1.5.0",
|
||||||
|
"@editorjs/nested-list": "^1.4.2",
|
||||||
|
"@editorjs/paragraph": "^2.11.3",
|
||||||
|
"@editorjs/simple-image": "^1.6.0",
|
||||||
|
"ace-builds": "^1.36.2",
|
||||||
|
"chart.js": "^4.4.1",
|
||||||
|
"codemirror-editor-vue3": "^2.8.0",
|
||||||
|
"dayjs": "^1.11.6",
|
||||||
|
"feather-icons": "^4.28.0",
|
||||||
|
"frappe-ui": "^0.1.72",
|
||||||
|
"lucide-vue-next": "^0.383.0",
|
||||||
|
"markdown-it": "^14.0.0",
|
||||||
|
"pinia": "^2.0.33",
|
||||||
|
"socket.io-client": "^4.7.2",
|
||||||
|
"tailwindcss": "^3.3.3",
|
||||||
|
"vue": "^3.4.23",
|
||||||
|
"vue-chartjs": "^5.3.0",
|
||||||
|
"vue-draggable-next": "^2.2.1",
|
||||||
|
"vue-router": "^4.0.12",
|
||||||
|
"vuedraggable": "4.1.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@vitejs/plugin-vue": "^5.0.3",
|
||||||
|
"autoprefixer": "^10.4.2",
|
||||||
|
"postcss": "^8.4.5",
|
||||||
|
"vite": "^5.0.11"
|
||||||
|
}
|
||||||
|
}
|
||||||
6
frontend/postcss.config.js
Normal file
6
frontend/postcss.config.js
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
module.exports = {
|
||||||
|
plugins: {
|
||||||
|
tailwindcss: {},
|
||||||
|
autoprefixer: {},
|
||||||
|
},
|
||||||
|
}
|
||||||
BIN
frontend/public/Quiz.mp4
Normal file
BIN
frontend/public/Quiz.mp4
Normal file
Binary file not shown.
BIN
frontend/public/Upload.mp4
Normal file
BIN
frontend/public/Upload.mp4
Normal file
Binary file not shown.
BIN
frontend/public/Youtube.mp4
Normal file
BIN
frontend/public/Youtube.mp4
Normal file
Binary file not shown.
BIN
frontend/public/favicon.png
Normal file
BIN
frontend/public/favicon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 440 B |
38
frontend/src/App.vue
Normal file
38
frontend/src/App.vue
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
<template>
|
||||||
|
<Layout>
|
||||||
|
<router-view />
|
||||||
|
</Layout>
|
||||||
|
<Dialogs />
|
||||||
|
<Toasts />
|
||||||
|
</template>
|
||||||
|
<script setup>
|
||||||
|
import { Toasts } from 'frappe-ui'
|
||||||
|
import { Dialogs } from '@/utils/dialogs'
|
||||||
|
import { computed, onMounted, onUnmounted } from 'vue'
|
||||||
|
import { useScreenSize } from './utils/composables'
|
||||||
|
import DesktopLayout from './components/DesktopLayout.vue'
|
||||||
|
import MobileLayout from './components/MobileLayout.vue'
|
||||||
|
import { stopSession } from '@/telemetry'
|
||||||
|
import { init as initTelemetry } from '@/telemetry'
|
||||||
|
import { usersStore } from '@/stores/user'
|
||||||
|
|
||||||
|
const screenSize = useScreenSize()
|
||||||
|
let { userResource } = usersStore()
|
||||||
|
|
||||||
|
const Layout = computed(() => {
|
||||||
|
if (screenSize.width < 640) {
|
||||||
|
return MobileLayout
|
||||||
|
} else {
|
||||||
|
return DesktopLayout
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
if (!userResource.data) return
|
||||||
|
await initTelemetry()
|
||||||
|
})
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
stopSession()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
BIN
frontend/src/assets/Inter/Inter-Black.woff
Normal file
BIN
frontend/src/assets/Inter/Inter-Black.woff
Normal file
Binary file not shown.
BIN
frontend/src/assets/Inter/Inter-Black.woff2
Normal file
BIN
frontend/src/assets/Inter/Inter-Black.woff2
Normal file
Binary file not shown.
BIN
frontend/src/assets/Inter/Inter-BlackItalic.woff
Normal file
BIN
frontend/src/assets/Inter/Inter-BlackItalic.woff
Normal file
Binary file not shown.
BIN
frontend/src/assets/Inter/Inter-BlackItalic.woff2
Normal file
BIN
frontend/src/assets/Inter/Inter-BlackItalic.woff2
Normal file
Binary file not shown.
BIN
frontend/src/assets/Inter/Inter-Bold.woff
Normal file
BIN
frontend/src/assets/Inter/Inter-Bold.woff
Normal file
Binary file not shown.
BIN
frontend/src/assets/Inter/Inter-Bold.woff2
Normal file
BIN
frontend/src/assets/Inter/Inter-Bold.woff2
Normal file
Binary file not shown.
BIN
frontend/src/assets/Inter/Inter-BoldItalic.woff
Normal file
BIN
frontend/src/assets/Inter/Inter-BoldItalic.woff
Normal file
Binary file not shown.
BIN
frontend/src/assets/Inter/Inter-BoldItalic.woff2
Normal file
BIN
frontend/src/assets/Inter/Inter-BoldItalic.woff2
Normal file
Binary file not shown.
BIN
frontend/src/assets/Inter/Inter-ExtraBold.woff
Normal file
BIN
frontend/src/assets/Inter/Inter-ExtraBold.woff
Normal file
Binary file not shown.
BIN
frontend/src/assets/Inter/Inter-ExtraBold.woff2
Normal file
BIN
frontend/src/assets/Inter/Inter-ExtraBold.woff2
Normal file
Binary file not shown.
BIN
frontend/src/assets/Inter/Inter-ExtraBoldItalic.woff
Normal file
BIN
frontend/src/assets/Inter/Inter-ExtraBoldItalic.woff
Normal file
Binary file not shown.
BIN
frontend/src/assets/Inter/Inter-ExtraBoldItalic.woff2
Normal file
BIN
frontend/src/assets/Inter/Inter-ExtraBoldItalic.woff2
Normal file
Binary file not shown.
BIN
frontend/src/assets/Inter/Inter-ExtraLight.woff
Normal file
BIN
frontend/src/assets/Inter/Inter-ExtraLight.woff
Normal file
Binary file not shown.
BIN
frontend/src/assets/Inter/Inter-ExtraLight.woff2
Normal file
BIN
frontend/src/assets/Inter/Inter-ExtraLight.woff2
Normal file
Binary file not shown.
BIN
frontend/src/assets/Inter/Inter-ExtraLightItalic.woff
Normal file
BIN
frontend/src/assets/Inter/Inter-ExtraLightItalic.woff
Normal file
Binary file not shown.
BIN
frontend/src/assets/Inter/Inter-ExtraLightItalic.woff2
Normal file
BIN
frontend/src/assets/Inter/Inter-ExtraLightItalic.woff2
Normal file
Binary file not shown.
BIN
frontend/src/assets/Inter/Inter-Italic.woff
Normal file
BIN
frontend/src/assets/Inter/Inter-Italic.woff
Normal file
Binary file not shown.
BIN
frontend/src/assets/Inter/Inter-Italic.woff2
Normal file
BIN
frontend/src/assets/Inter/Inter-Italic.woff2
Normal file
Binary file not shown.
BIN
frontend/src/assets/Inter/Inter-Light.woff
Normal file
BIN
frontend/src/assets/Inter/Inter-Light.woff
Normal file
Binary file not shown.
BIN
frontend/src/assets/Inter/Inter-Light.woff2
Normal file
BIN
frontend/src/assets/Inter/Inter-Light.woff2
Normal file
Binary file not shown.
BIN
frontend/src/assets/Inter/Inter-LightItalic.woff
Normal file
BIN
frontend/src/assets/Inter/Inter-LightItalic.woff
Normal file
Binary file not shown.
BIN
frontend/src/assets/Inter/Inter-LightItalic.woff2
Normal file
BIN
frontend/src/assets/Inter/Inter-LightItalic.woff2
Normal file
Binary file not shown.
BIN
frontend/src/assets/Inter/Inter-Medium.woff
Normal file
BIN
frontend/src/assets/Inter/Inter-Medium.woff
Normal file
Binary file not shown.
BIN
frontend/src/assets/Inter/Inter-Medium.woff2
Normal file
BIN
frontend/src/assets/Inter/Inter-Medium.woff2
Normal file
Binary file not shown.
BIN
frontend/src/assets/Inter/Inter-MediumItalic.woff
Normal file
BIN
frontend/src/assets/Inter/Inter-MediumItalic.woff
Normal file
Binary file not shown.
BIN
frontend/src/assets/Inter/Inter-MediumItalic.woff2
Normal file
BIN
frontend/src/assets/Inter/Inter-MediumItalic.woff2
Normal file
Binary file not shown.
BIN
frontend/src/assets/Inter/Inter-Regular.woff
Normal file
BIN
frontend/src/assets/Inter/Inter-Regular.woff
Normal file
Binary file not shown.
BIN
frontend/src/assets/Inter/Inter-Regular.woff2
Normal file
BIN
frontend/src/assets/Inter/Inter-Regular.woff2
Normal file
Binary file not shown.
BIN
frontend/src/assets/Inter/Inter-SemiBold.woff
Normal file
BIN
frontend/src/assets/Inter/Inter-SemiBold.woff
Normal file
Binary file not shown.
BIN
frontend/src/assets/Inter/Inter-SemiBold.woff2
Normal file
BIN
frontend/src/assets/Inter/Inter-SemiBold.woff2
Normal file
Binary file not shown.
BIN
frontend/src/assets/Inter/Inter-SemiBoldItalic.woff
Normal file
BIN
frontend/src/assets/Inter/Inter-SemiBoldItalic.woff
Normal file
Binary file not shown.
BIN
frontend/src/assets/Inter/Inter-SemiBoldItalic.woff2
Normal file
BIN
frontend/src/assets/Inter/Inter-SemiBoldItalic.woff2
Normal file
Binary file not shown.
BIN
frontend/src/assets/Inter/Inter-Thin.woff
Normal file
BIN
frontend/src/assets/Inter/Inter-Thin.woff
Normal file
Binary file not shown.
BIN
frontend/src/assets/Inter/Inter-Thin.woff2
Normal file
BIN
frontend/src/assets/Inter/Inter-Thin.woff2
Normal file
Binary file not shown.
BIN
frontend/src/assets/Inter/Inter-ThinItalic.woff
Normal file
BIN
frontend/src/assets/Inter/Inter-ThinItalic.woff
Normal file
Binary file not shown.
BIN
frontend/src/assets/Inter/Inter-ThinItalic.woff2
Normal file
BIN
frontend/src/assets/Inter/Inter-ThinItalic.woff2
Normal file
Binary file not shown.
BIN
frontend/src/assets/Inter/Inter-italic.var.woff2
Normal file
BIN
frontend/src/assets/Inter/Inter-italic.var.woff2
Normal file
Binary file not shown.
BIN
frontend/src/assets/Inter/Inter-roman.var.woff2
Normal file
BIN
frontend/src/assets/Inter/Inter-roman.var.woff2
Normal file
Binary file not shown.
BIN
frontend/src/assets/Inter/Inter.var.woff2
Normal file
BIN
frontend/src/assets/Inter/Inter.var.woff2
Normal file
Binary file not shown.
152
frontend/src/assets/Inter/inter.css
Normal file
152
frontend/src/assets/Inter/inter.css
Normal file
@@ -0,0 +1,152 @@
|
|||||||
|
@font-face {
|
||||||
|
font-family: 'Inter';
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 100;
|
||||||
|
font-display: swap;
|
||||||
|
src: url("Inter-Thin.woff2?v=3.12") format("woff2"),
|
||||||
|
url("Inter-Thin.woff?v=3.12") format("woff");
|
||||||
|
}
|
||||||
|
@font-face {
|
||||||
|
font-family: 'Inter';
|
||||||
|
font-style: italic;
|
||||||
|
font-weight: 100;
|
||||||
|
font-display: swap;
|
||||||
|
src: url("Inter-ThinItalic.woff2?v=3.12") format("woff2"),
|
||||||
|
url("Inter-ThinItalic.woff?v=3.12") format("woff");
|
||||||
|
}
|
||||||
|
|
||||||
|
@font-face {
|
||||||
|
font-family: 'Inter';
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 200;
|
||||||
|
font-display: swap;
|
||||||
|
src: url("Inter-ExtraLight.woff2?v=3.12") format("woff2"),
|
||||||
|
url("Inter-ExtraLight.woff?v=3.12") format("woff");
|
||||||
|
}
|
||||||
|
@font-face {
|
||||||
|
font-family: 'Inter';
|
||||||
|
font-style: italic;
|
||||||
|
font-weight: 200;
|
||||||
|
font-display: swap;
|
||||||
|
src: url("Inter-ExtraLightItalic.woff2?v=3.12") format("woff2"),
|
||||||
|
url("Inter-ExtraLightItalic.woff?v=3.12") format("woff");
|
||||||
|
}
|
||||||
|
|
||||||
|
@font-face {
|
||||||
|
font-family: 'Inter';
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 300;
|
||||||
|
font-display: swap;
|
||||||
|
src: url("Inter-Light.woff2?v=3.12") format("woff2"),
|
||||||
|
url("Inter-Light.woff?v=3.12") format("woff");
|
||||||
|
}
|
||||||
|
@font-face {
|
||||||
|
font-family: 'Inter';
|
||||||
|
font-style: italic;
|
||||||
|
font-weight: 300;
|
||||||
|
font-display: swap;
|
||||||
|
src: url("Inter-LightItalic.woff2?v=3.12") format("woff2"),
|
||||||
|
url("Inter-LightItalic.woff?v=3.12") format("woff");
|
||||||
|
}
|
||||||
|
|
||||||
|
@font-face {
|
||||||
|
font-family: 'Inter';
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 400;
|
||||||
|
font-display: swap;
|
||||||
|
src: url("Inter-Regular.woff2?v=3.12") format("woff2"),
|
||||||
|
url("Inter-Regular.woff?v=3.12") format("woff");
|
||||||
|
}
|
||||||
|
@font-face {
|
||||||
|
font-family: 'Inter';
|
||||||
|
font-style: italic;
|
||||||
|
font-weight: 400;
|
||||||
|
font-display: swap;
|
||||||
|
src: url("Inter-Italic.woff2?v=3.12") format("woff2"),
|
||||||
|
url("Inter-Italic.woff?v=3.12") format("woff");
|
||||||
|
}
|
||||||
|
|
||||||
|
@font-face {
|
||||||
|
font-family: 'Inter';
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 500;
|
||||||
|
font-display: swap;
|
||||||
|
src: url("Inter-Medium.woff2?v=3.12") format("woff2"),
|
||||||
|
url("Inter-Medium.woff?v=3.12") format("woff");
|
||||||
|
}
|
||||||
|
@font-face {
|
||||||
|
font-family: 'Inter';
|
||||||
|
font-style: italic;
|
||||||
|
font-weight: 500;
|
||||||
|
font-display: swap;
|
||||||
|
src: url("Inter-MediumItalic.woff2?v=3.12") format("woff2"),
|
||||||
|
url("Inter-MediumItalic.woff?v=3.12") format("woff");
|
||||||
|
}
|
||||||
|
|
||||||
|
@font-face {
|
||||||
|
font-family: 'Inter';
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 600;
|
||||||
|
font-display: swap;
|
||||||
|
src: url("Inter-SemiBold.woff2?v=3.12") format("woff2"),
|
||||||
|
url("Inter-SemiBold.woff?v=3.12") format("woff");
|
||||||
|
}
|
||||||
|
@font-face {
|
||||||
|
font-family: 'Inter';
|
||||||
|
font-style: italic;
|
||||||
|
font-weight: 600;
|
||||||
|
font-display: swap;
|
||||||
|
src: url("Inter-SemiBoldItalic.woff2?v=3.12") format("woff2"),
|
||||||
|
url("Inter-SemiBoldItalic.woff?v=3.12") format("woff");
|
||||||
|
}
|
||||||
|
|
||||||
|
@font-face {
|
||||||
|
font-family: 'Inter';
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 700;
|
||||||
|
font-display: swap;
|
||||||
|
src: url("Inter-Bold.woff2?v=3.12") format("woff2"),
|
||||||
|
url("Inter-Bold.woff?v=3.12") format("woff");
|
||||||
|
}
|
||||||
|
@font-face {
|
||||||
|
font-family: 'Inter';
|
||||||
|
font-style: italic;
|
||||||
|
font-weight: 700;
|
||||||
|
font-display: swap;
|
||||||
|
src: url("Inter-BoldItalic.woff2?v=3.12") format("woff2"),
|
||||||
|
url("Inter-BoldItalic.woff?v=3.12") format("woff");
|
||||||
|
}
|
||||||
|
|
||||||
|
@font-face {
|
||||||
|
font-family: 'Inter';
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 800;
|
||||||
|
font-display: swap;
|
||||||
|
src: url("Inter-ExtraBold.woff2?v=3.12") format("woff2"),
|
||||||
|
url("Inter-ExtraBold.woff?v=3.12") format("woff");
|
||||||
|
}
|
||||||
|
@font-face {
|
||||||
|
font-family: 'Inter';
|
||||||
|
font-style: italic;
|
||||||
|
font-weight: 800;
|
||||||
|
font-display: swap;
|
||||||
|
src: url("Inter-ExtraBoldItalic.woff2?v=3.12") format("woff2"),
|
||||||
|
url("Inter-ExtraBoldItalic.woff?v=3.12") format("woff");
|
||||||
|
}
|
||||||
|
|
||||||
|
@font-face {
|
||||||
|
font-family: 'Inter';
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 900;
|
||||||
|
font-display: swap;
|
||||||
|
src: url("Inter-Black.woff2?v=3.12") format("woff2"),
|
||||||
|
url("Inter-Black.woff?v=3.12") format("woff");
|
||||||
|
}
|
||||||
|
@font-face {
|
||||||
|
font-family: 'Inter';
|
||||||
|
font-style: italic;
|
||||||
|
font-weight: 900;
|
||||||
|
font-display: swap;
|
||||||
|
src: url("Inter-BlackItalic.woff2?v=3.12") format("woff2"),
|
||||||
|
url("Inter-BlackItalic.woff?v=3.12") format("woff");
|
||||||
|
}
|
||||||
53
frontend/src/components/Annoucements.vue
Normal file
53
frontend/src/components/Annoucements.vue
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
<template>
|
||||||
|
<div v-if="communications.data?.length">
|
||||||
|
<div v-for="comm in communications.data">
|
||||||
|
<div class="mb-8">
|
||||||
|
<div class="flex items-center justify-between mb-2">
|
||||||
|
<div class="flex items-center">
|
||||||
|
<Avatar :label="comm.sender_full_name" size="lg" />
|
||||||
|
<div class="ml-2">
|
||||||
|
{{ comm.sender_full_name }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="text-sm">
|
||||||
|
{{ timeAgo(comm.communication_date) }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
class="prose prose-sm bg-gray-50 !min-w-full px-4 py-2 rounded-md"
|
||||||
|
v-html="comm.content"
|
||||||
|
></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-else class="text-sm italic text-gray-600">
|
||||||
|
{{ __('No announcements') }}
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<script setup>
|
||||||
|
import { createResource, Avatar } from 'frappe-ui'
|
||||||
|
import { timeAgo } from '@/utils'
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
batch: {
|
||||||
|
type: String,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const communications = createResource({
|
||||||
|
url: 'lms.lms.api.get_announcements',
|
||||||
|
makeParams(value) {
|
||||||
|
return {
|
||||||
|
batch: props.batch,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
auto: true,
|
||||||
|
cache: ['announcement', props.batch],
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
<style>
|
||||||
|
.prose-sm p {
|
||||||
|
margin: 0 0 0.5rem;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
249
frontend/src/components/AppSidebar.vue
Normal file
249
frontend/src/components/AppSidebar.vue
Normal file
@@ -0,0 +1,249 @@
|
|||||||
|
<template>
|
||||||
|
<div
|
||||||
|
class="flex h-full flex-col justify-between transition-all duration-300 ease-in-out bg-gray-50"
|
||||||
|
:class="sidebarStore.isSidebarCollapsed ? 'w-14' : 'w-56'"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="flex flex-col overflow-hidden"
|
||||||
|
:class="sidebarStore.isSidebarCollapsed ? 'items-center' : ''"
|
||||||
|
>
|
||||||
|
<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>
|
||||||
|
<div
|
||||||
|
v-if="sidebarSettings.data?.web_pages?.length || isModerator"
|
||||||
|
class="mt-4"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="flex items-center justify-between pr-2 cursor-pointer"
|
||||||
|
:class="sidebarStore.isSidebarCollapsed ? 'pl-3' : 'pl-4'"
|
||||||
|
@click="showWebPages = !showWebPages"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
v-if="!sidebarStore.isSidebarCollapsed"
|
||||||
|
class="flex items-center text-sm text-gray-600 my-1"
|
||||||
|
>
|
||||||
|
<span class="grid h-5 w-6 flex-shrink-0 place-items-center">
|
||||||
|
<ChevronRight
|
||||||
|
class="h-4 w-4 stroke-1.5 text-gray-900 transition-all duration-300 ease-in-out"
|
||||||
|
:class="{ 'rotate-90': showWebPages }"
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
<span class="ml-2">
|
||||||
|
{{ __('More') }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<Button v-if="isModerator" variant="ghost" @click="openPageModal()">
|
||||||
|
<template #icon>
|
||||||
|
<Plus class="h-4 w-4 text-gray-700 stroke-1.5" />
|
||||||
|
</template>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-if="sidebarSettings.data?.web_pages?.length"
|
||||||
|
class="flex flex-col transition-all duration-300 ease-in-out"
|
||||||
|
:class="showWebPages ? 'block' : 'hidden'"
|
||||||
|
>
|
||||||
|
<SidebarLink
|
||||||
|
v-for="link in sidebarSettings.data.web_pages"
|
||||||
|
:link="link"
|
||||||
|
:isCollapsed="sidebarStore.isSidebarCollapsed"
|
||||||
|
class="mx-2 my-0.5"
|
||||||
|
:showControls="isModerator ? true : false"
|
||||||
|
@openModal="openPageModal"
|
||||||
|
@deletePage="deletePage"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<SidebarLink
|
||||||
|
:link="{
|
||||||
|
label: sidebarStore.isSidebarCollapsed ? 'Expand' : 'Collapse',
|
||||||
|
}"
|
||||||
|
:isCollapsed="sidebarStore.isSidebarCollapsed"
|
||||||
|
@click="toggleSidebar()"
|
||||||
|
class="m-2"
|
||||||
|
>
|
||||||
|
<template #icon>
|
||||||
|
<span class="grid h-5 w-6 flex-shrink-0 place-items-center">
|
||||||
|
<CollapseSidebar
|
||||||
|
class="h-4.5 w-4.5 text-gray-700 duration-300 ease-in-out"
|
||||||
|
:class="{
|
||||||
|
'[transform:rotateY(180deg)]': sidebarStore.isSidebarCollapsed,
|
||||||
|
}"
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
</SidebarLink>
|
||||||
|
</div>
|
||||||
|
<PageModal
|
||||||
|
v-model="showPageModal"
|
||||||
|
v-model:reloadSidebar="sidebarSettings"
|
||||||
|
:page="pageToEdit"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import UserDropdown from '@/components/UserDropdown.vue'
|
||||||
|
import CollapseSidebar from '@/components/Icons/CollapseSidebar.vue'
|
||||||
|
import SidebarLink from '@/components/SidebarLink.vue'
|
||||||
|
import { useStorage } from '@vueuse/core'
|
||||||
|
import { ref, onMounted, inject, watch } from 'vue'
|
||||||
|
import { getSidebarLinks } from '../utils'
|
||||||
|
import { usersStore } from '@/stores/user'
|
||||||
|
import { sessionStore } from '@/stores/session'
|
||||||
|
import { useSidebar } from '@/stores/sidebar'
|
||||||
|
import { useSettings } from '@/stores/settings'
|
||||||
|
import { ChevronRight, Plus } from 'lucide-vue-next'
|
||||||
|
import { createResource, Button } from 'frappe-ui'
|
||||||
|
import PageModal from '@/components/Modals/PageModal.vue'
|
||||||
|
|
||||||
|
const { user, sidebarSettings } = sessionStore()
|
||||||
|
const { userResource } = usersStore()
|
||||||
|
let sidebarStore = useSidebar()
|
||||||
|
const socket = inject('$socket')
|
||||||
|
const unreadCount = ref(0)
|
||||||
|
const sidebarLinks = ref(getSidebarLinks())
|
||||||
|
const showPageModal = ref(false)
|
||||||
|
const isModerator = ref(false)
|
||||||
|
const isInstructor = ref(false)
|
||||||
|
const pageToEdit = ref(null)
|
||||||
|
const showWebPages = ref(false)
|
||||||
|
const settingsStore = useSettings()
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
socket.on('publish_lms_notifications', (data) => {
|
||||||
|
unreadNotifications.reload()
|
||||||
|
})
|
||||||
|
addNotifications()
|
||||||
|
sidebarSettings.reload(
|
||||||
|
{},
|
||||||
|
{
|
||||||
|
onSuccess(data) {
|
||||||
|
Object.keys(data).forEach((key) => {
|
||||||
|
if (!parseInt(data[key])) {
|
||||||
|
sidebarLinks.value = sidebarLinks.value.filter(
|
||||||
|
(link) => link.label.toLowerCase().split(' ').join('_') !== key
|
||||||
|
)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
const unreadNotifications = createResource({
|
||||||
|
cache: 'Unread Notifications Count',
|
||||||
|
url: 'frappe.client.get_count',
|
||||||
|
makeParams(values) {
|
||||||
|
return {
|
||||||
|
doctype: 'Notification Log',
|
||||||
|
filters: {
|
||||||
|
for_user: user,
|
||||||
|
read: 0,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onSuccess(data) {
|
||||||
|
unreadCount.value = data
|
||||||
|
sidebarLinks.value = sidebarLinks.value.map((link) => {
|
||||||
|
if (link.label === 'Notifications') {
|
||||||
|
link.count = data
|
||||||
|
}
|
||||||
|
return link
|
||||||
|
})
|
||||||
|
},
|
||||||
|
auto: user ? true : false,
|
||||||
|
})
|
||||||
|
|
||||||
|
const addNotifications = () => {
|
||||||
|
if (user) {
|
||||||
|
sidebarLinks.value.push({
|
||||||
|
label: 'Notifications',
|
||||||
|
icon: 'Bell',
|
||||||
|
to: 'Notifications',
|
||||||
|
activeFor: ['Notifications'],
|
||||||
|
count: unreadCount.value,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const addQuizzes = () => {
|
||||||
|
if (isInstructor.value || isModerator.value) {
|
||||||
|
sidebarLinks.value.push({
|
||||||
|
label: 'Quizzes',
|
||||||
|
icon: 'CircleHelp',
|
||||||
|
to: 'Quizzes',
|
||||||
|
activeFor: ['Quizzes', 'QuizForm'],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const addPrograms = () => {
|
||||||
|
if (settingsStore.learningPaths.data) {
|
||||||
|
let activeFor = ['Programs', 'ProgramForm']
|
||||||
|
let index = 1
|
||||||
|
if (!isInstructor.value && !isModerator.value) {
|
||||||
|
sidebarLinks.value = sidebarLinks.value.filter(
|
||||||
|
(link) => link.label !== 'Courses'
|
||||||
|
)
|
||||||
|
activeFor.push('CourseDetail')
|
||||||
|
activeFor.push('Lesson')
|
||||||
|
index = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
sidebarLinks.value.splice(index, 0, {
|
||||||
|
label: 'Programs',
|
||||||
|
icon: 'Route',
|
||||||
|
to: 'Programs',
|
||||||
|
activeFor: activeFor,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const openPageModal = (link) => {
|
||||||
|
showPageModal.value = true
|
||||||
|
pageToEdit.value = link
|
||||||
|
}
|
||||||
|
|
||||||
|
const deletePage = (link) => {
|
||||||
|
createResource({
|
||||||
|
url: 'lms.lms.api.delete_sidebar_item',
|
||||||
|
makeParams(values) {
|
||||||
|
return {
|
||||||
|
webpage: link.web_page,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}).submit(
|
||||||
|
{},
|
||||||
|
{
|
||||||
|
onSuccess() {
|
||||||
|
sidebarSettings.reload()
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const getSidebarFromStorage = () => {
|
||||||
|
return useStorage('sidebar_is_collapsed', false)
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(userResource, () => {
|
||||||
|
if (userResource.data) {
|
||||||
|
isModerator.value = userResource.data.is_moderator
|
||||||
|
isInstructor.value = userResource.data.is_instructor
|
||||||
|
addQuizzes()
|
||||||
|
addPrograms()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const toggleSidebar = () => {
|
||||||
|
sidebarStore.isSidebarCollapsed = !sidebarStore.isSidebarCollapsed
|
||||||
|
}
|
||||||
|
</script>
|
||||||
67
frontend/src/components/Apps.vue
Normal file
67
frontend/src/components/Apps.vue
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
<template>
|
||||||
|
<Popover placement="right-start" class="flex w-full">
|
||||||
|
<template #target="{ togglePopover }">
|
||||||
|
<button
|
||||||
|
:class="[
|
||||||
|
'group w-full flex h-7 items-center justify-between rounded px-2 text-base text-gray-800 hover:bg-gray-100',
|
||||||
|
]"
|
||||||
|
@click.prevent="togglePopover()"
|
||||||
|
>
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<LayoutGrid class="size-4 stroke-1.5" />
|
||||||
|
<span class="whitespace-nowrap">
|
||||||
|
{{ __('Apps') }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<ChevronRight class="h-4 w-4 stroke-1.5" />
|
||||||
|
</button>
|
||||||
|
</template>
|
||||||
|
<template #body>
|
||||||
|
<div
|
||||||
|
class="grid grid-cols-3 justify-between mx-3 p-2 rounded-lg border border-gray-100 bg-white shadow-xl"
|
||||||
|
>
|
||||||
|
<div v-for="app in apps.data" key="name">
|
||||||
|
<a
|
||||||
|
:href="app.route"
|
||||||
|
class="flex flex-col gap-1.5 rounded justify-center items-center py-2 px-3 hover:bg-gray-100"
|
||||||
|
>
|
||||||
|
<img class="size-8" :src="app.logo" />
|
||||||
|
<div class="text-sm" @click="app.onClick">
|
||||||
|
{{ app.title }}
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</Popover>
|
||||||
|
</template>
|
||||||
|
<script setup>
|
||||||
|
import { Popover, createResource } from 'frappe-ui'
|
||||||
|
import { LayoutGrid, ChevronRight } from 'lucide-vue-next'
|
||||||
|
|
||||||
|
const apps = createResource({
|
||||||
|
url: 'frappe.apps.get_apps',
|
||||||
|
cache: 'apps',
|
||||||
|
auto: true,
|
||||||
|
transform: (data) => {
|
||||||
|
let _apps = [
|
||||||
|
{
|
||||||
|
name: 'frappe',
|
||||||
|
logo: '/assets/lms/images/desk.png',
|
||||||
|
title: __('Desk'),
|
||||||
|
route: '/app',
|
||||||
|
},
|
||||||
|
]
|
||||||
|
data.map((app) => {
|
||||||
|
if (app.name === 'lms') return
|
||||||
|
_apps.push({
|
||||||
|
name: app.name,
|
||||||
|
logo: app.logo,
|
||||||
|
title: __(app.title),
|
||||||
|
route: app.route,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
return _apps
|
||||||
|
},
|
||||||
|
})
|
||||||
|
</script>
|
||||||
196
frontend/src/components/Assessments.vue
Normal file
196
frontend/src/components/Assessments.vue
Normal file
@@ -0,0 +1,196 @@
|
|||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div class="text-lg font-semibold mb-4">
|
||||||
|
{{ __('Assessments') }}
|
||||||
|
</div>
|
||||||
|
<Button v-if="canSeeAddButton()" @click="showModal = true">
|
||||||
|
<template #prefix>
|
||||||
|
<Plus class="h-4 w-4" />
|
||||||
|
</template>
|
||||||
|
{{ __('Add') }}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<div v-if="assessments.data?.length">
|
||||||
|
<ListView
|
||||||
|
:columns="getAssessmentColumns()"
|
||||||
|
:rows="assessments.data"
|
||||||
|
row-key="name"
|
||||||
|
:options="{
|
||||||
|
showTooltip: false,
|
||||||
|
getRowRoute: (row) => getRowRoute(row),
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
<ListHeader
|
||||||
|
class="mb-2 grid items-center space-x-4 rounded bg-gray-100 p-2"
|
||||||
|
>
|
||||||
|
<ListHeaderItem :item="item" v-for="item in getAssessmentColumns()">
|
||||||
|
<template #prefix="{ item }">
|
||||||
|
<component
|
||||||
|
v-if="item.icon"
|
||||||
|
:is="item.icon"
|
||||||
|
class="h-4 w-4 stroke-1.5 ml-4"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
</ListHeaderItem>
|
||||||
|
</ListHeader>
|
||||||
|
<ListRows>
|
||||||
|
<ListRow :row="row" v-for="row in assessments.data">
|
||||||
|
<template #default="{ column, item }">
|
||||||
|
<ListRowItem :item="row[column.key]" :align="column.align">
|
||||||
|
<div>
|
||||||
|
{{ row[column.key] }}
|
||||||
|
</div>
|
||||||
|
</ListRowItem>
|
||||||
|
</template>
|
||||||
|
</ListRow>
|
||||||
|
</ListRows>
|
||||||
|
<ListSelectBanner>
|
||||||
|
<template #actions="{ unselectAll, selections }">
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
@click="removeAssessments(selections, unselectAll)"
|
||||||
|
>
|
||||||
|
<Trash2 class="h-4 w-4 stroke-1.5" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</ListSelectBanner>
|
||||||
|
</ListView>
|
||||||
|
</div>
|
||||||
|
<div v-else class="text-sm italic text-gray-600">
|
||||||
|
{{ __('No Assessments') }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<AssessmentModal
|
||||||
|
v-model="showModal"
|
||||||
|
v-model:assessments="assessments"
|
||||||
|
:batch="props.batch"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
<script setup>
|
||||||
|
import {
|
||||||
|
ListView,
|
||||||
|
ListRow,
|
||||||
|
ListRows,
|
||||||
|
ListHeader,
|
||||||
|
ListHeaderItem,
|
||||||
|
ListRowItem,
|
||||||
|
ListSelectBanner,
|
||||||
|
createResource,
|
||||||
|
Button,
|
||||||
|
} from 'frappe-ui'
|
||||||
|
import { inject, ref } from 'vue'
|
||||||
|
import AssessmentModal from '@/components/Modals/AssessmentModal.vue'
|
||||||
|
import { Plus, Trash2 } from 'lucide-vue-next'
|
||||||
|
|
||||||
|
const user = inject('$user')
|
||||||
|
const showModal = ref(false)
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
batch: {
|
||||||
|
type: String,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
rows: {
|
||||||
|
type: Array,
|
||||||
|
},
|
||||||
|
columns: {
|
||||||
|
type: Array,
|
||||||
|
},
|
||||||
|
options: {
|
||||||
|
type: Object,
|
||||||
|
default: () => ({
|
||||||
|
selectable: true,
|
||||||
|
totalCount: 0,
|
||||||
|
rowCount: 0,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const assessments = createResource({
|
||||||
|
url: 'lms.lms.utils.get_assessments',
|
||||||
|
params: {
|
||||||
|
batch: props.batch,
|
||||||
|
},
|
||||||
|
auto: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
const deleteAssessments = createResource({
|
||||||
|
url: 'lms.lms.api.delete_documents',
|
||||||
|
makeParams(values) {
|
||||||
|
return {
|
||||||
|
doctype: 'LMS Assessment',
|
||||||
|
documents: values.assessments,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const removeAssessments = (selections, unselectAll) => {
|
||||||
|
deleteAssessments.submit(
|
||||||
|
{ assessments: Array.from(selections) },
|
||||||
|
{
|
||||||
|
onSuccess(data) {
|
||||||
|
assessments.reload()
|
||||||
|
unselectAll()
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const getRowRoute = (row) => {
|
||||||
|
if (row.assessment_type == 'LMS Assignment') {
|
||||||
|
if (row.submission) {
|
||||||
|
return {
|
||||||
|
name: 'AssignmentSubmission',
|
||||||
|
params: {
|
||||||
|
assignmentName: row.assessment_name,
|
||||||
|
submissionName: row.submission.name,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
return {
|
||||||
|
name: 'AssignmentSubmission',
|
||||||
|
params: {
|
||||||
|
assignmentName: row.assessment_name,
|
||||||
|
submissionName: 'new',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
return {
|
||||||
|
name: 'QuizPage',
|
||||||
|
params: {
|
||||||
|
quizID: row.assessment_name,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const canSeeAddButton = () => {
|
||||||
|
return user.data?.is_moderator || user.data?.is_evaluator
|
||||||
|
}
|
||||||
|
|
||||||
|
const getAssessmentColumns = () => {
|
||||||
|
let columns = [
|
||||||
|
{
|
||||||
|
label: 'Assessment',
|
||||||
|
key: 'title',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Type',
|
||||||
|
key: 'assessment_type',
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
if (!user.data?.is_moderator) {
|
||||||
|
columns.push({
|
||||||
|
label: 'Status/Score',
|
||||||
|
key: 'status',
|
||||||
|
align: 'center',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return columns
|
||||||
|
}
|
||||||
|
</script>
|
||||||
134
frontend/src/components/AudioBlock.vue
Normal file
134
frontend/src/components/AudioBlock.vue
Normal file
@@ -0,0 +1,134 @@
|
|||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<!-- <audio width="100%" controls controlsList="nodownload" class="mb-4">
|
||||||
|
<source :src="encodeURI(file)" type="audio/mp3" />
|
||||||
|
</audio> -->
|
||||||
|
<audio @ended="handleAudioEnd" controlsList="nodownload" class="mb-4">
|
||||||
|
<source :src="encodeURI(file)" type="audio/mp3" />
|
||||||
|
</audio>
|
||||||
|
<div class="flex items-center space-x-2 shadow rounded-lg p-1 w-1/2">
|
||||||
|
<Button variant="ghost" @click="togglePlay">
|
||||||
|
<template #icon>
|
||||||
|
<Play v-if="!isPlaying" class="w-4 h-4 text-gray-900" />
|
||||||
|
<Pause v-else class="w-4 h-4 text-gray-900" />
|
||||||
|
</template>
|
||||||
|
</Button>
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
min="0"
|
||||||
|
:max="duration"
|
||||||
|
step="0.1"
|
||||||
|
v-model="currentTime"
|
||||||
|
@input="changeCurrentTime"
|
||||||
|
class="duration-slider w-full h-1"
|
||||||
|
/>
|
||||||
|
<span class="text-xs text-gray-900 font-medium">
|
||||||
|
{{ formatTime(currentTime) }} / {{ formatTime(duration) }}
|
||||||
|
</span>
|
||||||
|
<Button variant="ghost" @click="toggleMute">
|
||||||
|
<template #icon>
|
||||||
|
<Volume2 v-if="!isMuted" class="w-4 h-4 text-gray-900" />
|
||||||
|
<VolumeX v-else class="w-4 h-4 text-gray-900" />
|
||||||
|
</template>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, onMounted, watch } from 'vue'
|
||||||
|
import { Play, Pause, Volume2, VolumeX } from 'lucide-vue-next'
|
||||||
|
import { Button } from 'frappe-ui'
|
||||||
|
|
||||||
|
const isPlaying = ref(false)
|
||||||
|
const audio = ref(null)
|
||||||
|
let isMuted = ref(false)
|
||||||
|
let currentTime = ref(0)
|
||||||
|
let duration = ref(0)
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
file: {
|
||||||
|
type: String,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
setTimeout(() => {
|
||||||
|
audio.value = document.querySelector('audio')
|
||||||
|
audio.value.onloadedmetadata = () => {
|
||||||
|
duration.value = audio.value.duration
|
||||||
|
}
|
||||||
|
audio.value.ontimeupdate = () => {
|
||||||
|
currentTime.value = audio.value.currentTime
|
||||||
|
}
|
||||||
|
}, 0)
|
||||||
|
})
|
||||||
|
|
||||||
|
const togglePlay = () => {
|
||||||
|
if (audio.value.paused) {
|
||||||
|
audio.value.play()
|
||||||
|
isPlaying.value = true
|
||||||
|
} else {
|
||||||
|
audio.value.pause()
|
||||||
|
isPlaying.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const toggleMute = () => {
|
||||||
|
audio.value.muted = !audio.value.muted
|
||||||
|
isMuted.value = audio.value.muted
|
||||||
|
}
|
||||||
|
|
||||||
|
const changeCurrentTime = () => {
|
||||||
|
audio.value.currentTime = currentTime.value
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleAudioEnd = () => {
|
||||||
|
isPlaying.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatTime = (time) => {
|
||||||
|
const minutes = Math.floor(time / 60)
|
||||||
|
const seconds = Math.floor(time % 60)
|
||||||
|
return `${minutes}:${seconds < 10 ? '0' : ''}${seconds}`
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(isPlaying, (newVal) => {
|
||||||
|
if (newVal) {
|
||||||
|
audio.value.play()
|
||||||
|
} else {
|
||||||
|
audio.value.pause()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
<style>
|
||||||
|
.duration-slider {
|
||||||
|
flex: 1;
|
||||||
|
-webkit-appearance: none;
|
||||||
|
appearance: none;
|
||||||
|
background-color: theme('colors.gray.400');
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.duration-slider::-webkit-slider-thumb {
|
||||||
|
height: 10px;
|
||||||
|
width: 10px;
|
||||||
|
-webkit-appearance: none;
|
||||||
|
background-color: theme('colors.gray.900');
|
||||||
|
}
|
||||||
|
|
||||||
|
@media screen and (-webkit-min-device-pixel-ratio: 0) {
|
||||||
|
input[type='range'] {
|
||||||
|
overflow: hidden;
|
||||||
|
width: 150px;
|
||||||
|
-webkit-appearance: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
input[type='range']::-webkit-slider-thumb {
|
||||||
|
-webkit-appearance: none;
|
||||||
|
cursor: pointer;
|
||||||
|
box-shadow: -150px 0 0 150px theme('colors.gray.900');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
109
frontend/src/components/BatchCard.vue
Normal file
109
frontend/src/components/BatchCard.vue
Normal file
@@ -0,0 +1,109 @@
|
|||||||
|
<template>
|
||||||
|
<div
|
||||||
|
class="flex flex-col shadow hover:bg-gray-100 rounded-md p-4 h-full"
|
||||||
|
style="min-height: 150px"
|
||||||
|
>
|
||||||
|
<div class="text-lg leading-5 font-semibold mb-2">
|
||||||
|
{{ batch.title }}
|
||||||
|
</div>
|
||||||
|
<Badge
|
||||||
|
v-if="batch.seat_count && batch.seats_left > 0"
|
||||||
|
theme="green"
|
||||||
|
class="self-start mb-2"
|
||||||
|
>
|
||||||
|
{{ batch.seats_left }}
|
||||||
|
<span v-if="batch.seats_left > 1">{{ __('Seats Left') }}</span
|
||||||
|
><span v-else-if="batch.seats_left == 1">{{ __('Seat Left') }}</span>
|
||||||
|
</Badge>
|
||||||
|
<Badge
|
||||||
|
v-else-if="batch.seat_count && batch.seats_left <= 0"
|
||||||
|
theme="red"
|
||||||
|
class="self-start mb-2"
|
||||||
|
>
|
||||||
|
{{ __('Sold Out') }}
|
||||||
|
</Badge>
|
||||||
|
<div class="short-introduction text-sm text-gray-700">
|
||||||
|
{{ batch.description }}
|
||||||
|
</div>
|
||||||
|
<div v-if="batch.amount" class="font-semibold mb-4">
|
||||||
|
{{ batch.price }}
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-col space-y-2 mt-auto">
|
||||||
|
<DateRange
|
||||||
|
:startDate="batch.start_date"
|
||||||
|
:endDate="batch.end_date"
|
||||||
|
class="text-sm text-gray-700"
|
||||||
|
/>
|
||||||
|
<div class="flex items-center text-sm text-gray-700">
|
||||||
|
<Clock class="h-4 w-4 stroke-1.5 mr-2 text-gray-700" />
|
||||||
|
<span>
|
||||||
|
{{ formatTime(batch.start_time) }} - {{ formatTime(batch.end_time) }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-if="batch.timezone"
|
||||||
|
class="flex items-center text-sm text-gray-700"
|
||||||
|
>
|
||||||
|
<Globe class="h-4 w-4 stroke-1.5 mr-2 text-gray-600" />
|
||||||
|
<span>
|
||||||
|
{{ batch.timezone }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-if="batch.instructors?.length"
|
||||||
|
class="flex avatar-group overlap mt-4"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="h-6 mr-1"
|
||||||
|
:class="{ 'avatar-group overlap': batch.instructors.length > 1 }"
|
||||||
|
>
|
||||||
|
<UserAvatar
|
||||||
|
v-for="instructor in batch.instructors"
|
||||||
|
:user="instructor"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<CourseInstructors :instructors="batch.instructors" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<script setup>
|
||||||
|
import { Badge } from 'frappe-ui'
|
||||||
|
import { formatTime } from '../utils'
|
||||||
|
import { Clock, BookOpen, Globe } from 'lucide-vue-next'
|
||||||
|
import DateRange from '@/components/Common/DateRange.vue'
|
||||||
|
import CourseInstructors from '@/components/CourseInstructors.vue'
|
||||||
|
import UserAvatar from '@/components/UserAvatar.vue'
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
batch: {
|
||||||
|
type: Object,
|
||||||
|
default: null,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
<style>
|
||||||
|
.short-introduction {
|
||||||
|
display: -webkit-box;
|
||||||
|
-webkit-line-clamp: 2;
|
||||||
|
-webkit-box-orient: vertical;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
width: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
margin: 0.25rem 0 1rem;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.avatar-group {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.avatar-group .avatar {
|
||||||
|
transition: margin 0.1s ease-in-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
.avatar-group.overlap .avatar + .avatar {
|
||||||
|
margin-left: calc(-8px);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
160
frontend/src/components/BatchCourses.vue
Normal file
160
frontend/src/components/BatchCourses.vue
Normal file
@@ -0,0 +1,160 @@
|
|||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<div class="flex items-center justify-between mb-4">
|
||||||
|
<div class="text-xl font-semibold">
|
||||||
|
{{ __('Courses') }}
|
||||||
|
</div>
|
||||||
|
<Button v-if="canSeeAddButton()" @click="openCourseModal()">
|
||||||
|
<template #prefix>
|
||||||
|
<Plus class="h-4 w-4" />
|
||||||
|
</template>
|
||||||
|
{{ __('Add') }}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<div v-if="courses.data?.length">
|
||||||
|
<ListView
|
||||||
|
:columns="getCoursesColumns()"
|
||||||
|
:rows="courses.data"
|
||||||
|
row-key="batch_course"
|
||||||
|
:options="{
|
||||||
|
showTooltip: false,
|
||||||
|
getRowRoute: (row) => ({
|
||||||
|
name: 'CourseDetail',
|
||||||
|
params: { courseName: row.name },
|
||||||
|
}),
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
<ListHeader
|
||||||
|
class="mb-2 grid items-center space-x-4 rounded bg-gray-100 p-2"
|
||||||
|
>
|
||||||
|
<ListHeaderItem :item="item" v-for="item in getCoursesColumns()">
|
||||||
|
<template #prefix="{ item }">
|
||||||
|
<component
|
||||||
|
v-if="item.icon"
|
||||||
|
:is="item.icon"
|
||||||
|
class="h-4 w-4 stroke-1.5 ml-4"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
</ListHeaderItem>
|
||||||
|
</ListHeader>
|
||||||
|
<ListRows>
|
||||||
|
<ListRow :row="row" v-for="row in courses.data">
|
||||||
|
<template #default="{ column, item }">
|
||||||
|
<ListRowItem :item="row[column.key]" :align="column.align">
|
||||||
|
<div>
|
||||||
|
{{ row[column.key] }}
|
||||||
|
</div>
|
||||||
|
</ListRowItem>
|
||||||
|
</template>
|
||||||
|
</ListRow>
|
||||||
|
</ListRows>
|
||||||
|
<ListSelectBanner>
|
||||||
|
<template #actions="{ unselectAll, selections }">
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
@click="removeCourses(selections, unselectAll)"
|
||||||
|
>
|
||||||
|
<Trash2 class="h-4 w-4 stroke-1.5" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</ListSelectBanner>
|
||||||
|
</ListView>
|
||||||
|
</div>
|
||||||
|
<BatchCourseModal
|
||||||
|
v-model="showCourseModal"
|
||||||
|
:batch="batch"
|
||||||
|
v-model:courses="courses"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<script setup>
|
||||||
|
import { ref, inject } from 'vue'
|
||||||
|
import BatchCourseModal from '@/components/Modals/BatchCourseModal.vue'
|
||||||
|
import {
|
||||||
|
createResource,
|
||||||
|
Button,
|
||||||
|
ListHeader,
|
||||||
|
ListHeaderItem,
|
||||||
|
ListSelectBanner,
|
||||||
|
ListRow,
|
||||||
|
ListRows,
|
||||||
|
ListView,
|
||||||
|
ListRowItem,
|
||||||
|
} from 'frappe-ui'
|
||||||
|
import { Plus, Trash2 } from 'lucide-vue-next'
|
||||||
|
import { showToast } from '@/utils'
|
||||||
|
|
||||||
|
const showCourseModal = ref(false)
|
||||||
|
const user = inject('$user')
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
batch: {
|
||||||
|
type: String,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const courses = createResource({
|
||||||
|
url: 'lms.lms.utils.get_batch_courses',
|
||||||
|
params: {
|
||||||
|
batch: props.batch,
|
||||||
|
},
|
||||||
|
cache: ['batchCourses', props.batchName],
|
||||||
|
auto: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
const openCourseModal = () => {
|
||||||
|
showCourseModal.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
const getCoursesColumns = () => {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
label: 'Title',
|
||||||
|
key: 'title',
|
||||||
|
width: 2,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Lessons',
|
||||||
|
key: 'lesson_count',
|
||||||
|
align: 'right',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Enrollments',
|
||||||
|
align: 'right',
|
||||||
|
key: 'enrollment_count',
|
||||||
|
},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
const deleteCourses = createResource({
|
||||||
|
url: 'lms.lms.api.delete_documents',
|
||||||
|
makeParams(values) {
|
||||||
|
return {
|
||||||
|
doctype: 'Batch Course',
|
||||||
|
documents: values.courses,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const removeCourses = (selections, unselectAll) => {
|
||||||
|
deleteCourses.submit(
|
||||||
|
{
|
||||||
|
courses: Array.from(selections),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
onSuccess(data) {
|
||||||
|
courses.reload()
|
||||||
|
showToast(__('Success'), __('Courses deleted successfully'), 'check')
|
||||||
|
unselectAll()
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const canSeeAddButton = () => {
|
||||||
|
return user.data?.is_moderator || user.data?.is_evaluator
|
||||||
|
}
|
||||||
|
</script>
|
||||||
26
frontend/src/components/BatchDashboard.vue
Normal file
26
frontend/src/components/BatchDashboard.vue
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<UpcomingEvaluations
|
||||||
|
:batch="batch.data.name"
|
||||||
|
:endDate="batch.data.evaluation_end_date"
|
||||||
|
:courses="batch.data.courses"
|
||||||
|
:isStudent="isStudent"
|
||||||
|
/>
|
||||||
|
<Assessments :batch="batch.data.name" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<script setup>
|
||||||
|
import UpcomingEvaluations from '@/components/UpcomingEvaluations.vue'
|
||||||
|
import Assessments from '@/components/Assessments.vue'
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
batch: {
|
||||||
|
type: Object,
|
||||||
|
default: null,
|
||||||
|
},
|
||||||
|
isStudent: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
</script>
|
||||||
164
frontend/src/components/BatchOverlay.vue
Normal file
164
frontend/src/components/BatchOverlay.vue
Normal file
@@ -0,0 +1,164 @@
|
|||||||
|
<template>
|
||||||
|
<div v-if="batch.data" class="shadow rounded-md p-5 lg:w-72">
|
||||||
|
<Badge
|
||||||
|
v-if="batch.data.seat_count && seats_left > 0"
|
||||||
|
theme="green"
|
||||||
|
class="self-start mb-2 float-right"
|
||||||
|
>
|
||||||
|
{{ seats_left }} <span v-if="seats_left > 1">{{ __('Seats Left') }}</span
|
||||||
|
><span v-else-if="seats_left == 1">{{ __('Seat Left') }}</span>
|
||||||
|
</Badge>
|
||||||
|
<Badge
|
||||||
|
v-else-if="batch.data.seat_count && seats_left <= 0"
|
||||||
|
theme="red"
|
||||||
|
class="self-start mb-2 float-right"
|
||||||
|
>
|
||||||
|
{{ __('Sold Out') }}
|
||||||
|
</Badge>
|
||||||
|
<div v-if="batch.data.amount" class="text-lg font-semibold mb-3">
|
||||||
|
{{ formatNumberIntoCurrency(batch.data.amount, batch.data.currency) }}
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center mb-3">
|
||||||
|
<BookOpen class="h-4 w-4 stroke-1.5 mr-2 text-gray-700" />
|
||||||
|
<span> {{ batch.data.courses.length }} {{ __('Courses') }} </span>
|
||||||
|
</div>
|
||||||
|
<DateRange
|
||||||
|
:startDate="batch.data.start_date"
|
||||||
|
:endDate="batch.data.end_date"
|
||||||
|
class="mb-3"
|
||||||
|
/>
|
||||||
|
<div class="flex items-center mb-3">
|
||||||
|
<Clock class="h-4 w-4 stroke-1.5 mr-2 text-gray-700" />
|
||||||
|
<span>
|
||||||
|
{{ formatTime(batch.data.start_time) }} -
|
||||||
|
{{ formatTime(batch.data.end_time) }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div v-if="batch.data.timezone" class="flex items-center">
|
||||||
|
<Globe class="h-4 w-4 stroke-1.5 mr-2 text-gray-700" />
|
||||||
|
<span>
|
||||||
|
{{ batch.data.timezone }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<router-link
|
||||||
|
v-if="isModerator || isStudent"
|
||||||
|
:to="{
|
||||||
|
name: 'Batch',
|
||||||
|
params: {
|
||||||
|
batchName: batch.data.name,
|
||||||
|
},
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
<Button variant="solid" class="w-full mt-4">
|
||||||
|
<span>
|
||||||
|
{{ isModerator ? __('Manage Batch') : __('Visit Batch') }}
|
||||||
|
</span>
|
||||||
|
</Button>
|
||||||
|
</router-link>
|
||||||
|
<router-link
|
||||||
|
:to="{
|
||||||
|
name: 'Billing',
|
||||||
|
params: {
|
||||||
|
type: 'batch',
|
||||||
|
name: batch.data.name,
|
||||||
|
},
|
||||||
|
}"
|
||||||
|
v-else-if="batch.data.paid_batch && batch.data.seats_left"
|
||||||
|
>
|
||||||
|
<Button v-if="!isStudent" class="w-full mt-4" variant="solid">
|
||||||
|
<span>
|
||||||
|
{{ __('Register Now') }}
|
||||||
|
</span>
|
||||||
|
</Button>
|
||||||
|
</router-link>
|
||||||
|
<Button
|
||||||
|
variant="solid"
|
||||||
|
class="w-full mt-2"
|
||||||
|
v-else-if="batch.data.allow_self_enrollment && batch.data.seats_left"
|
||||||
|
@click="enrollInBatch()"
|
||||||
|
>
|
||||||
|
{{ __('Enroll Now') }}
|
||||||
|
</Button>
|
||||||
|
<router-link
|
||||||
|
v-if="isModerator"
|
||||||
|
:to="{
|
||||||
|
name: 'BatchForm',
|
||||||
|
params: {
|
||||||
|
batchName: batch.data.name,
|
||||||
|
},
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
<Button class="w-full mt-2">
|
||||||
|
<span>
|
||||||
|
{{ __('Edit') }}
|
||||||
|
</span>
|
||||||
|
</Button>
|
||||||
|
</router-link>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<script setup>
|
||||||
|
import { inject, computed } from 'vue'
|
||||||
|
import { Badge, Button, createResource } from 'frappe-ui'
|
||||||
|
import { BookOpen, Clock, Globe } from 'lucide-vue-next'
|
||||||
|
import { formatNumberIntoCurrency, formatTime, showToast } from '@/utils'
|
||||||
|
import DateRange from '@/components/Common/DateRange.vue'
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
|
|
||||||
|
const router = useRouter()
|
||||||
|
const user = inject('$user')
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
batch: {
|
||||||
|
type: Object,
|
||||||
|
default: null,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const enroll = createResource({
|
||||||
|
url: 'lms.lms.utils.enroll_in_batch',
|
||||||
|
makeParams(values) {
|
||||||
|
return {
|
||||||
|
batch: props.batch.data.name,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const enrollInBatch = () => {
|
||||||
|
if (!user.data) {
|
||||||
|
window.location.href = `/login?redirect-to=/batches/details/${props.batch.data.name}`
|
||||||
|
}
|
||||||
|
enroll.submit(
|
||||||
|
{},
|
||||||
|
{
|
||||||
|
onSuccess(data) {
|
||||||
|
showToast(
|
||||||
|
__('Success'),
|
||||||
|
__('You have been enrolled in this batch'),
|
||||||
|
'check'
|
||||||
|
)
|
||||||
|
router.push({
|
||||||
|
name: 'Batch',
|
||||||
|
params: {
|
||||||
|
batchName: props.batch.data.name,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const seats_left = computed(() => {
|
||||||
|
if (props.batch.data?.seat_count) {
|
||||||
|
return props.batch.data?.seat_count - props.batch.data?.students?.length
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
})
|
||||||
|
|
||||||
|
const isStudent = computed(() => {
|
||||||
|
return props.batch.data?.students?.includes(user.data?.name)
|
||||||
|
})
|
||||||
|
|
||||||
|
const isModerator = computed(() => {
|
||||||
|
return user.data?.is_moderator
|
||||||
|
})
|
||||||
|
</script>
|
||||||
163
frontend/src/components/BatchStudents.vue
Normal file
163
frontend/src/components/BatchStudents.vue
Normal file
@@ -0,0 +1,163 @@
|
|||||||
|
<template>
|
||||||
|
<Button class="float-right mb-3" @click="openStudentModal()">
|
||||||
|
<template #prefix>
|
||||||
|
<Plus class="h-4 w-4" />
|
||||||
|
</template>
|
||||||
|
{{ __('Add') }}
|
||||||
|
</Button>
|
||||||
|
<div class="text-lg font-semibold mb-4">
|
||||||
|
{{ __('Students') }}
|
||||||
|
</div>
|
||||||
|
<div v-if="students.data?.length">
|
||||||
|
<ListView
|
||||||
|
:columns="getStudentColumns()"
|
||||||
|
:rows="students.data"
|
||||||
|
row-key="name"
|
||||||
|
:options="{ showTooltip: false }"
|
||||||
|
>
|
||||||
|
<ListHeader
|
||||||
|
class="mb-2 grid items-center space-x-4 rounded bg-gray-100 p-2"
|
||||||
|
>
|
||||||
|
<ListHeaderItem :item="item" v-for="item in getStudentColumns()">
|
||||||
|
<template #prefix="{ item }">
|
||||||
|
<component
|
||||||
|
v-if="item.icon"
|
||||||
|
:is="item.icon"
|
||||||
|
class="h-4 w-4 stroke-1.5 ml-4"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
</ListHeaderItem>
|
||||||
|
</ListHeader>
|
||||||
|
<ListRows>
|
||||||
|
<ListRow :row="row" v-for="row in students.data">
|
||||||
|
<template #default="{ column, item }">
|
||||||
|
<ListRowItem :item="row[column.key]" :align="column.align">
|
||||||
|
<template #prefix>
|
||||||
|
<div v-if="column.key == 'full_name'">
|
||||||
|
<Avatar
|
||||||
|
class="flex items-center"
|
||||||
|
:image="row['user_image']"
|
||||||
|
:label="item"
|
||||||
|
size="sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<div>
|
||||||
|
{{ row[column.key] }}
|
||||||
|
</div>
|
||||||
|
</ListRowItem>
|
||||||
|
</template>
|
||||||
|
</ListRow>
|
||||||
|
</ListRows>
|
||||||
|
<ListSelectBanner>
|
||||||
|
<template #actions="{ unselectAll, selections }">
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
@click="removeStudents(selections, unselectAll)"
|
||||||
|
>
|
||||||
|
<Trash2 class="h-4 w-4 stroke-1.5" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</ListSelectBanner>
|
||||||
|
</ListView>
|
||||||
|
</div>
|
||||||
|
<div v-else class="text-sm italic text-gray-600">
|
||||||
|
{{ __('There are no students in this batch.') }}
|
||||||
|
</div>
|
||||||
|
<StudentModal
|
||||||
|
:batch="props.batch"
|
||||||
|
v-model="showStudentModal"
|
||||||
|
v-model:reloadStudents="students"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
<script setup>
|
||||||
|
import {
|
||||||
|
createResource,
|
||||||
|
ListHeader,
|
||||||
|
ListHeaderItem,
|
||||||
|
ListSelectBanner,
|
||||||
|
ListRow,
|
||||||
|
ListRows,
|
||||||
|
ListView,
|
||||||
|
ListRowItem,
|
||||||
|
Avatar,
|
||||||
|
Button,
|
||||||
|
} from 'frappe-ui'
|
||||||
|
import { Trash2, Plus } from 'lucide-vue-next'
|
||||||
|
import { ref } from 'vue'
|
||||||
|
import StudentModal from '@/components/Modals/StudentModal.vue'
|
||||||
|
import { showToast } from '@/utils'
|
||||||
|
|
||||||
|
const showStudentModal = ref(false)
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
batch: {
|
||||||
|
type: String,
|
||||||
|
default: null,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const students = createResource({
|
||||||
|
url: 'lms.lms.utils.get_batch_students',
|
||||||
|
cache: ['students', props.batch],
|
||||||
|
params: {
|
||||||
|
batch: props.batch,
|
||||||
|
},
|
||||||
|
auto: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
const getStudentColumns = () => {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
label: 'Full Name',
|
||||||
|
key: 'full_name',
|
||||||
|
width: 2,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Courses Done',
|
||||||
|
key: 'courses_completed',
|
||||||
|
align: 'center',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Assessments Done',
|
||||||
|
key: 'assessments_completed',
|
||||||
|
align: 'center',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Last Active',
|
||||||
|
key: 'last_active',
|
||||||
|
},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
const openStudentModal = () => {
|
||||||
|
showStudentModal.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
const deleteStudents = createResource({
|
||||||
|
url: 'lms.lms.api.delete_documents',
|
||||||
|
makeParams(values) {
|
||||||
|
return {
|
||||||
|
doctype: 'Batch Student',
|
||||||
|
documents: values.students,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const removeStudents = (selections, unselectAll) => {
|
||||||
|
deleteStudents.submit(
|
||||||
|
{
|
||||||
|
students: Array.from(selections),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
onSuccess(data) {
|
||||||
|
students.reload()
|
||||||
|
showToast(__('Success'), __('Students deleted successfully'), 'check')
|
||||||
|
unselectAll()
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
</script>
|
||||||
92
frontend/src/components/BrandSettings.vue
Normal file
92
frontend/src/components/BrandSettings.vue
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
<template>
|
||||||
|
<div class="flex flex-col justify-between min-h-0">
|
||||||
|
<div>
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div class="font-semibold mb-1">
|
||||||
|
{{ __(label) }}
|
||||||
|
</div>
|
||||||
|
<Badge
|
||||||
|
v-if="isDirty"
|
||||||
|
:label="__('Not Saved')"
|
||||||
|
variant="subtle"
|
||||||
|
theme="orange"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="text-xs text-gray-600">
|
||||||
|
{{ __(description) }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="overflow-y-auto">
|
||||||
|
<SettingFields :fields="fields" :data="data.data" />
|
||||||
|
<div class="flex flex-row-reverse mt-auto">
|
||||||
|
<Button variant="solid" :loading="saveSettings.loading" @click="update">
|
||||||
|
{{ __('Update') }}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<script setup>
|
||||||
|
import { createResource, Button, Badge } from 'frappe-ui'
|
||||||
|
import SettingFields from '@/components/SettingFields.vue'
|
||||||
|
import { watch, ref } from 'vue'
|
||||||
|
|
||||||
|
const isDirty = ref(false)
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
fields: {
|
||||||
|
type: Array,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
data: {
|
||||||
|
type: Object,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
label: {
|
||||||
|
type: String,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
description: {
|
||||||
|
type: String,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const saveSettings = createResource({
|
||||||
|
url: 'frappe.client.set_value',
|
||||||
|
makeParams(values) {
|
||||||
|
return {
|
||||||
|
doctype: 'Website Settings',
|
||||||
|
name: 'Website Settings',
|
||||||
|
fieldname: values.fields,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const update = () => {
|
||||||
|
let fieldsToSave = {}
|
||||||
|
let imageFields = ['favicon', 'banner_image', 'footer_logo']
|
||||||
|
props.fields.forEach((f) => {
|
||||||
|
if (imageFields.includes(f.name)) {
|
||||||
|
fieldsToSave[f.name] = f.value ? f.value.file_url : null
|
||||||
|
} else {
|
||||||
|
fieldsToSave[f.name] = f.value
|
||||||
|
}
|
||||||
|
})
|
||||||
|
saveSettings.submit(
|
||||||
|
{
|
||||||
|
fields: fieldsToSave,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
onSuccess(data) {
|
||||||
|
isDirty.value = false
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(props.data, (newData) => {
|
||||||
|
if (newData && !isDirty.value) {
|
||||||
|
isDirty.value = true
|
||||||
|
}
|
||||||
|
})
|
||||||
|
</script>
|
||||||
151
frontend/src/components/Categories.vue
Normal file
151
frontend/src/components/Categories.vue
Normal file
@@ -0,0 +1,151 @@
|
|||||||
|
<template>
|
||||||
|
<div class="flex flex-col min-h-0">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div class="text-xl font-semibold mb-1">
|
||||||
|
{{ label }}
|
||||||
|
</div>
|
||||||
|
<Button @click="() => showCategoryForm()">
|
||||||
|
<template #icon>
|
||||||
|
<Plus v-if="!showForm" class="h-3 w-3 stroke-1.5" />
|
||||||
|
<X v-else class="h-3 w-3 stroke-1.5" />
|
||||||
|
</template>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-if="showForm"
|
||||||
|
class="flex items-center justify-between my-4 space-x-2"
|
||||||
|
>
|
||||||
|
<FormControl
|
||||||
|
ref="categoryInput"
|
||||||
|
v-model="category"
|
||||||
|
:placeholder="__('Category Name')"
|
||||||
|
class="flex-1"
|
||||||
|
/>
|
||||||
|
<Button @click="addCategory()" variant="subtle">
|
||||||
|
{{ __('Add') }}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="overflow-y-scroll">
|
||||||
|
<div class="text-base divide-y">
|
||||||
|
<FormControl
|
||||||
|
:value="cat.category"
|
||||||
|
type="text"
|
||||||
|
v-for="cat in categories.data"
|
||||||
|
class="form-control"
|
||||||
|
@change.stop="(e) => update(cat.name, e.target.value)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<script setup>
|
||||||
|
import {
|
||||||
|
Button,
|
||||||
|
FormControl,
|
||||||
|
createListResource,
|
||||||
|
createResource,
|
||||||
|
debounce,
|
||||||
|
} from 'frappe-ui'
|
||||||
|
import { Plus, X } from 'lucide-vue-next'
|
||||||
|
import { ref } from 'vue'
|
||||||
|
|
||||||
|
const showForm = ref(false)
|
||||||
|
const category = ref(null)
|
||||||
|
const categoryInput = ref(null)
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
label: {
|
||||||
|
type: String,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
description: {
|
||||||
|
type: String,
|
||||||
|
default: '',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const categories = createListResource({
|
||||||
|
doctype: 'LMS Category',
|
||||||
|
fields: ['name', 'category'],
|
||||||
|
auto: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
const newCategory = createResource({
|
||||||
|
url: 'frappe.client.insert',
|
||||||
|
makeParams(values) {
|
||||||
|
return {
|
||||||
|
doc: {
|
||||||
|
doctype: 'LMS Category',
|
||||||
|
category: category.value,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const addCategory = () => {
|
||||||
|
newCategory.submit(
|
||||||
|
{},
|
||||||
|
{
|
||||||
|
onSuccess(data) {
|
||||||
|
categories.reload()
|
||||||
|
category.value = null
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const showCategoryForm = () => {
|
||||||
|
showForm.value = !showForm.value
|
||||||
|
setTimeout(() => {
|
||||||
|
categoryInput.value.$el.querySelector('input').focus()
|
||||||
|
}, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateCategory = createResource({
|
||||||
|
url: 'frappe.client.rename_doc',
|
||||||
|
makeParams(values) {
|
||||||
|
return {
|
||||||
|
doctype: 'LMS Category',
|
||||||
|
old_name: values.name,
|
||||||
|
new_name: values.category,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const update = (name, value) => {
|
||||||
|
updateCategory.submit(
|
||||||
|
{
|
||||||
|
name: name,
|
||||||
|
category: value,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
onSuccess() {
|
||||||
|
categories.reload()
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
<style>
|
||||||
|
.form-control input {
|
||||||
|
padding: 1.25rem 0;
|
||||||
|
border-color: transparent;
|
||||||
|
background: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-control input:focus {
|
||||||
|
outline: transparent;
|
||||||
|
background: white;
|
||||||
|
box-shadow: none;
|
||||||
|
border-color: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-control input:hover {
|
||||||
|
outline: transparent;
|
||||||
|
background: white;
|
||||||
|
box-shadow: none;
|
||||||
|
border-color: transparent;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
22
frontend/src/components/Common/DateRange.vue
Normal file
22
frontend/src/components/Common/DateRange.vue
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
<template>
|
||||||
|
<div class="flex items-center">
|
||||||
|
<Calendar class="h-4 w-4 stroke-1.5 mr-2 text-gray-700" />
|
||||||
|
<span>
|
||||||
|
{{ getFormattedDateRange(props.startDate, props.endDate) }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { Calendar } from 'lucide-vue-next'
|
||||||
|
import { getFormattedDateRange } from '@/utils'
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
startDate: {
|
||||||
|
type: String,
|
||||||
|
},
|
||||||
|
endDate: {
|
||||||
|
type: String,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
</script>
|
||||||
286
frontend/src/components/Controls/Autocomplete.vue
Normal file
286
frontend/src/components/Controls/Autocomplete.vue
Normal file
@@ -0,0 +1,286 @@
|
|||||||
|
<template>
|
||||||
|
<Combobox v-model="selectedValue" nullable v-slot="{ open: isComboboxOpen }">
|
||||||
|
<Popover class="w-full" v-model:show="showOptions">
|
||||||
|
<template #target="{ open: openPopover, togglePopover }">
|
||||||
|
<slot name="target" v-bind="{ open: openPopover, togglePopover }">
|
||||||
|
<div class="w-full">
|
||||||
|
<button
|
||||||
|
class="flex w-full items-center justify-between focus:outline-none"
|
||||||
|
:class="inputClasses"
|
||||||
|
@click="() => togglePopover()"
|
||||||
|
>
|
||||||
|
<div class="flex items-center">
|
||||||
|
<slot name="prefix" />
|
||||||
|
<span
|
||||||
|
class="overflow-hidden text-ellipsis whitespace-nowrap text-base leading-5"
|
||||||
|
v-if="selectedValue"
|
||||||
|
>
|
||||||
|
{{ displayValue(selectedValue) }}
|
||||||
|
</span>
|
||||||
|
<span class="text-base leading-5 text-gray-500" v-else>
|
||||||
|
{{ placeholder || '' }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<ChevronDown class="h-4 w-4 stroke-1.5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</slot>
|
||||||
|
</template>
|
||||||
|
<template #body="{ isOpen }">
|
||||||
|
<div v-show="isOpen">
|
||||||
|
<div class="mt-1 rounded-lg bg-white py-1 text-base shadow-2xl">
|
||||||
|
<div class="relative px-1.5 pt-0.5">
|
||||||
|
<ComboboxInput
|
||||||
|
ref="search"
|
||||||
|
class="form-input w-full"
|
||||||
|
type="text"
|
||||||
|
@change="
|
||||||
|
(e) => {
|
||||||
|
query = e.target.value
|
||||||
|
}
|
||||||
|
"
|
||||||
|
:value="query"
|
||||||
|
autocomplete="off"
|
||||||
|
placeholder="Search"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
class="absolute right-1.5 inline-flex h-7 w-7 items-center justify-center"
|
||||||
|
@click="selectedValue = null"
|
||||||
|
>
|
||||||
|
<X class="h-4 w-4 stroke-1.5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<ComboboxOptions
|
||||||
|
class="my-1 max-h-[12rem] overflow-y-auto px-1.5"
|
||||||
|
static
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="mt-1.5"
|
||||||
|
v-for="group in groups"
|
||||||
|
:key="group.key"
|
||||||
|
v-show="group.items.length > 0"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
v-if="group.group && !group.hideLabel"
|
||||||
|
class="px-2.5 py-1.5 text-sm font-medium text-gray-500"
|
||||||
|
>
|
||||||
|
{{ group.group }}
|
||||||
|
</div>
|
||||||
|
<ComboboxOption
|
||||||
|
as="template"
|
||||||
|
v-for="option in group.items"
|
||||||
|
:key="option.value"
|
||||||
|
:value="option"
|
||||||
|
v-slot="{ active, selected }"
|
||||||
|
>
|
||||||
|
<li
|
||||||
|
:class="[
|
||||||
|
'flex items-center rounded px-2.5 py-2 text-base',
|
||||||
|
{ 'bg-gray-100': active },
|
||||||
|
]"
|
||||||
|
>
|
||||||
|
<slot
|
||||||
|
name="item-prefix"
|
||||||
|
v-bind="{ active, selected, option }"
|
||||||
|
/>
|
||||||
|
<slot
|
||||||
|
name="item-label"
|
||||||
|
v-bind="{ active, selected, option }"
|
||||||
|
>
|
||||||
|
<div class="flex flex-col space-y-1">
|
||||||
|
<div>
|
||||||
|
{{ option.label }}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-if="option.description"
|
||||||
|
class="text-xs text-gray-700"
|
||||||
|
v-html="option.description"
|
||||||
|
></div>
|
||||||
|
</div>
|
||||||
|
</slot>
|
||||||
|
</li>
|
||||||
|
</ComboboxOption>
|
||||||
|
</div>
|
||||||
|
<li
|
||||||
|
v-if="groups.length == 0"
|
||||||
|
class="mt-1.5 rounded-md px-2.5 py-1.5 text-base text-gray-600"
|
||||||
|
>
|
||||||
|
No results found
|
||||||
|
</li>
|
||||||
|
</ComboboxOptions>
|
||||||
|
<div v-if="slots.footer" class="border-t p-1.5 pb-0.5">
|
||||||
|
<slot
|
||||||
|
name="footer"
|
||||||
|
v-bind="{ value: search?.el._value, close }"
|
||||||
|
></slot>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</Popover>
|
||||||
|
</Combobox>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import {
|
||||||
|
Combobox,
|
||||||
|
ComboboxInput,
|
||||||
|
ComboboxOptions,
|
||||||
|
ComboboxOption,
|
||||||
|
} from '@headlessui/vue'
|
||||||
|
import { Popover, Button } from 'frappe-ui'
|
||||||
|
import { ChevronDown, X } from 'lucide-vue-next'
|
||||||
|
import { ref, computed, useAttrs, useSlots, watch, nextTick } from 'vue'
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
modelValue: {
|
||||||
|
type: String,
|
||||||
|
default: '',
|
||||||
|
},
|
||||||
|
options: {
|
||||||
|
type: Array,
|
||||||
|
default: () => [],
|
||||||
|
},
|
||||||
|
size: {
|
||||||
|
type: String,
|
||||||
|
default: 'md',
|
||||||
|
},
|
||||||
|
variant: {
|
||||||
|
type: String,
|
||||||
|
default: 'subtle',
|
||||||
|
},
|
||||||
|
placeholder: {
|
||||||
|
type: String,
|
||||||
|
default: '',
|
||||||
|
},
|
||||||
|
disabled: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
|
filterable: {
|
||||||
|
type: Boolean,
|
||||||
|
default: true,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
const emit = defineEmits(['update:modelValue', 'update:query', 'change'])
|
||||||
|
|
||||||
|
const query = ref('')
|
||||||
|
const showOptions = ref(false)
|
||||||
|
const search = ref(null)
|
||||||
|
|
||||||
|
const attrs = useAttrs()
|
||||||
|
const slots = useSlots()
|
||||||
|
|
||||||
|
const valuePropPassed = computed(() => 'value' in attrs)
|
||||||
|
|
||||||
|
const selectedValue = computed({
|
||||||
|
get() {
|
||||||
|
return valuePropPassed.value ? attrs.value : props.modelValue
|
||||||
|
},
|
||||||
|
set(val) {
|
||||||
|
query.value = ''
|
||||||
|
if (val) {
|
||||||
|
showOptions.value = false
|
||||||
|
}
|
||||||
|
emit(valuePropPassed.value ? 'change' : 'update:modelValue', val)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
function close() {
|
||||||
|
showOptions.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
const groups = computed(() => {
|
||||||
|
if (!props.options || props.options.length == 0) return []
|
||||||
|
|
||||||
|
let groups = props.options[0]?.group
|
||||||
|
? props.options
|
||||||
|
: [{ group: '', items: props.options }]
|
||||||
|
|
||||||
|
return groups
|
||||||
|
.map((group, i) => {
|
||||||
|
return {
|
||||||
|
key: i,
|
||||||
|
group: group.group,
|
||||||
|
hideLabel: group.hideLabel || false,
|
||||||
|
items: props.filterable ? filterOptions(group.items) : group.items,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.filter((group) => group.items.length > 0)
|
||||||
|
})
|
||||||
|
|
||||||
|
function filterOptions(options) {
|
||||||
|
if (!query.value) {
|
||||||
|
return options
|
||||||
|
}
|
||||||
|
return options.filter((option) => {
|
||||||
|
let searchTexts = [option.label, option.value]
|
||||||
|
return searchTexts.some((text) =>
|
||||||
|
(text || '').toString().toLowerCase().includes(query.value.toLowerCase())
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function displayValue(option) {
|
||||||
|
if (typeof option === 'string') {
|
||||||
|
let allOptions = groups.value.flatMap((group) => group.items)
|
||||||
|
let selectedOption = allOptions.find((o) => o.value === option)
|
||||||
|
return selectedOption?.label || option
|
||||||
|
}
|
||||||
|
return option?.label
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(query, (q) => {
|
||||||
|
emit('update:query', q)
|
||||||
|
})
|
||||||
|
|
||||||
|
watch(showOptions, (val) => {
|
||||||
|
if (val) {
|
||||||
|
nextTick(() => {
|
||||||
|
search.value.el.focus()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const textColor = computed(() => {
|
||||||
|
return props.disabled ? 'text-gray-600' : 'text-gray-800'
|
||||||
|
})
|
||||||
|
|
||||||
|
const inputClasses = computed(() => {
|
||||||
|
let sizeClasses = {
|
||||||
|
sm: 'text-base rounded h-7',
|
||||||
|
md: 'text-base rounded h-8',
|
||||||
|
lg: 'text-lg rounded-md h-10',
|
||||||
|
xl: 'text-xl rounded-md h-10',
|
||||||
|
}[props.size]
|
||||||
|
|
||||||
|
let paddingClasses = {
|
||||||
|
sm: 'py-1.5 px-2',
|
||||||
|
md: 'py-1.5 px-2.5',
|
||||||
|
lg: 'py-1.5 px-3',
|
||||||
|
xl: 'py-1.5 px-3',
|
||||||
|
}[props.size]
|
||||||
|
|
||||||
|
let variant = props.disabled ? 'disabled' : props.variant
|
||||||
|
let variantClasses = {
|
||||||
|
subtle:
|
||||||
|
'border border-gray-100 bg-gray-100 placeholder-gray-500 hover:border-gray-200 hover:bg-gray-200 focus:bg-white focus:border-gray-500 focus:shadow-sm focus:ring-0 focus-visible:ring-2 focus-visible:ring-gray-400',
|
||||||
|
outline:
|
||||||
|
'border border-gray-300 bg-white placeholder-gray-500 hover:border-gray-400 hover:shadow-sm focus:bg-white focus:border-gray-500 focus:shadow-sm focus:ring-0 focus-visible:ring-2 focus-visible:ring-gray-400',
|
||||||
|
disabled: [
|
||||||
|
'border bg-gray-50 placeholder-gray-400',
|
||||||
|
props.variant === 'outline' ? 'border-gray-300' : 'border-transparent',
|
||||||
|
],
|
||||||
|
}[variant]
|
||||||
|
|
||||||
|
return [
|
||||||
|
sizeClasses,
|
||||||
|
paddingClasses,
|
||||||
|
variantClasses,
|
||||||
|
textColor.value,
|
||||||
|
'transition-colors w-full',
|
||||||
|
]
|
||||||
|
})
|
||||||
|
|
||||||
|
defineExpose({ query })
|
||||||
|
</script>
|
||||||
204
frontend/src/components/Controls/CodeEditor.vue
Normal file
204
frontend/src/components/Controls/CodeEditor.vue
Normal file
@@ -0,0 +1,204 @@
|
|||||||
|
<template>
|
||||||
|
<div
|
||||||
|
class="editor flex flex-col gap-1"
|
||||||
|
:style="{
|
||||||
|
height: height,
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
<span class="text-xs" v-if="label">
|
||||||
|
{{ label }}
|
||||||
|
</span>
|
||||||
|
<div
|
||||||
|
ref="editor"
|
||||||
|
class="h-auto flex-1 overflow-hidden overscroll-none !rounded border border-outline-gray-2 bg-surface-gray-2 dark:bg-gray-900"
|
||||||
|
/>
|
||||||
|
<span
|
||||||
|
class="mt-1 text-xs text-gray-600"
|
||||||
|
v-show="description"
|
||||||
|
v-html="description"
|
||||||
|
></span>
|
||||||
|
<Button
|
||||||
|
v-if="showSaveButton"
|
||||||
|
@click="emit('save', aceEditor?.getValue())"
|
||||||
|
class="mt-3"
|
||||||
|
>
|
||||||
|
{{ __('Save') }}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { useDark } from '@vueuse/core'
|
||||||
|
import ace from 'ace-builds'
|
||||||
|
import 'ace-builds/src-min-noconflict/ext-searchbox'
|
||||||
|
import 'ace-builds/src-min-noconflict/theme-chrome'
|
||||||
|
import 'ace-builds/src-min-noconflict/theme-twilight'
|
||||||
|
import { PropType, onMounted, ref, watch } from 'vue'
|
||||||
|
import { Button } from 'frappe-ui'
|
||||||
|
|
||||||
|
const isDark = useDark({
|
||||||
|
attribute: 'data-theme',
|
||||||
|
})
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
modelValue: {
|
||||||
|
type: [Object, String, Array],
|
||||||
|
},
|
||||||
|
type: {
|
||||||
|
type: String as PropType<'JSON' | 'HTML' | 'Python' | 'JavaScript' | 'CSS'>,
|
||||||
|
default: 'JSON',
|
||||||
|
},
|
||||||
|
label: {
|
||||||
|
type: String,
|
||||||
|
default: '',
|
||||||
|
},
|
||||||
|
readonly: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
|
height: {
|
||||||
|
type: String,
|
||||||
|
default: '250px',
|
||||||
|
},
|
||||||
|
showLineNumbers: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
|
autofocus: {
|
||||||
|
type: Boolean,
|
||||||
|
default: true,
|
||||||
|
},
|
||||||
|
showSaveButton: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
|
description: {
|
||||||
|
type: String,
|
||||||
|
default: '',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const emit = defineEmits(['save', 'update:modelValue'])
|
||||||
|
const editor = ref<HTMLElement | null>(null)
|
||||||
|
let aceEditor = null as ace.Ace.Editor | null
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
setupEditor()
|
||||||
|
})
|
||||||
|
|
||||||
|
const setupEditor = () => {
|
||||||
|
aceEditor = ace.edit(editor.value as HTMLElement)
|
||||||
|
resetEditor(props.modelValue as string, true)
|
||||||
|
aceEditor.setReadOnly(props.readonly)
|
||||||
|
aceEditor.setOptions({
|
||||||
|
fontSize: '12px',
|
||||||
|
useWorker: false,
|
||||||
|
showGutter: props.showLineNumbers,
|
||||||
|
wrap: props.showLineNumbers,
|
||||||
|
})
|
||||||
|
if (props.type === 'CSS') {
|
||||||
|
import('ace-builds/src-noconflict/mode-css').then(() => {
|
||||||
|
aceEditor?.session.setMode('ace/mode/css')
|
||||||
|
})
|
||||||
|
} else if (props.type === 'JavaScript') {
|
||||||
|
import('ace-builds/src-noconflict/mode-javascript').then(() => {
|
||||||
|
aceEditor?.session.setMode('ace/mode/javascript')
|
||||||
|
})
|
||||||
|
} else if (props.type === 'Python') {
|
||||||
|
import('ace-builds/src-noconflict/mode-python').then(() => {
|
||||||
|
aceEditor?.session.setMode('ace/mode/python')
|
||||||
|
})
|
||||||
|
} else if (props.type === 'JSON') {
|
||||||
|
import('ace-builds/src-noconflict/mode-json').then(() => {
|
||||||
|
aceEditor?.session.setMode('ace/mode/json')
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
import('ace-builds/src-noconflict/mode-html').then(() => {
|
||||||
|
aceEditor?.session.setMode('ace/mode/html')
|
||||||
|
})
|
||||||
|
}
|
||||||
|
aceEditor.on('blur', () => {
|
||||||
|
try {
|
||||||
|
let value = aceEditor?.getValue() || ''
|
||||||
|
if (props.type === 'JSON') {
|
||||||
|
value = JSON.parse(value)
|
||||||
|
}
|
||||||
|
if (value === props.modelValue) return
|
||||||
|
if (!props.showSaveButton && !props.readonly) {
|
||||||
|
emit('update:modelValue', value)
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
// do nothing
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const getModelValue = () => {
|
||||||
|
let value = props.modelValue || ''
|
||||||
|
try {
|
||||||
|
if (props.type === 'JSON' || typeof value === 'object') {
|
||||||
|
value = JSON.stringify(value, null, 2)
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
// do nothing
|
||||||
|
}
|
||||||
|
return value as string
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetEditor(value: string, resetHistory = false) {
|
||||||
|
value = getModelValue()
|
||||||
|
aceEditor?.setValue(value)
|
||||||
|
aceEditor?.clearSelection()
|
||||||
|
aceEditor?.setTheme(isDark.value ? 'ace/theme/twilight' : 'ace/theme/chrome')
|
||||||
|
props.autofocus && aceEditor?.focus()
|
||||||
|
if (resetHistory) {
|
||||||
|
aceEditor?.session.getUndoManager().reset()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(isDark, () => {
|
||||||
|
aceEditor?.setTheme(isDark.value ? 'ace/theme/twilight' : 'ace/theme/chrome')
|
||||||
|
})
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.type,
|
||||||
|
() => {
|
||||||
|
setupEditor()
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.modelValue,
|
||||||
|
() => {
|
||||||
|
resetEditor(props.modelValue as string)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
defineExpose({ resetEditor })
|
||||||
|
</script>
|
||||||
|
<style scoped>
|
||||||
|
.editor .ace_editor {
|
||||||
|
height: 100%;
|
||||||
|
width: 100%;
|
||||||
|
border-radius: 5px;
|
||||||
|
overscroll-behavior: none;
|
||||||
|
}
|
||||||
|
.editor :deep(.ace_scrollbar-h) {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
.editor :deep(.ace_search) {
|
||||||
|
@apply dark:bg-gray-800 dark:text-gray-200;
|
||||||
|
@apply dark:border-gray-800;
|
||||||
|
}
|
||||||
|
.editor :deep(.ace_searchbtn) {
|
||||||
|
@apply dark:bg-gray-800 dark:text-gray-200;
|
||||||
|
@apply dark:border-gray-800;
|
||||||
|
}
|
||||||
|
.editor :deep(.ace_button) {
|
||||||
|
@apply dark:bg-gray-800 dark:text-gray-200;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor :deep(.ace_search_field) {
|
||||||
|
@apply dark:bg-gray-900 dark:text-gray-200;
|
||||||
|
@apply dark:border-gray-800;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
114
frontend/src/components/Controls/IconPicker.vue
Normal file
114
frontend/src/components/Controls/IconPicker.vue
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
<template>
|
||||||
|
<div class="space-y-1.5">
|
||||||
|
<label class="block text-xs text-gray-600">
|
||||||
|
{{ label }}
|
||||||
|
</label>
|
||||||
|
<div class="w-full">
|
||||||
|
<Popover>
|
||||||
|
<template #target="{ togglePopover }">
|
||||||
|
<button
|
||||||
|
@click="openPopover(togglePopover)"
|
||||||
|
class="flex w-full items-center space-x-2 focus:outline-none bg-gray-100 rounded h-7 py-1.5 px-2 hover:bg-gray-200 focus:bg-white border border-gray-100 hover:border-gray-200 focus:border-gray-500"
|
||||||
|
>
|
||||||
|
<component
|
||||||
|
v-if="selectedIcon"
|
||||||
|
class="w-4 h-4 text-gray-700 stroke-1.5"
|
||||||
|
:is="icons[selectedIcon]"
|
||||||
|
/>
|
||||||
|
<component
|
||||||
|
v-else
|
||||||
|
class="w-4 h-4 text-gray-700 stroke-1.5"
|
||||||
|
:is="icons.Folder"
|
||||||
|
/>
|
||||||
|
<span v-if="selectedIcon">
|
||||||
|
{{ selectedIcon }}
|
||||||
|
</span>
|
||||||
|
<span v-else class="text-gray-600">
|
||||||
|
{{ __('Choose an icon') }}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</template>
|
||||||
|
<template #body-main="{ close, isOpen }" class="w-full">
|
||||||
|
<div class="p-3 max-h-56 overflow-auto w-full">
|
||||||
|
<FormControl
|
||||||
|
ref="search"
|
||||||
|
v-model="iconQuery"
|
||||||
|
:placeholder="__('Search for an icon')"
|
||||||
|
autocomplete="off"
|
||||||
|
/>
|
||||||
|
<div class="grid grid-cols-10 gap-4 mt-4">
|
||||||
|
<div v-for="(iconComponent, iconName) in filteredIcons">
|
||||||
|
<component
|
||||||
|
:is="iconComponent"
|
||||||
|
class="h-4 w-4 stroke-1.5 text-gray-700 cursor-pointer"
|
||||||
|
@click="setIcon(iconName, close)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</Popover>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<script setup>
|
||||||
|
import { FormControl, Popover } from 'frappe-ui'
|
||||||
|
import * as icons from 'lucide-vue-next'
|
||||||
|
import { ref, computed, onMounted, nextTick } from 'vue'
|
||||||
|
|
||||||
|
const iconQuery = ref('')
|
||||||
|
const selectedIcon = ref('')
|
||||||
|
const search = ref(null)
|
||||||
|
const emit = defineEmits(['update:modelValue', 'change'])
|
||||||
|
|
||||||
|
const iconArray = ref(
|
||||||
|
Object.keys(icons)
|
||||||
|
.sort(() => 0.5 - Math.random())
|
||||||
|
.slice(0, 100)
|
||||||
|
.reduce((result, key) => {
|
||||||
|
result[key] = icons[key]
|
||||||
|
return result
|
||||||
|
}, {})
|
||||||
|
)
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
label: {
|
||||||
|
type: String,
|
||||||
|
default: 'Icon',
|
||||||
|
},
|
||||||
|
modelValue: {
|
||||||
|
type: String,
|
||||||
|
default: '',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
selectedIcon.value = props.modelValue
|
||||||
|
})
|
||||||
|
|
||||||
|
const setIcon = (icon, close) => {
|
||||||
|
emit('update:modelValue', icon)
|
||||||
|
selectedIcon.value = icon
|
||||||
|
iconQuery.value = ''
|
||||||
|
close()
|
||||||
|
}
|
||||||
|
|
||||||
|
const filteredIcons = computed(() => {
|
||||||
|
if (!iconQuery.value) {
|
||||||
|
return iconArray.value
|
||||||
|
}
|
||||||
|
|
||||||
|
return Object.keys(icons)
|
||||||
|
.filter((icon) =>
|
||||||
|
icon.toLowerCase().includes(iconQuery.value.toLowerCase())
|
||||||
|
)
|
||||||
|
.reduce((result, key) => {
|
||||||
|
result[key] = icons[key]
|
||||||
|
return result
|
||||||
|
}, {})
|
||||||
|
})
|
||||||
|
|
||||||
|
const openPopover = (togglePopover) => {
|
||||||
|
togglePopover()
|
||||||
|
}
|
||||||
|
</script>
|
||||||
154
frontend/src/components/Controls/Link.vue
Normal file
154
frontend/src/components/Controls/Link.vue
Normal file
@@ -0,0 +1,154 @@
|
|||||||
|
<template>
|
||||||
|
<div class="space-y-1.5">
|
||||||
|
<label class="block" :class="labelClasses" v-if="attrs.label">
|
||||||
|
{{ attrs.label }}
|
||||||
|
<span class="text-red-500" v-if="attrs.required">*</span>
|
||||||
|
</label>
|
||||||
|
<Autocomplete
|
||||||
|
ref="autocomplete"
|
||||||
|
:options="options.data"
|
||||||
|
v-model="value"
|
||||||
|
:size="attrs.size || 'sm'"
|
||||||
|
:variant="attrs.variant"
|
||||||
|
:placeholder="attrs.placeholder"
|
||||||
|
:filterable="false"
|
||||||
|
>
|
||||||
|
<template #target="{ open, togglePopover }">
|
||||||
|
<slot name="target" v-bind="{ open, togglePopover }" />
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template #prefix>
|
||||||
|
<slot name="prefix" />
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template #item-prefix="{ active, selected, option }">
|
||||||
|
<slot name="item-prefix" v-bind="{ active, selected, option }" />
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template #item-label="{ active, selected, option }">
|
||||||
|
<slot name="item-label" v-bind="{ active, selected, option }" />
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template v-if="attrs.onCreate" #footer="{ value, close }">
|
||||||
|
<div>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
class="w-full !justify-start"
|
||||||
|
label="Create New"
|
||||||
|
@click="attrs.onCreate(value, close)"
|
||||||
|
>
|
||||||
|
<template #prefix>
|
||||||
|
<Plus class="h-4 w-4 stroke-1.5" />
|
||||||
|
</template>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</Autocomplete>
|
||||||
|
<p v-if="description" class="text-sm text-gray-600">{{ description }}</p>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import Autocomplete from '@/components/Controls/Autocomplete.vue'
|
||||||
|
import { watchDebounced } from '@vueuse/core'
|
||||||
|
import { createResource, Button } from 'frappe-ui'
|
||||||
|
import { Plus } from 'lucide-vue-next'
|
||||||
|
import { useAttrs, computed, ref } from 'vue'
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
doctype: {
|
||||||
|
type: String,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
filters: {
|
||||||
|
type: Object,
|
||||||
|
default: () => ({}),
|
||||||
|
},
|
||||||
|
modelValue: {
|
||||||
|
type: String,
|
||||||
|
default: '',
|
||||||
|
},
|
||||||
|
description: {
|
||||||
|
type: String,
|
||||||
|
default: '',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const emit = defineEmits(['update:modelValue', 'change'])
|
||||||
|
|
||||||
|
const attrs = useAttrs()
|
||||||
|
|
||||||
|
const valuePropPassed = computed(() => 'value' in attrs)
|
||||||
|
|
||||||
|
const value = computed({
|
||||||
|
get: () => (valuePropPassed.value ? attrs.value : props.modelValue),
|
||||||
|
set: (val) => {
|
||||||
|
return (
|
||||||
|
val?.value &&
|
||||||
|
emit(valuePropPassed.value ? 'change' : 'update:modelValue', val?.value)
|
||||||
|
)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const autocomplete = ref(null)
|
||||||
|
const text = ref('')
|
||||||
|
|
||||||
|
watchDebounced(
|
||||||
|
() => autocomplete.value?.query,
|
||||||
|
(val) => {
|
||||||
|
val = val || ''
|
||||||
|
if (text.value === val) return
|
||||||
|
text.value = val
|
||||||
|
reload(val)
|
||||||
|
},
|
||||||
|
{ debounce: 300, immediate: true }
|
||||||
|
)
|
||||||
|
|
||||||
|
watchDebounced(
|
||||||
|
() => props.doctype,
|
||||||
|
() => reload(''),
|
||||||
|
{ debounce: 300, immediate: true }
|
||||||
|
)
|
||||||
|
|
||||||
|
const options = createResource({
|
||||||
|
url: 'frappe.desk.search.search_link',
|
||||||
|
cache: [props.doctype, text.value],
|
||||||
|
method: 'POST',
|
||||||
|
auto: true,
|
||||||
|
params: {
|
||||||
|
txt: text.value,
|
||||||
|
doctype: props.doctype,
|
||||||
|
filters: props.filters,
|
||||||
|
},
|
||||||
|
transform: (data) => {
|
||||||
|
return data.map((option) => {
|
||||||
|
return {
|
||||||
|
label: option.label || option.value,
|
||||||
|
value: option.value,
|
||||||
|
description: option.description,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
function reload(val) {
|
||||||
|
options.update({
|
||||||
|
params: {
|
||||||
|
txt: val,
|
||||||
|
doctype: props.doctype,
|
||||||
|
filters: props.filters,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
options.reload()
|
||||||
|
}
|
||||||
|
|
||||||
|
const labelClasses = computed(() => {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
sm: 'text-xs',
|
||||||
|
md: 'text-base',
|
||||||
|
}[attrs.size || 'sm'],
|
||||||
|
'text-gray-600',
|
||||||
|
]
|
||||||
|
})
|
||||||
|
</script>
|
||||||
246
frontend/src/components/Controls/MultiSelect.vue
Normal file
246
frontend/src/components/Controls/MultiSelect.vue
Normal file
@@ -0,0 +1,246 @@
|
|||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<label class="block mb-1" :class="labelClasses" v-if="label">
|
||||||
|
{{ label }}
|
||||||
|
<span class="text-red-500" v-if="required">*</span>
|
||||||
|
</label>
|
||||||
|
<div class="grid grid-cols-3 gap-1">
|
||||||
|
<Button
|
||||||
|
ref="emails"
|
||||||
|
v-for="value in values"
|
||||||
|
:key="value"
|
||||||
|
:label="value"
|
||||||
|
theme="gray"
|
||||||
|
variant="subtle"
|
||||||
|
class="rounded-md"
|
||||||
|
@keydown.delete.capture.stop="removeLastValue"
|
||||||
|
>
|
||||||
|
<template #suffix>
|
||||||
|
<X @click="removeValue(value)" class="h-4 w-4 stroke-1.5" />
|
||||||
|
</template>
|
||||||
|
</Button>
|
||||||
|
<div class="">
|
||||||
|
<Combobox v-model="selectedValue" nullable>
|
||||||
|
<Popover class="w-full" v-model:show="showOptions">
|
||||||
|
<template #target="{ togglePopover }">
|
||||||
|
<ComboboxInput
|
||||||
|
ref="search"
|
||||||
|
class="search-input form-input w-full focus-visible:!ring-0"
|
||||||
|
type="text"
|
||||||
|
:value="query"
|
||||||
|
@change="
|
||||||
|
(e) => {
|
||||||
|
query = e.target.value
|
||||||
|
showOptions = true
|
||||||
|
}
|
||||||
|
"
|
||||||
|
autocomplete="off"
|
||||||
|
@focus="() => togglePopover()"
|
||||||
|
@keydown.delete.capture.stop="removeLastValue"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
<template #body="{ isOpen }">
|
||||||
|
<div v-show="isOpen">
|
||||||
|
<div class="mt-1 rounded-lg bg-white py-1 text-base shadow-2xl">
|
||||||
|
<ComboboxOptions
|
||||||
|
class="my-1 max-h-[12rem] overflow-y-auto px-1.5"
|
||||||
|
static
|
||||||
|
>
|
||||||
|
<ComboboxOption
|
||||||
|
v-for="option in options"
|
||||||
|
:key="option.value"
|
||||||
|
:value="option"
|
||||||
|
v-slot="{ active }"
|
||||||
|
>
|
||||||
|
<li
|
||||||
|
:class="[
|
||||||
|
'flex cursor-pointer items-center rounded px-2 py-1 text-base',
|
||||||
|
{ 'bg-gray-100': active },
|
||||||
|
]"
|
||||||
|
>
|
||||||
|
<div class="flex flex-col gap-1 p-1">
|
||||||
|
<div class="text-base font-medium">
|
||||||
|
{{ option.description }}
|
||||||
|
</div>
|
||||||
|
<div class="text-sm text-gray-600">
|
||||||
|
{{ option.value }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
</ComboboxOption>
|
||||||
|
</ComboboxOptions>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</Popover>
|
||||||
|
</Combobox>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- <ErrorMessage class="mt-2 pl-2" v-if="error" :message="error" /> -->
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import {
|
||||||
|
Combobox,
|
||||||
|
ComboboxInput,
|
||||||
|
ComboboxOptions,
|
||||||
|
ComboboxOption,
|
||||||
|
} from '@headlessui/vue'
|
||||||
|
import { createResource, Popover, Button } from 'frappe-ui'
|
||||||
|
import { ref, computed, nextTick } from 'vue'
|
||||||
|
import { watchDebounced } from '@vueuse/core'
|
||||||
|
import { X } from 'lucide-vue-next'
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
label: {
|
||||||
|
type: String,
|
||||||
|
},
|
||||||
|
size: {
|
||||||
|
type: String,
|
||||||
|
default: 'sm',
|
||||||
|
},
|
||||||
|
doctype: {
|
||||||
|
type: String,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
filters: {
|
||||||
|
type: Object,
|
||||||
|
default: () => ({}),
|
||||||
|
},
|
||||||
|
validate: {
|
||||||
|
type: Function,
|
||||||
|
default: null,
|
||||||
|
},
|
||||||
|
errorMessage: {
|
||||||
|
type: Function,
|
||||||
|
default: (value) => `${value} is an Invalid value`,
|
||||||
|
},
|
||||||
|
required: {
|
||||||
|
type: Boolean,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const values = defineModel()
|
||||||
|
|
||||||
|
const emails = ref([])
|
||||||
|
const search = ref(null)
|
||||||
|
const error = ref(null)
|
||||||
|
const query = ref('')
|
||||||
|
const text = ref('')
|
||||||
|
const showOptions = ref(false)
|
||||||
|
|
||||||
|
const selectedValue = computed({
|
||||||
|
get: () => query.value || '',
|
||||||
|
set: (val) => {
|
||||||
|
query.value = ''
|
||||||
|
if (val) {
|
||||||
|
showOptions.value = false
|
||||||
|
}
|
||||||
|
val?.value && addValue(val.value)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
watchDebounced(
|
||||||
|
query,
|
||||||
|
(val) => {
|
||||||
|
val = val || ''
|
||||||
|
if (text.value === val) return
|
||||||
|
text.value = val
|
||||||
|
reload(val)
|
||||||
|
},
|
||||||
|
{ debounce: 300, immediate: true }
|
||||||
|
)
|
||||||
|
|
||||||
|
const filterOptions = createResource({
|
||||||
|
url: 'frappe.desk.search.search_link',
|
||||||
|
method: 'POST',
|
||||||
|
cache: [text.value, props.doctype],
|
||||||
|
auto: true,
|
||||||
|
params: {
|
||||||
|
txt: text.value,
|
||||||
|
doctype: props.doctype,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const options = computed(() => {
|
||||||
|
return filterOptions.data || []
|
||||||
|
})
|
||||||
|
|
||||||
|
function reload(val) {
|
||||||
|
filterOptions.update({
|
||||||
|
params: {
|
||||||
|
txt: val,
|
||||||
|
doctype: props.doctype,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
filterOptions.reload()
|
||||||
|
}
|
||||||
|
|
||||||
|
const addValue = (value) => {
|
||||||
|
error.value = null
|
||||||
|
if (value) {
|
||||||
|
const splitValues = value.split(',')
|
||||||
|
splitValues.forEach((value) => {
|
||||||
|
value = value.trim()
|
||||||
|
if (value) {
|
||||||
|
// check if value is not already in the values array
|
||||||
|
if (!values.value?.includes(value)) {
|
||||||
|
// check if value is valid
|
||||||
|
if (value && props.validate && !props.validate(value)) {
|
||||||
|
error.value = props.errorMessage(value)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// add value to values array
|
||||||
|
if (!values.value) {
|
||||||
|
values.value = [value]
|
||||||
|
} else {
|
||||||
|
values.value.push(value)
|
||||||
|
}
|
||||||
|
value = value.replace(value, '')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
!error.value && (value = '')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const removeValue = (value) => {
|
||||||
|
values.value = values.value.filter((v) => v !== value)
|
||||||
|
}
|
||||||
|
|
||||||
|
const removeLastValue = () => {
|
||||||
|
if (query.value) return
|
||||||
|
|
||||||
|
let emailRef = emails.value[emails.value.length - 1]?.$el
|
||||||
|
if (document.activeElement === emailRef) {
|
||||||
|
values.value.pop()
|
||||||
|
nextTick(() => {
|
||||||
|
if (values.value.length) {
|
||||||
|
emailRef = emails.value[emails.value.length - 1].$el
|
||||||
|
emailRef?.focus()
|
||||||
|
} else {
|
||||||
|
setFocus()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
emailRef?.focus()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function setFocus() {
|
||||||
|
search.value.$el.focus()
|
||||||
|
}
|
||||||
|
|
||||||
|
defineExpose({ setFocus })
|
||||||
|
|
||||||
|
const labelClasses = computed(() => {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
sm: 'text-xs',
|
||||||
|
md: 'text-base',
|
||||||
|
}[props.size || 'sm'],
|
||||||
|
'text-gray-600',
|
||||||
|
]
|
||||||
|
})
|
||||||
|
</script>
|
||||||
81
frontend/src/components/Controls/Rating.vue
Normal file
81
frontend/src/components/Controls/Rating.vue
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
<template>
|
||||||
|
<div class="space-y-1">
|
||||||
|
<label class="block text-xs text-gray-600" v-if="props.label">
|
||||||
|
{{ props.label }}
|
||||||
|
</label>
|
||||||
|
<div class="flex text-center">
|
||||||
|
<div
|
||||||
|
v-for="index in 5"
|
||||||
|
@mouseover="hoveredRating = index"
|
||||||
|
@mouseleave="hoveredRating = 0"
|
||||||
|
>
|
||||||
|
<Star
|
||||||
|
class="fill-gray-400 text-gray-50 stroke-1 mr-1 cursor-pointer"
|
||||||
|
:class="iconClasses(index)"
|
||||||
|
@click="markRating(index)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { Star } from 'lucide-vue-next'
|
||||||
|
import { ref, watch } from 'vue'
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
id: {
|
||||||
|
type: String,
|
||||||
|
default: '',
|
||||||
|
},
|
||||||
|
modelValue: {
|
||||||
|
type: Number,
|
||||||
|
default: 0,
|
||||||
|
},
|
||||||
|
label: {
|
||||||
|
type: String,
|
||||||
|
default: '',
|
||||||
|
},
|
||||||
|
size: {
|
||||||
|
type: String,
|
||||||
|
default: 'md',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const iconClasses = (index) => {
|
||||||
|
let classes = [
|
||||||
|
{
|
||||||
|
sm: 'size-4',
|
||||||
|
md: 'size-5',
|
||||||
|
lg: 'size-6',
|
||||||
|
xl: 'size-7',
|
||||||
|
}[props.size],
|
||||||
|
]
|
||||||
|
if (index <= hoveredRating.value && index > rating.value) {
|
||||||
|
classes.push('fill-yellow-200')
|
||||||
|
} else if (index <= rating.value) {
|
||||||
|
classes.push('fill-yellow-500')
|
||||||
|
}
|
||||||
|
return classes.join(' ')
|
||||||
|
}
|
||||||
|
|
||||||
|
const emit = defineEmits(['update:modelValue'])
|
||||||
|
const rating = ref(props.modelValue)
|
||||||
|
const hoveredRating = ref(0)
|
||||||
|
|
||||||
|
let emitChange = (value) => {
|
||||||
|
emit('update:modelValue', value)
|
||||||
|
}
|
||||||
|
|
||||||
|
function markRating(index) {
|
||||||
|
emitChange(index)
|
||||||
|
rating.value = index
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.modelValue,
|
||||||
|
(newVal) => {
|
||||||
|
rating.value = newVal
|
||||||
|
}
|
||||||
|
)
|
||||||
|
</script>
|
||||||
186
frontend/src/components/CourseCard.vue
Normal file
186
frontend/src/components/CourseCard.vue
Normal file
@@ -0,0 +1,186 @@
|
|||||||
|
<template>
|
||||||
|
<div
|
||||||
|
v-if="course.title"
|
||||||
|
class="flex flex-col h-full rounded-md shadow-md text-base overflow-auto"
|
||||||
|
style="min-height: 350px"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="course-image"
|
||||||
|
:class="{ 'default-image': !course.image }"
|
||||||
|
:style="{ backgroundImage: 'url(\'' + encodeURI(course.image) + '\')' }"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="flex items-center flex-wrap space-x-1 relative top-4 px-2 w-fit"
|
||||||
|
>
|
||||||
|
<Badge v-if="course.featured" variant="subtle" theme="green" size="md">
|
||||||
|
{{ __('Featured') }}
|
||||||
|
</Badge>
|
||||||
|
<Badge
|
||||||
|
variant="subtle"
|
||||||
|
theme="gray"
|
||||||
|
size="md"
|
||||||
|
v-for="tag in course.tags"
|
||||||
|
>
|
||||||
|
{{ tag }}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
<div v-if="!course.image" class="image-placeholder">
|
||||||
|
{{ course.title[0] }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-col flex-auto p-4">
|
||||||
|
<div class="flex items-center justify-between mb-2">
|
||||||
|
<div v-if="course.lessons">
|
||||||
|
<Tooltip :text="__('Lessons')">
|
||||||
|
<span class="flex items-center">
|
||||||
|
<BookOpen class="h-4 w-4 stroke-1.5 text-gray-700 mr-1" />
|
||||||
|
{{ course.lessons }}
|
||||||
|
</span>
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="course.enrollments">
|
||||||
|
<Tooltip :text="__('Enrolled Students')">
|
||||||
|
<span class="flex items-center">
|
||||||
|
<Users class="h-4 w-4 stroke-1.5 text-gray-700 mr-1" />
|
||||||
|
{{ course.enrollments }}
|
||||||
|
</span>
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="course.rating">
|
||||||
|
<Tooltip :text="__('Average Rating')">
|
||||||
|
<span class="flex items-center">
|
||||||
|
<Star class="h-4 w-4 stroke-1.5 text-gray-700 mr-1" />
|
||||||
|
{{ course.rating }}
|
||||||
|
</span>
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="course.status != 'Approved'">
|
||||||
|
<Badge
|
||||||
|
variant="solid"
|
||||||
|
:theme="course.status === 'Under Review' ? 'orange' : 'blue'"
|
||||||
|
size="sm"
|
||||||
|
>
|
||||||
|
{{ course.status }}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="text-xl font-semibold leading-6">
|
||||||
|
{{ course.title }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="short-introduction text-gray-700 text-sm">
|
||||||
|
{{ course.short_introduction }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ProgressBar
|
||||||
|
v-if="user && course.membership"
|
||||||
|
:progress="course.membership.progress"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div v-if="user && course.membership" class="text-sm mb-4">
|
||||||
|
{{ Math.ceil(course.membership.progress) }}% completed
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-center justify-between mt-auto">
|
||||||
|
<div class="flex avatar-group overlap">
|
||||||
|
<div
|
||||||
|
class="h-6 mr-1"
|
||||||
|
:class="{ 'avatar-group overlap': course.instructors.length > 1 }"
|
||||||
|
>
|
||||||
|
<UserAvatar
|
||||||
|
v-for="instructor in course.instructors"
|
||||||
|
:user="instructor"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<CourseInstructors :instructors="course.instructors" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="font-semibold">
|
||||||
|
{{ course.price }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<script setup>
|
||||||
|
import { BookOpen, Users, Star } from 'lucide-vue-next'
|
||||||
|
import UserAvatar from '@/components/UserAvatar.vue'
|
||||||
|
import { sessionStore } from '@/stores/session'
|
||||||
|
import { Badge, Tooltip } from 'frappe-ui'
|
||||||
|
import CourseInstructors from '@/components/CourseInstructors.vue'
|
||||||
|
import ProgressBar from '@/components/ProgressBar.vue'
|
||||||
|
|
||||||
|
const { user } = sessionStore()
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
course: {
|
||||||
|
type: Object,
|
||||||
|
default: null,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
<style>
|
||||||
|
.course-image {
|
||||||
|
height: 168px;
|
||||||
|
width: 100%;
|
||||||
|
background-size: cover;
|
||||||
|
background-position: center;
|
||||||
|
background-repeat: no-repeat;
|
||||||
|
}
|
||||||
|
|
||||||
|
.course-card-pills {
|
||||||
|
background: #ffffff;
|
||||||
|
margin-left: 0;
|
||||||
|
margin-right: 0.5rem;
|
||||||
|
padding: 3.5px 8px;
|
||||||
|
font-size: 11px;
|
||||||
|
text-align: center;
|
||||||
|
letter-spacing: 0.011em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
font-weight: 600;
|
||||||
|
width: fit-content;
|
||||||
|
}
|
||||||
|
|
||||||
|
.default-image {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
background-color: theme('colors.green.100');
|
||||||
|
color: theme('colors.green.600');
|
||||||
|
}
|
||||||
|
|
||||||
|
.avatar-group {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.avatar-group .avatar {
|
||||||
|
transition: margin 0.1s ease-in-out;
|
||||||
|
}
|
||||||
|
.image-placeholder {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
flex: 1;
|
||||||
|
font-size: 5rem;
|
||||||
|
color: theme('colors.gray.700');
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.avatar-group.overlap .avatar + .avatar {
|
||||||
|
margin-left: calc(-8px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.short-introduction {
|
||||||
|
display: -webkit-box;
|
||||||
|
-webkit-line-clamp: 2;
|
||||||
|
-webkit-box-orient: vertical;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
width: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
margin: 0.25rem 0 1.25rem;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
222
frontend/src/components/CourseCardOverlay.vue
Normal file
222
frontend/src/components/CourseCardOverlay.vue
Normal file
@@ -0,0 +1,222 @@
|
|||||||
|
<template>
|
||||||
|
<div class="shadow rounded-md min-w-80">
|
||||||
|
<iframe
|
||||||
|
v-if="course.data.video_link"
|
||||||
|
:src="video_link"
|
||||||
|
class="rounded-t-md min-h-56 w-full"
|
||||||
|
/>
|
||||||
|
<div class="p-5">
|
||||||
|
<div v-if="course.data.price" class="text-2xl font-semibold mb-3">
|
||||||
|
{{ course.data.price }}
|
||||||
|
</div>
|
||||||
|
<router-link
|
||||||
|
v-if="course.data.membership"
|
||||||
|
:to="{
|
||||||
|
name: 'Lesson',
|
||||||
|
params: {
|
||||||
|
courseName: course.name,
|
||||||
|
chapterNumber: course.data.current_lesson
|
||||||
|
? course.data.current_lesson.split('-')[0]
|
||||||
|
: 1,
|
||||||
|
lessonNumber: course.data.current_lesson
|
||||||
|
? course.data.current_lesson.split('-')[1]
|
||||||
|
: 1,
|
||||||
|
},
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
<Button variant="solid" size="md" class="w-full">
|
||||||
|
<span>
|
||||||
|
{{ __('Continue Learning') }}
|
||||||
|
</span>
|
||||||
|
</Button>
|
||||||
|
</router-link>
|
||||||
|
<router-link
|
||||||
|
v-else-if="course.data.paid_course"
|
||||||
|
:to="{
|
||||||
|
name: 'Billing',
|
||||||
|
params: {
|
||||||
|
type: 'course',
|
||||||
|
name: course.data.name,
|
||||||
|
},
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
<Button variant="solid" size="md" class="w-full">
|
||||||
|
<span>
|
||||||
|
{{ __('Buy this course') }}
|
||||||
|
</span>
|
||||||
|
</Button>
|
||||||
|
</router-link>
|
||||||
|
<div
|
||||||
|
v-else-if="course.data.disable_self_learning"
|
||||||
|
class="bg-blue-100 text-blue-900 text-sm rounded-md py-1 px-3"
|
||||||
|
>
|
||||||
|
{{ __('Contact the Administrator to enroll for this course.') }}
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
v-else
|
||||||
|
@click="enrollStudent()"
|
||||||
|
variant="solid"
|
||||||
|
class="w-full"
|
||||||
|
size="md"
|
||||||
|
>
|
||||||
|
<span>
|
||||||
|
{{ __('Start Learning') }}
|
||||||
|
</span>
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
v-if="canGetCertificate"
|
||||||
|
@click="fetchCertificate()"
|
||||||
|
variant="subtle"
|
||||||
|
class="w-full mt-2"
|
||||||
|
size="md"
|
||||||
|
>
|
||||||
|
{{ __('Get Certificate') }}
|
||||||
|
</Button>
|
||||||
|
<router-link
|
||||||
|
v-if="user?.data?.is_moderator || is_instructor()"
|
||||||
|
:to="{
|
||||||
|
name: 'CourseForm',
|
||||||
|
params: {
|
||||||
|
courseName: course.data.name,
|
||||||
|
},
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
<Button variant="subtle" class="w-full mt-2" size="md">
|
||||||
|
<span>
|
||||||
|
{{ __('Edit') }}
|
||||||
|
</span>
|
||||||
|
</Button>
|
||||||
|
</router-link>
|
||||||
|
<div class="mt-8 mb-4 font-medium">
|
||||||
|
{{ __('This course has:') }}
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center mb-3">
|
||||||
|
<BookOpen class="h-5 w-5 stroke-1.5 text-gray-600" />
|
||||||
|
<span class="ml-2">
|
||||||
|
{{ course.data.lessons }} {{ __('Lessons') }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center mb-3">
|
||||||
|
<Users class="h-5 w-5 stroke-1.5 text-gray-600" />
|
||||||
|
<span class="ml-2">
|
||||||
|
{{ formatAmount(course.data.enrollments) }}
|
||||||
|
{{ __('Enrolled Students') }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center">
|
||||||
|
<Star class="h-5 w-5 stroke-1.5 fill-orange-500 text-gray-50" />
|
||||||
|
<span class="ml-2"> {{ course.data.rating }} {{ __('Rating') }} </span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<script setup>
|
||||||
|
import { BookOpen, Users, Star } from 'lucide-vue-next'
|
||||||
|
import { computed, inject } from 'vue'
|
||||||
|
import { Button, createResource } from 'frappe-ui'
|
||||||
|
import { showToast, formatAmount } from '@/utils/'
|
||||||
|
import { capture } from '@/telemetry'
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
|
|
||||||
|
const router = useRouter()
|
||||||
|
const user = inject('$user')
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
course: {
|
||||||
|
type: Object,
|
||||||
|
default: null,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const video_link = computed(() => {
|
||||||
|
if (props.course.data.video_link) {
|
||||||
|
return 'https://www.youtube.com/embed/' + props.course.data.video_link
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
})
|
||||||
|
|
||||||
|
function enrollStudent() {
|
||||||
|
if (!user.data) {
|
||||||
|
showToast(
|
||||||
|
__('Please Login'),
|
||||||
|
__('You need to login first to enroll for this course'),
|
||||||
|
'alert-circle'
|
||||||
|
)
|
||||||
|
setTimeout(() => {
|
||||||
|
window.location.href = `/login?redirect-to=${window.location.pathname}`
|
||||||
|
}, 2000)
|
||||||
|
} else {
|
||||||
|
const enrollStudentResource = createResource({
|
||||||
|
url: 'lms.lms.doctype.lms_enrollment.lms_enrollment.create_membership',
|
||||||
|
})
|
||||||
|
enrollStudentResource
|
||||||
|
.submit({
|
||||||
|
course: props.course.data.name,
|
||||||
|
})
|
||||||
|
.then(() => {
|
||||||
|
capture('enrolled_in_course', {
|
||||||
|
course: props.course.data.name,
|
||||||
|
})
|
||||||
|
showToast(
|
||||||
|
__('Success'),
|
||||||
|
__('You have been enrolled in this course'),
|
||||||
|
'check'
|
||||||
|
)
|
||||||
|
setTimeout(() => {
|
||||||
|
router.push({
|
||||||
|
name: 'Lesson',
|
||||||
|
params: {
|
||||||
|
courseName: props.course.data.name,
|
||||||
|
chapterNumber: 1,
|
||||||
|
lessonNumber: 1,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}, 2000)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const is_instructor = () => {
|
||||||
|
let user_is_instructor = false
|
||||||
|
props.course.data.instructors.forEach((instructor) => {
|
||||||
|
if (!user_is_instructor && instructor.name == user.data?.name) {
|
||||||
|
user_is_instructor = true
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return user_is_instructor
|
||||||
|
}
|
||||||
|
|
||||||
|
const canGetCertificate = computed(() => {
|
||||||
|
if (
|
||||||
|
props.course.data?.enable_certification &&
|
||||||
|
props.course.data?.membership?.progress == 100
|
||||||
|
) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
})
|
||||||
|
|
||||||
|
const certificate = createResource({
|
||||||
|
url: 'lms.lms.doctype.lms_certificate.lms_certificate.create_certificate',
|
||||||
|
makeParams(values) {
|
||||||
|
return {
|
||||||
|
course: values.course,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onSuccess(data) {
|
||||||
|
window.open(
|
||||||
|
`/api/method/frappe.utils.print_format.download_pdf?doctype=LMS+Certificate&name=${
|
||||||
|
data.name
|
||||||
|
}&format=${encodeURIComponent(data.template)}`,
|
||||||
|
'_blank'
|
||||||
|
)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const fetchCertificate = () => {
|
||||||
|
certificate.submit({
|
||||||
|
course: props.course.data?.name,
|
||||||
|
member: user.data?.name,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
</script>
|
||||||
50
frontend/src/components/CourseInstructors.vue
Normal file
50
frontend/src/components/CourseInstructors.vue
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
<template>
|
||||||
|
<span v-if="instructors?.length == 1">
|
||||||
|
<router-link
|
||||||
|
:to="{
|
||||||
|
name: 'Profile',
|
||||||
|
params: { username: instructors[0].username },
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
{{ instructors[0].full_name }}
|
||||||
|
</router-link>
|
||||||
|
</span>
|
||||||
|
<span v-if="instructors?.length == 2">
|
||||||
|
<router-link
|
||||||
|
:to="{
|
||||||
|
name: 'Profile',
|
||||||
|
params: { username: instructors[0].username },
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
{{ instructors[0].first_name }}
|
||||||
|
</router-link>
|
||||||
|
and
|
||||||
|
<router-link
|
||||||
|
:to="{
|
||||||
|
name: 'Profile',
|
||||||
|
params: { username: instructors[1].username },
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
{{ instructors[1].first_name }}
|
||||||
|
</router-link>
|
||||||
|
</span>
|
||||||
|
<span v-if="instructors?.length > 2">
|
||||||
|
<router-link
|
||||||
|
:to="{
|
||||||
|
name: 'Profile',
|
||||||
|
params: { username: instructors[0].username },
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
{{ instructors[0].first_name }}
|
||||||
|
</router-link>
|
||||||
|
and {{ instructors?.length - 1 }} others
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
<script setup>
|
||||||
|
const props = defineProps({
|
||||||
|
instructors: {
|
||||||
|
type: Array,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
</script>
|
||||||
331
frontend/src/components/CourseOutline.vue
Normal file
331
frontend/src/components/CourseOutline.vue
Normal file
@@ -0,0 +1,331 @@
|
|||||||
|
<template>
|
||||||
|
<div class="text-base">
|
||||||
|
<div
|
||||||
|
v-if="title && (outline.data?.length || allowEdit)"
|
||||||
|
class="grid grid-cols-[70%,30%] mb-4 px-2"
|
||||||
|
>
|
||||||
|
<div class="font-semibold text-lg leading-5">
|
||||||
|
{{ __(title) }}
|
||||||
|
</div>
|
||||||
|
<Button size="sm" v-if="allowEdit" @click="openChapterModal()">
|
||||||
|
{{ __('Add Chapter') }}
|
||||||
|
</Button>
|
||||||
|
<!-- <span class="font-medium cursor-pointer" @click="expandAllChapters()">
|
||||||
|
{{ expandAll ? __("Collapse all chapters") : __("Expand all chapters") }}
|
||||||
|
</span> -->
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
:class="{
|
||||||
|
'shadow rounded-md py-2 px-2': showOutline && outline.data?.length,
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
<Disclosure
|
||||||
|
v-slot="{ open }"
|
||||||
|
v-for="(chapter, index) in outline.data"
|
||||||
|
:key="chapter.name"
|
||||||
|
:defaultOpen="openChapterDetail(chapter.idx)"
|
||||||
|
>
|
||||||
|
<DisclosureButton ref="" class="flex items-center w-full p-2 group">
|
||||||
|
<ChevronRight
|
||||||
|
:class="{
|
||||||
|
'rotate-90 transform duration-200': open,
|
||||||
|
'duration-200': !open,
|
||||||
|
hidden: chapter.is_scorm_package,
|
||||||
|
open: index == 1,
|
||||||
|
}"
|
||||||
|
class="h-4 w-4 text-gray-900 stroke-1"
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
class="text-base text-left font-medium leading-5 ml-2"
|
||||||
|
@click="redirectToChapter(chapter)"
|
||||||
|
>
|
||||||
|
{{ chapter.title }}
|
||||||
|
</div>
|
||||||
|
<div class="flex ml-auto space-x-4">
|
||||||
|
<Tooltip :text="__('Edit Chapter')" placement="bottom">
|
||||||
|
<FilePenLine
|
||||||
|
v-if="allowEdit"
|
||||||
|
@click.prevent="openChapterModal(chapter)"
|
||||||
|
class="h-4 w-4 text-gray-900 invisible group-hover:visible"
|
||||||
|
/>
|
||||||
|
</Tooltip>
|
||||||
|
<Tooltip :text="__('Delete Chapter')" placement="bottom">
|
||||||
|
<Trash2
|
||||||
|
v-if="allowEdit"
|
||||||
|
@click.prevent="trashChapter(chapter.name)"
|
||||||
|
class="h-4 w-4 text-red-500 invisible group-hover:visible"
|
||||||
|
/>
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
|
</DisclosureButton>
|
||||||
|
<DisclosurePanel v-if="!chapter.is_scorm_package">
|
||||||
|
<Draggable
|
||||||
|
v-if="!chapter.is_scorm_package"
|
||||||
|
:list="chapter.lessons"
|
||||||
|
:disabled="!allowEdit"
|
||||||
|
item-key="name"
|
||||||
|
group="items"
|
||||||
|
@end="updateOutline"
|
||||||
|
:data-chapter="chapter.name"
|
||||||
|
>
|
||||||
|
<template #item="{ element: lesson }">
|
||||||
|
<div class="outline-lesson pl-8 py-2 pr-4">
|
||||||
|
<router-link
|
||||||
|
:to="{
|
||||||
|
name: allowEdit ? 'LessonForm' : 'Lesson',
|
||||||
|
params: {
|
||||||
|
courseName: courseName,
|
||||||
|
chapterNumber: lesson.number.split('.')[0],
|
||||||
|
lessonNumber: lesson.number.split('.')[1],
|
||||||
|
},
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
<div class="flex items-center text-sm leading-5 group">
|
||||||
|
<MonitorPlay
|
||||||
|
v-if="lesson.icon === 'icon-youtube'"
|
||||||
|
class="h-4 w-4 text-gray-900 stroke-1 mr-2"
|
||||||
|
/>
|
||||||
|
<HelpCircle
|
||||||
|
v-else-if="lesson.icon === 'icon-quiz'"
|
||||||
|
class="h-4 w-4 text-gray-900 stroke-1 mr-2"
|
||||||
|
/>
|
||||||
|
<FileText
|
||||||
|
v-else-if="lesson.icon === 'icon-list'"
|
||||||
|
class="h-4 w-4 text-gray-900 stroke-1 mr-2"
|
||||||
|
/>
|
||||||
|
{{ lesson.title }}
|
||||||
|
<Trash2
|
||||||
|
v-if="allowEdit"
|
||||||
|
@click.prevent="trashLesson(lesson.name, chapter.name)"
|
||||||
|
class="h-4 w-4 text-red-500 ml-auto invisible group-hover:visible"
|
||||||
|
/>
|
||||||
|
<Check
|
||||||
|
v-if="lesson.is_complete"
|
||||||
|
class="h-4 w-4 text-green-700 ml-2"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</router-link>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</Draggable>
|
||||||
|
<div v-if="allowEdit" class="flex mt-2 mb-4 pl-8">
|
||||||
|
<router-link
|
||||||
|
v-if="!chapter.is_scorm_package"
|
||||||
|
:to="{
|
||||||
|
name: 'LessonForm',
|
||||||
|
params: {
|
||||||
|
courseName: courseName,
|
||||||
|
chapterNumber: chapter.idx,
|
||||||
|
lessonNumber: chapter.lessons.length + 1,
|
||||||
|
},
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
<Button>
|
||||||
|
{{ __('Add Lesson') }}
|
||||||
|
</Button>
|
||||||
|
</router-link>
|
||||||
|
</div>
|
||||||
|
</DisclosurePanel>
|
||||||
|
</Disclosure>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<ChapterModal
|
||||||
|
v-model="showChapterModal"
|
||||||
|
v-model:outline="outline"
|
||||||
|
:course="courseName"
|
||||||
|
:chapterDetail="getCurrentChapter()"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
<script setup>
|
||||||
|
import { Button, createResource, Tooltip } from 'frappe-ui'
|
||||||
|
import { getCurrentInstance, inject, ref } from 'vue'
|
||||||
|
import Draggable from 'vuedraggable'
|
||||||
|
import { Disclosure, DisclosureButton, DisclosurePanel } from '@headlessui/vue'
|
||||||
|
import {
|
||||||
|
Check,
|
||||||
|
ChevronRight,
|
||||||
|
FileText,
|
||||||
|
FilePenLine,
|
||||||
|
HelpCircle,
|
||||||
|
MonitorPlay,
|
||||||
|
Trash2,
|
||||||
|
} from 'lucide-vue-next'
|
||||||
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
|
import ChapterModal from '@/components/Modals/ChapterModal.vue'
|
||||||
|
import { showToast } from '@/utils'
|
||||||
|
|
||||||
|
const route = useRoute()
|
||||||
|
const router = useRouter()
|
||||||
|
const user = inject('$user')
|
||||||
|
const showChapterModal = ref(false)
|
||||||
|
const currentChapter = ref(null)
|
||||||
|
const app = getCurrentInstance()
|
||||||
|
const { $dialog } = app.appContext.config.globalProperties
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
courseName: {
|
||||||
|
type: String,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
showOutline: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
|
title: {
|
||||||
|
type: String,
|
||||||
|
default: '',
|
||||||
|
},
|
||||||
|
allowEdit: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
|
getProgress: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const outline = createResource({
|
||||||
|
url: 'lms.lms.utils.get_course_outline',
|
||||||
|
cache: ['course_outline', props.courseName],
|
||||||
|
params: {
|
||||||
|
course: props.courseName,
|
||||||
|
progress: props.getProgress,
|
||||||
|
},
|
||||||
|
auto: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
const deleteLesson = createResource({
|
||||||
|
url: 'lms.lms.api.delete_lesson',
|
||||||
|
makeParams(values) {
|
||||||
|
return {
|
||||||
|
lesson: values.lesson,
|
||||||
|
chapter: values.chapter,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onSuccess() {
|
||||||
|
outline.reload()
|
||||||
|
showToast('Success', 'Lesson deleted successfully', 'check')
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const updateLessonIndex = createResource({
|
||||||
|
url: 'lms.lms.api.update_lesson_index',
|
||||||
|
makeParams(values) {
|
||||||
|
return {
|
||||||
|
lesson: values.lesson,
|
||||||
|
sourceChapter: values.sourceChapter,
|
||||||
|
targetChapter: values.targetChapter,
|
||||||
|
idx: values.idx,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onSuccess() {
|
||||||
|
showToast('Success', 'Lesson moved successfully', 'check')
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const trashLesson = (lessonName, chapterName) => {
|
||||||
|
$dialog({
|
||||||
|
title: __('Delete this lesson?'),
|
||||||
|
message: __(
|
||||||
|
'Deleting this lesson will permanently remove it from the course. This action cannot be undone. Are you sure you want to continue?'
|
||||||
|
),
|
||||||
|
actions: [
|
||||||
|
{
|
||||||
|
label: __('Delete'),
|
||||||
|
theme: 'red',
|
||||||
|
variant: 'solid',
|
||||||
|
onClick(close) {
|
||||||
|
deleteLesson.submit({
|
||||||
|
lesson: lessonName,
|
||||||
|
chapter: chapterName,
|
||||||
|
})
|
||||||
|
close()
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const openChapterDetail = (index) => {
|
||||||
|
return index == route.params.chapterNumber || index == 1
|
||||||
|
}
|
||||||
|
|
||||||
|
const openChapterModal = (chapter = null) => {
|
||||||
|
currentChapter.value = chapter
|
||||||
|
showChapterModal.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
const getCurrentChapter = () => {
|
||||||
|
return currentChapter.value
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateOutline = (e) => {
|
||||||
|
updateLessonIndex.submit({
|
||||||
|
lesson: e.item.__draggable_context.element.name,
|
||||||
|
sourceChapter: e.from.dataset.chapter,
|
||||||
|
targetChapter: e.to.dataset.chapter,
|
||||||
|
idx: e.newIndex,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const deleteChapter = createResource({
|
||||||
|
url: 'lms.lms.api.delete_chapter',
|
||||||
|
makeParams(values) {
|
||||||
|
return {
|
||||||
|
chapter: values.chapter,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onSuccess() {
|
||||||
|
outline.reload()
|
||||||
|
showToast('Success', 'Chapter deleted successfully', 'check')
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const trashChapter = (chapterName) => {
|
||||||
|
$dialog({
|
||||||
|
title: __('Delete this chapter?'),
|
||||||
|
message: __(
|
||||||
|
'Deleting this chapter will also delete all its lessons and permanently remove it from the course. This action cannot be undone. Are you sure you want to continue?'
|
||||||
|
),
|
||||||
|
actions: [
|
||||||
|
{
|
||||||
|
label: __('Delete'),
|
||||||
|
theme: 'red',
|
||||||
|
variant: 'solid',
|
||||||
|
onClick(close) {
|
||||||
|
deleteChapter.submit({ chapter: chapterName })
|
||||||
|
close()
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const redirectToChapter = (chapter) => {
|
||||||
|
if (!chapter.is_scorm_package) return
|
||||||
|
event.preventDefault()
|
||||||
|
if (props.allowEdit) return
|
||||||
|
if (!user.data) {
|
||||||
|
showToast(
|
||||||
|
__('You are not enrolled'),
|
||||||
|
__('Please enroll for this course to view this lesson'),
|
||||||
|
'alert-circle'
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
router.push({
|
||||||
|
name: 'SCORMChapter',
|
||||||
|
params: {
|
||||||
|
courseName: props.courseName,
|
||||||
|
chapterName: chapter.name,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
<style>
|
||||||
|
.outline-lesson:has(.router-link-active) {
|
||||||
|
background-color: theme('colors.gray.100');
|
||||||
|
}
|
||||||
|
</style>
|
||||||
115
frontend/src/components/CourseReviews.vue
Normal file
115
frontend/src/components/CourseReviews.vue
Normal file
@@ -0,0 +1,115 @@
|
|||||||
|
<template>
|
||||||
|
<div v-if="reviews.data?.length || membership" class="mt-20 mb-10">
|
||||||
|
<Button
|
||||||
|
v-if="membership && !hasReviewed.data"
|
||||||
|
@click="openReviewModal()"
|
||||||
|
class="float-right"
|
||||||
|
>
|
||||||
|
{{ __('Write a Review') }}
|
||||||
|
</Button>
|
||||||
|
<div class="flex items-center font-semibold text-2xl">
|
||||||
|
{{ __('Student Reviews') }}
|
||||||
|
</div>
|
||||||
|
<div class="grid gap-8 mt-10">
|
||||||
|
<div v-for="(review, index) in reviews.data">
|
||||||
|
<div class="flex items-center">
|
||||||
|
<router-link
|
||||||
|
:to="{
|
||||||
|
name: 'Profile',
|
||||||
|
params: { username: review.owner_details.username },
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
<UserAvatar :user="review.owner_details" :size="'2xl'" />
|
||||||
|
</router-link>
|
||||||
|
<div class="mx-4">
|
||||||
|
<router-link
|
||||||
|
:to="{
|
||||||
|
name: 'Profile',
|
||||||
|
params: { username: review.owner_details.username },
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
<span class="text-lg font-medium mr-4">
|
||||||
|
{{ review.owner_details.full_name }}
|
||||||
|
</span>
|
||||||
|
</router-link>
|
||||||
|
<span>
|
||||||
|
{{ review.creation }}
|
||||||
|
</span>
|
||||||
|
<div class="flex mt-2">
|
||||||
|
<Star
|
||||||
|
v-for="index in 5"
|
||||||
|
class="h-5 w-5 text-gray-100 bg-gray-200 rounded-sm mr-2"
|
||||||
|
:class="
|
||||||
|
index <= Math.ceil(review.rating)
|
||||||
|
? 'fill-orange-500'
|
||||||
|
: 'fill-gray-600'
|
||||||
|
"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-if="review.review" class="mt-4 leading-5">
|
||||||
|
{{ review.review }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<ReviewModal
|
||||||
|
v-model="showReviewModal"
|
||||||
|
v-model:reloadReviews="reviews"
|
||||||
|
v-model:hasReviewed="hasReviewed"
|
||||||
|
:courseName="courseName"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
<script setup>
|
||||||
|
import { Star } from 'lucide-vue-next'
|
||||||
|
import { createResource, Button } from 'frappe-ui'
|
||||||
|
import { computed, ref, inject } from 'vue'
|
||||||
|
import UserAvatar from '@/components/UserAvatar.vue'
|
||||||
|
import ReviewModal from '@/components/Modals/ReviewModal.vue'
|
||||||
|
|
||||||
|
const user = inject('$user')
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
courseName: {
|
||||||
|
type: String,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
avg_rating: {
|
||||||
|
type: String,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
membership: {
|
||||||
|
type: Object,
|
||||||
|
required: false,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const hasReviewed = createResource({
|
||||||
|
url: 'frappe.client.get_count',
|
||||||
|
cache: ['eligible_to_review', props.courseName, props.membership?.member],
|
||||||
|
params: {
|
||||||
|
doctype: 'LMS Course Review',
|
||||||
|
filters: {
|
||||||
|
course: props.courseName,
|
||||||
|
owner: props.membership?.member,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
auto: user.data?.name ? true : false,
|
||||||
|
})
|
||||||
|
|
||||||
|
const reviews = createResource({
|
||||||
|
url: 'lms.lms.utils.get_reviews',
|
||||||
|
cache: ['course_reviews', props.courseName],
|
||||||
|
params: {
|
||||||
|
course: props.courseName,
|
||||||
|
},
|
||||||
|
auto: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
const showReviewModal = ref(false)
|
||||||
|
|
||||||
|
function openReviewModal() {
|
||||||
|
showReviewModal.value = true
|
||||||
|
}
|
||||||
|
</script>
|
||||||
30
frontend/src/components/CreateOutline.vue
Normal file
30
frontend/src/components/CreateOutline.vue
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
<template>
|
||||||
|
<div v-if="course">
|
||||||
|
<div class="text-xl font-semibold">
|
||||||
|
{{ course.title }}
|
||||||
|
</div>
|
||||||
|
<div v-if="course.chapters.length">
|
||||||
|
{{ course.chapters }}
|
||||||
|
</div>
|
||||||
|
<div v-else class="border bg-white rounded-md p-5 text-center mt-4">
|
||||||
|
<div>
|
||||||
|
{{
|
||||||
|
__(
|
||||||
|
'There are no chapters in this course. Create and manage chapters from here.'
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
</div>
|
||||||
|
<Button class="mt-4">
|
||||||
|
{{ __('Add Chapter') }}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<script setup>
|
||||||
|
const props = defineProps({
|
||||||
|
course: {
|
||||||
|
type: Object,
|
||||||
|
default: {},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
</script>
|
||||||
21
frontend/src/components/DesktopLayout.vue
Normal file
21
frontend/src/components/DesktopLayout.vue
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
<template>
|
||||||
|
<div class="relative flex h-full flex-col">
|
||||||
|
<div class="h-full flex-1">
|
||||||
|
<div class="flex h-screen text-base">
|
||||||
|
<div
|
||||||
|
class="relative block min-h-0 flex-shrink-0 overflow-hidden hover:overflow-auto"
|
||||||
|
>
|
||||||
|
<AppSidebar />
|
||||||
|
</div>
|
||||||
|
<div class="w-full overflow-auto" id="scrollContainer">
|
||||||
|
<OnboardingBanner />
|
||||||
|
<slot />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<script setup>
|
||||||
|
import AppSidebar from './AppSidebar.vue'
|
||||||
|
import OnboardingBanner from '@/components/OnboardingBanner.vue'
|
||||||
|
</script>
|
||||||
244
frontend/src/components/DiscussionReplies.vue
Normal file
244
frontend/src/components/DiscussionReplies.vue
Normal file
@@ -0,0 +1,244 @@
|
|||||||
|
<template>
|
||||||
|
<div class="mt-6">
|
||||||
|
<div v-if="!singleThread" class="flex items-center mb-5">
|
||||||
|
<Button variant="outline" @click="showTopics = true">
|
||||||
|
<template #icon>
|
||||||
|
<ChevronLeft class="w-5 h-5 stroke-1.5 text-gray-700" />
|
||||||
|
</template>
|
||||||
|
</Button>
|
||||||
|
<span class="text-lg font-semibold ml-2">
|
||||||
|
{{ topic.title }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-for="(reply, index) in replies.data">
|
||||||
|
<div
|
||||||
|
class="py-3"
|
||||||
|
:class="{ 'border-b': index + 1 != replies.data.length }"
|
||||||
|
>
|
||||||
|
<div class="flex items-center justify-between mb-2">
|
||||||
|
<div class="flex items-center">
|
||||||
|
<UserAvatar :user="reply.user" class="mr-2" />
|
||||||
|
<span>
|
||||||
|
{{ reply.user.full_name }}
|
||||||
|
</span>
|
||||||
|
<span class="text-sm ml-2">
|
||||||
|
{{ timeAgo(reply.creation) }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<Dropdown
|
||||||
|
v-if="user.data.name == reply.owner && !reply.editable"
|
||||||
|
:options="[
|
||||||
|
{
|
||||||
|
label: 'Edit',
|
||||||
|
onClick() {
|
||||||
|
reply.editable = true
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Delete',
|
||||||
|
onClick() {
|
||||||
|
deleteReply(reply)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]"
|
||||||
|
>
|
||||||
|
<template v-slot="{ open }">
|
||||||
|
<MoreHorizontal class="w-4 h-4 stroke-1.5 cursor-pointer" />
|
||||||
|
</template>
|
||||||
|
</Dropdown>
|
||||||
|
<div v-if="reply.editable">
|
||||||
|
<Button variant="ghost" @click="postEdited(reply)">
|
||||||
|
{{ __('Post') }}
|
||||||
|
</Button>
|
||||||
|
<Button variant="ghost" @click="reply.editable = false">
|
||||||
|
{{ __('Discard') }}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<TextEditor
|
||||||
|
:content="reply.reply"
|
||||||
|
@change="(val) => (reply.reply = val)"
|
||||||
|
:editable="reply.editable || false"
|
||||||
|
:fixedMenu="reply.editable || false"
|
||||||
|
:editorClass="
|
||||||
|
reply.editable
|
||||||
|
? 'ProseMirror prose prose-table:table-fixed prose-td:p-2 prose-th:p-2 prose-td:border prose-th:border prose-td:border-gray-300 prose-th:border-gray-300 prose-td:relative prose-th:relative prose-th:bg-gray-100 prose-sm max-w-none'
|
||||||
|
: 'prose-sm'
|
||||||
|
"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<TextEditor
|
||||||
|
class="mt-5"
|
||||||
|
:content="newReply"
|
||||||
|
:mentions="mentionUsers"
|
||||||
|
@change="(val) => (newReply = val)"
|
||||||
|
placeholder="Type your reply here..."
|
||||||
|
:fixedMenu="true"
|
||||||
|
editorClass="ProseMirror prose prose-table:table-fixed prose-td:p-2 prose-th:p-2 prose-td:border prose-th:border prose-td:border-gray-300 prose-th:border-gray-300 prose-td:relative prose-th:relative prose-th:bg-gray-100 prose-sm max-w-none border border-gray-300 rounded-b-md min-h-[7rem] py-1 px-2"
|
||||||
|
/>
|
||||||
|
<div class="flex justify-between mt-2">
|
||||||
|
<span> </span>
|
||||||
|
<Button @click="postReply()">
|
||||||
|
<span>
|
||||||
|
{{ __('Post') }}
|
||||||
|
</span>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<script setup>
|
||||||
|
import { createResource, TextEditor, Button, Dropdown } from 'frappe-ui'
|
||||||
|
import { timeAgo } from '../utils'
|
||||||
|
import UserAvatar from '@/components/UserAvatar.vue'
|
||||||
|
import { ChevronLeft, MoreHorizontal } from 'lucide-vue-next'
|
||||||
|
import { ref, inject, onMounted, computed } from 'vue'
|
||||||
|
import { createToast } from '../utils'
|
||||||
|
|
||||||
|
const showTopics = defineModel('showTopics')
|
||||||
|
const newReply = ref('')
|
||||||
|
const socket = inject('$socket')
|
||||||
|
const user = inject('$user')
|
||||||
|
const allUsers = inject('$allUsers')
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
topic: {
|
||||||
|
type: Object,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
singleThread: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
socket.on('publish_message', (data) => {
|
||||||
|
replies.reload()
|
||||||
|
})
|
||||||
|
socket.on('update_message', (data) => {
|
||||||
|
replies.reload()
|
||||||
|
})
|
||||||
|
socket.on('delete_message', (data) => {
|
||||||
|
replies.reload()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
const replies = createResource({
|
||||||
|
url: 'lms.lms.utils.get_discussion_replies',
|
||||||
|
cache: ['replies', props.topic],
|
||||||
|
makeParams(values) {
|
||||||
|
return {
|
||||||
|
topic: props.topic.name,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
auto: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
const newReplyResource = createResource({
|
||||||
|
url: 'frappe.client.insert',
|
||||||
|
makeParams(values) {
|
||||||
|
return {
|
||||||
|
doc: {
|
||||||
|
doctype: 'Discussion Reply',
|
||||||
|
reply: newReply.value,
|
||||||
|
topic: props.topic.name,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const mentionUsers = computed(() => {
|
||||||
|
let users = Object.values(allUsers.data).map((user) => {
|
||||||
|
return {
|
||||||
|
value: user.name,
|
||||||
|
label: user.full_name,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return users
|
||||||
|
})
|
||||||
|
|
||||||
|
const postReply = () => {
|
||||||
|
newReplyResource.submit(
|
||||||
|
{},
|
||||||
|
{
|
||||||
|
validate() {
|
||||||
|
if (!newReply.value) {
|
||||||
|
return 'Reply cannot be empty'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onSuccess() {
|
||||||
|
newReply.value = ''
|
||||||
|
replies.reload()
|
||||||
|
},
|
||||||
|
onError(err) {
|
||||||
|
createToast({
|
||||||
|
title: 'Error',
|
||||||
|
text: err.messages?.[0] || err,
|
||||||
|
icon: 'x',
|
||||||
|
iconClasses: 'bg-red-600 text-white rounded-md p-px',
|
||||||
|
position: 'top-center',
|
||||||
|
timeout: 10,
|
||||||
|
})
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const editReplyResource = createResource({
|
||||||
|
url: 'frappe.client.set_value',
|
||||||
|
makeParams(values) {
|
||||||
|
return {
|
||||||
|
doctype: 'Discussion Reply',
|
||||||
|
name: values.name,
|
||||||
|
fieldname: 'reply',
|
||||||
|
value: values.reply,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const postEdited = (reply) => {
|
||||||
|
editReplyResource.submit(
|
||||||
|
{
|
||||||
|
name: reply.name,
|
||||||
|
reply: reply.reply,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
validate() {
|
||||||
|
if (!reply.reply) {
|
||||||
|
return 'Reply cannot be empty'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onSuccess() {
|
||||||
|
reply.editable = false
|
||||||
|
replies.reload()
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const deleteReplyResource = createResource({
|
||||||
|
url: 'frappe.client.delete',
|
||||||
|
makeParams(values) {
|
||||||
|
return {
|
||||||
|
doctype: 'Discussion Reply',
|
||||||
|
name: values.name,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const deleteReply = (reply) => {
|
||||||
|
deleteReplyResource.submit(
|
||||||
|
{
|
||||||
|
name: reply.name,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
onSuccess() {
|
||||||
|
replies.reload()
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
</script>
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user