Compare commits
1613 Commits
task/desig
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2bfb94d01b | ||
|
|
df84aaadf4 | ||
|
|
2789660e8a | ||
|
|
a78e6eb64f | ||
|
|
6c7d189957 | ||
|
|
31f2a21a76 | ||
|
|
c49b1c8070 | ||
|
|
d78654a30f | ||
|
|
655e019bf6 | ||
|
|
d7a8f496c0 | ||
|
|
5e363e9951 | ||
|
|
3370f3c648 | ||
|
|
d77336374d | ||
|
|
e765dea106 | ||
|
|
75fb9490e0 | ||
|
|
3ef7998193 | ||
|
|
32cd8495d6 | ||
|
|
4e3cfec9e8 | ||
|
|
ba8cc342b1 | ||
|
|
dd8f821e35 | ||
|
|
a4ef2222e2 | ||
|
|
93d9e49358 | ||
|
|
5d0a3acbee | ||
|
|
340ff5a30a | ||
|
|
37908423eb | ||
|
|
b388ee399f | ||
|
|
4ac11df6ae | ||
|
|
14e2702aca | ||
|
|
fec3b51553 | ||
|
|
53d6b0dd51 | ||
|
|
d8386bdd8e | ||
|
|
df2f83e496 | ||
|
|
e214d8b598 | ||
|
|
c14f16c97a | ||
|
|
ca2cf739ee | ||
|
|
d432fb4bc4 | ||
|
|
d5bffc9bad | ||
|
|
75b4643918 | ||
|
|
9ae6b8e894 | ||
|
|
6f6c5a4209 | ||
|
|
769b1b39d3 | ||
|
|
4bb12c7f01 | ||
|
|
a80a342ae2 | ||
|
|
e5e60fcce9 | ||
|
|
b175d8797e | ||
|
|
f06349e350 | ||
|
|
34caf9986c | ||
|
|
3a3d3d014d | ||
|
|
c49c303f20 | ||
|
|
cbe353c2c5 | ||
|
|
991adede96 | ||
|
|
f95bce6fa2 | ||
|
|
1dd6cead9e | ||
|
|
5a72ebaea1 | ||
|
|
00d2a7c2ad | ||
|
|
a6e122e82d | ||
|
|
bf2aa29b98 | ||
|
|
cf12a4ed4f | ||
|
|
8711802b97 | ||
|
|
36afde8aa4 | ||
|
|
752881df41 | ||
|
|
63604b68e2 | ||
|
|
d74aa39076 | ||
|
|
c3849518fb | ||
|
|
7fb5e1a62b | ||
|
|
4b405297f2 | ||
|
|
f0849b9b42 | ||
|
|
845a5aa9dc | ||
|
|
d48c7b0d03 | ||
|
|
6692c201e4 | ||
|
|
f4c7961caa | ||
|
|
b215885dc6 | ||
|
|
de15eb5ee1 | ||
|
|
d3385caaf8 | ||
|
|
19f2193414 | ||
|
|
d59b654ac2 | ||
|
|
29b6a02118 | ||
|
|
b77476dc9a | ||
|
|
5a685ebe80 | ||
|
|
835a9bee03 | ||
|
|
16545d2075 | ||
|
|
b684262759 | ||
|
|
ac539332e6 | ||
|
|
ed87c8b163 | ||
|
|
e33ab315ad | ||
|
|
1feef5c419 | ||
|
|
a0229cd971 | ||
|
|
662e3b0266 | ||
|
|
b9aec7261f | ||
|
|
54a9f6869a | ||
|
|
9de4cba8e8 | ||
|
|
7d750dc584 | ||
|
|
98ba0bfc04 | ||
|
|
f89b42c41c | ||
|
|
c968044160 | ||
|
|
5d727fc528 | ||
|
|
bdc5ff7797 | ||
|
|
011c6e9e30 | ||
|
|
42a8ec2f8a | ||
|
|
58aebaa66c | ||
|
|
b69b6e6c77 | ||
|
|
86af876f01 | ||
|
|
b685259dc7 | ||
|
|
16b959fb7a | ||
|
|
a40ae04aa3 | ||
|
|
db95fc5681 | ||
|
|
8db47a3962 | ||
|
|
ab81a1753d | ||
|
|
c98af863c3 | ||
|
|
73610dc273 | ||
|
|
37216e2a5a | ||
|
|
ac072b0a5a | ||
|
|
2c0153e055 | ||
|
|
2eff08bf86 | ||
|
|
f71a7182dd | ||
|
|
1f7639a30e | ||
|
|
41d09eaad8 | ||
|
|
f6b0c96b3b | ||
|
|
dcd25465fd | ||
|
|
c921d54d50 | ||
|
|
a4f60455b5 | ||
|
|
a0936cb1a4 | ||
|
|
aa76c2b54b | ||
|
|
4e81c08adb | ||
|
|
4895f00184 | ||
|
|
f727ab4792 | ||
|
|
1c75a0e59c | ||
|
|
e36b24ea3f | ||
|
|
8f8d5e5640 | ||
|
|
73e2e95449 | ||
|
|
48187fc7f2 | ||
|
|
01222b3a13 | ||
|
|
4d788e13b4 | ||
|
|
39a397d262 | ||
|
|
ae9a49681e | ||
|
|
50d2841349 | ||
|
|
f485c782f3 | ||
|
|
c2c9b3374c | ||
|
|
66d23b4140 | ||
|
|
580e319fb9 | ||
|
|
205449e1ae | ||
|
|
4724e98993 | ||
|
|
6f9be29cd8 | ||
|
|
d8fafa5cae | ||
|
|
ccbbf30058 | ||
|
|
387418b9bd | ||
|
|
715a841483 | ||
|
|
f6cd509aa4 | ||
|
|
393b1a6be9 | ||
|
|
bc89f4b9ce | ||
|
|
8f77f28aaa | ||
|
|
61e07dae95 | ||
|
|
0739e044a1 | ||
|
|
7f91a92962 | ||
|
|
af9f70880a | ||
|
|
26fa1691c4 | ||
|
|
548163d66c | ||
|
|
8ff0d16402 | ||
|
|
4c746b93bc | ||
|
|
502cc64f99 | ||
|
|
f64b50df9e | ||
|
|
17154be8bf | ||
|
|
b52259794e | ||
|
|
bd9e249704 | ||
|
|
f642e41bfa | ||
|
|
7b5d021bf3 | ||
|
|
958f74bd9c | ||
|
|
bac2a08748 | ||
|
|
770056e0c4 | ||
|
|
f8e9cfbeff | ||
|
|
408cfbb500 | ||
|
|
2146f57941 | ||
|
|
e9c961e633 | ||
|
|
9cf13e3f26 | ||
|
|
f1d97aa6c9 | ||
|
|
d938535d9f | ||
|
|
319da200c6 | ||
|
|
345b784daf | ||
|
|
1b15a035df | ||
|
|
8d7b47312e | ||
|
|
860f1295e5 | ||
|
|
0cbdba1ab8 | ||
|
|
4ee3724196 | ||
|
|
98a1636d0c | ||
|
|
020f65c566 | ||
|
|
f6d387ce2d | ||
|
|
d3d5e59aad | ||
|
|
84f66f0bbb | ||
|
|
8d0f98d186 | ||
|
|
ed9de74f28 | ||
|
|
bcf3cf0667 | ||
|
|
f3057c675f | ||
|
|
61d1bbbe13 | ||
|
|
6bb817f9af | ||
|
|
3b6836c15a | ||
|
|
858e29eb93 | ||
|
|
240e36f15a | ||
|
|
3e74827c47 | ||
|
|
578d29066f | ||
|
|
1a7d35317b | ||
|
|
ce35ba71f4 | ||
|
|
76cbf8dc41 | ||
|
|
71ed1013b7 | ||
|
|
fa0c257040 | ||
|
|
cf85b5a822 | ||
|
|
efba1939e5 | ||
|
|
eabfcd026b | ||
|
|
d074ec390c | ||
|
|
6a6c4661c4 | ||
|
|
7538392e44 | ||
|
|
8920eb8441 | ||
|
|
35d28fbff6 | ||
|
|
19b6626b71 | ||
|
|
8bd8b61041 | ||
|
|
4f32e3cf93 | ||
|
|
70530b3f6c | ||
|
|
7d08db28aa | ||
|
|
3b6fd2bc6b | ||
|
|
04b2cb3907 | ||
|
|
ec47d750bf | ||
|
|
98ed842932 | ||
|
|
5252faafb7 | ||
|
|
7801a7d05a | ||
|
|
a36be67c8b | ||
|
|
1b97f86a37 | ||
|
|
444fb15c29 | ||
|
|
490c5ad7d3 | ||
|
|
b6f61c6be1 | ||
|
|
2e5545f181 | ||
|
|
cd14ac537d | ||
|
|
24d613e9cd | ||
|
|
1f1c2b4aaf | ||
|
|
0faa908538 | ||
|
|
5cfd2c90b7 | ||
|
|
8efaa67574 | ||
|
|
c90bc271d3 | ||
|
|
09e97a28a5 | ||
|
|
a96d4c6e52 | ||
|
|
432c6ed6fd | ||
|
|
942b2c853c | ||
|
|
a2a513077f | ||
|
|
a0e79f7a5c | ||
|
|
ca5977e78b | ||
|
|
40d049ad36 | ||
|
|
dacab73265 | ||
|
|
af5ae2a687 | ||
|
|
44da859d30 | ||
|
|
6a1bc92270 | ||
|
|
48d3cfe5f8 | ||
|
|
4e94773861 | ||
|
|
f2941e2ba3 | ||
|
|
67909c1d7c | ||
|
|
efb153a33d | ||
|
|
2e96508205 | ||
|
|
61b6da749e | ||
|
|
c6e8d3527d | ||
|
|
9faf82ee9c | ||
|
|
1fc439cb25 | ||
|
|
de08164dd8 | ||
|
|
2ed4e6509e | ||
|
|
39ff336af5 | ||
|
|
bb5326a331 | ||
|
|
a1501d6c23 | ||
|
|
114da173be | ||
|
|
bce3a25dc2 | ||
|
|
47762544fc | ||
|
|
55a03b283f | ||
|
|
e5087d4d58 | ||
|
|
2160a42964 | ||
|
|
593d349617 | ||
|
|
a6bd3a9f3b | ||
|
|
f6fc701fb7 | ||
|
|
50bbb0dacf | ||
|
|
f301001ebe | ||
|
|
0eed8e4612 | ||
|
|
e6d77af53f | ||
|
|
bb24fe3c6d | ||
|
|
4f60819dcc | ||
|
|
501606233f | ||
|
|
d564d86feb | ||
|
|
c067e26e0c | ||
|
|
e40f880342 | ||
|
|
e9b7bd14cc | ||
|
|
2e5c4295dc | ||
|
|
ba71995ace | ||
|
|
a18bdfcef6 | ||
|
|
07e03c8981 | ||
|
|
8cb09e349f | ||
|
|
70a461bc69 | ||
|
|
6aa5385939 | ||
|
|
49749bfbe9 | ||
|
|
69d0b2bdf4 | ||
|
|
9000ca9001 | ||
|
|
153d7f5448 | ||
|
|
6f5f53d564 | ||
|
|
311036fe86 | ||
|
|
49c63544a1 | ||
|
|
36c3bb4392 | ||
|
|
1cc068db3e | ||
|
|
fdf411d133 | ||
|
|
b2dc9b2e31 | ||
|
|
1787e3ed53 | ||
|
|
711a0743c2 | ||
|
|
0a3a00cd3f | ||
|
|
696c968ebc | ||
|
|
af21ff2f20 | ||
|
|
4219341951 | ||
|
|
322d7905c3 | ||
|
|
a010a630ac | ||
|
|
7ac15fc767 | ||
|
|
042b07c267 | ||
|
|
c507eae507 | ||
|
|
f0a97d42a4 | ||
|
|
065497dfa3 | ||
|
|
7ae91d7bc1 | ||
|
|
78cf011bf7 | ||
|
|
fae7b729fe | ||
|
|
0325bb68f5 | ||
|
|
50481a836e | ||
|
|
bead6d98ae | ||
|
|
55737781bc | ||
|
|
4ce208281d | ||
|
|
0741c4c647 | ||
|
|
dc8f00c318 | ||
|
|
7045b4e3c7 | ||
|
|
a371b171bb | ||
|
|
5165b6ae6d | ||
|
|
817100b0e4 | ||
|
|
7de860ad44 | ||
|
|
27b72c162b | ||
|
|
b5ac908d09 | ||
|
|
b50913eda8 | ||
|
|
ffa2045a2d | ||
|
|
f686985c6e | ||
|
|
e8b56485ee | ||
|
|
df41611093 | ||
|
|
54c5b2063b | ||
|
|
225640ad8a | ||
|
|
7dacc69321 | ||
|
|
e6f9d9b79a | ||
|
|
b5a40ea220 | ||
|
|
15c9c4d4bd | ||
|
|
774f5e72c0 | ||
|
|
a502b2b863 | ||
|
|
dc4694eb17 | ||
|
|
ce353f34c7 | ||
|
|
a2bc997e8f | ||
|
|
f29daa0d94 | ||
|
|
28c5d13682 | ||
|
|
35ca933339 | ||
|
|
dd94f245eb | ||
|
|
ef857fee59 | ||
|
|
fa0c502467 | ||
|
|
0becd295b0 | ||
|
|
184a5fd820 | ||
|
|
70a027f85b | ||
|
|
87d7d6f12b | ||
|
|
4917583c67 | ||
|
|
a0a9402945 | ||
|
|
22b8aed127 | ||
|
|
532b49165c | ||
|
|
1fb7343aa7 | ||
|
|
de31f77181 | ||
|
|
a53ee79c0a | ||
|
|
55204e2ce1 | ||
|
|
c43ab9a911 | ||
|
|
1ef4efcacf | ||
|
|
b5200c88fc | ||
|
|
3d4a604aa2 | ||
|
|
9f1f564e25 | ||
|
|
8adacc51a6 | ||
|
|
564e6438cb | ||
|
|
3c1c4489f8 | ||
|
|
044ec8d966 | ||
|
|
bae02e5192 | ||
|
|
dd94228672 | ||
|
|
8c392f8b49 | ||
|
|
07c9074d15 | ||
|
|
71bac76c3a | ||
|
|
fb293dc98c | ||
|
|
3ce97b4dcd | ||
|
|
7bfd000213 | ||
|
|
2a10933206 | ||
|
|
33a46c227b | ||
|
|
5153c3d5f1 | ||
|
|
85c8f622ee | ||
|
|
b9c097d42c | ||
|
|
192132559b | ||
|
|
6d1e8a9788 | ||
|
|
1c61d50a5c | ||
|
|
9f0ba418e5 | ||
|
|
6fd2e64e04 | ||
|
|
2c01e6b460 | ||
|
|
6e0c4d4361 | ||
|
|
745eef981f | ||
|
|
7a33f42bcd | ||
|
|
02564c8426 | ||
|
|
eab6ab03b7 | ||
|
|
6f534662e1 | ||
|
|
fbc7abdabb | ||
|
|
392eac2ef9 | ||
|
|
b7349b5df8 | ||
|
|
298901a642 | ||
|
|
88eafafe12 | ||
|
|
31a01a3157 | ||
|
|
a5b3a7e94d | ||
|
|
49e8237e99 | ||
|
|
d5769c2cb9 | ||
|
|
e49a325074 | ||
|
|
e6528392a2 | ||
|
|
a073ca1cce | ||
|
|
620e4dd787 | ||
|
|
7e30ca5750 | ||
|
|
e3847baadb | ||
|
|
2e065eddcb | ||
|
|
4e91b2f1fb | ||
|
|
5b8631ab6a | ||
|
|
f9f29eabb3 | ||
|
|
898edb152f | ||
|
|
bf0d696b2f | ||
|
|
d91b1c14e7 | ||
|
|
cdd42b2f07 | ||
|
|
34bc9df9ea | ||
|
|
15cc7c8cc9 | ||
|
|
b4ab620c78 | ||
|
|
6e4ef249b8 | ||
|
|
c2b4bb29d6 | ||
|
|
cab469007b | ||
|
|
d6782bd86e | ||
|
|
6251f8f4db | ||
|
|
fb9d11f38d | ||
|
|
bb8dca69cf | ||
|
|
53b31b306d | ||
|
|
d173cdb02a | ||
|
|
07f0ea25bb | ||
|
|
e7ee55d608 | ||
|
|
7fa4edf37d | ||
|
|
49022394b0 | ||
|
|
3be0d158e3 | ||
|
|
56f374bbfe | ||
|
|
417c9176fe | ||
|
|
e3400e8564 | ||
|
|
d680905a87 | ||
|
|
c07e3f86fb | ||
|
|
238a25aaeb | ||
|
|
171231cd21 | ||
|
|
6ed342bb6f | ||
|
|
6f7ef1abef | ||
|
|
e33fa00fa3 | ||
|
|
c0b814081e | ||
|
|
e8b7c5ff80 | ||
|
|
8c94bcac52 | ||
|
|
8803a8c166 | ||
|
|
2f63fd196b | ||
|
|
42471170ce | ||
|
|
2bf9afca9c | ||
|
|
9c41ddee60 | ||
|
|
9993c7a8a7 | ||
|
|
a22c9d102f | ||
|
|
2d0cb8eefb | ||
|
|
58448a391f | ||
|
|
f6550e6a36 | ||
|
|
cfe297cc38 | ||
|
|
4530e4079f | ||
|
|
de35e1a8b7 | ||
|
|
a6bf53e84c | ||
|
|
d7ffdc3031 | ||
|
|
98f2527fed | ||
|
|
d8bf10eaea | ||
|
|
f9216637df | ||
|
|
08945bfbdd | ||
|
|
b92a4285c9 | ||
|
|
271ca7069e | ||
|
|
08ed8fcb32 | ||
|
|
17f678a3ac | ||
|
|
6bd9816edd | ||
|
|
77ac15c2bb | ||
|
|
55cc9765e2 | ||
|
|
e433a150a9 | ||
|
|
a61ad2cc7e | ||
|
|
680f4cfa95 | ||
|
|
311824e8b7 | ||
|
|
2fb73cc3a3 | ||
|
|
70de97766e | ||
|
|
c7ff11d0fc | ||
|
|
e312af36bb | ||
|
|
ac980023b5 | ||
|
|
3b43803b7e | ||
|
|
c8be2f1255 | ||
|
|
b6b5f3a9f1 | ||
|
|
6ce81b300a | ||
|
|
3d3c4448ae | ||
|
|
cb2c1641f5 | ||
|
|
7bcd0f863f | ||
|
|
2d95cbd3dc | ||
|
|
49aac93618 | ||
|
|
28ad7944e0 | ||
|
|
d4553501b8 | ||
|
|
4654c21d92 | ||
|
|
becc91d8ea | ||
|
|
06cb4485f4 | ||
|
|
9b22fb259c | ||
|
|
f2137efaa0 | ||
|
|
00834fec7b | ||
|
|
ef84052909 | ||
|
|
6ea80dd0da | ||
|
|
5168e70edc | ||
|
|
a7719dbb55 | ||
|
|
25e6cb36a9 | ||
|
|
a7c1ea0409 | ||
|
|
8aed075553 | ||
|
|
3fc581aaac | ||
|
|
020f689af6 | ||
|
|
04c9ff24ea | ||
|
|
105c03fa09 | ||
|
|
547e0fc530 | ||
|
|
bf7793e103 | ||
|
|
60554d8e16 | ||
|
|
5d26af511c | ||
|
|
12104e797a | ||
|
|
d307c61cd7 | ||
|
|
6774b2d0b6 | ||
|
|
fa53382c08 | ||
|
|
67929655f4 | ||
|
|
e8c47941d0 | ||
|
|
82d0a0556f | ||
|
|
7cd18b07bb | ||
|
|
aca8ad2d14 | ||
|
|
4bb80919ad | ||
|
|
5ed851878a | ||
|
|
763452e3cc | ||
|
|
063a73691a | ||
|
|
caddf87231 | ||
|
|
0f38e01283 | ||
|
|
640b6f0e4d | ||
|
|
f43d562405 | ||
|
|
39752cbb1d | ||
|
|
229d93c03e | ||
|
|
b0ab8a8fce | ||
|
|
90cb705ad2 | ||
|
|
65fa6e64e6 | ||
|
|
7c0e7ef53e | ||
|
|
b6c3754b40 | ||
|
|
4e3c947d2a | ||
|
|
abcb1afd48 | ||
|
|
0b88d6bcd1 | ||
|
|
fef5bf44de | ||
|
|
2c43d48bbd | ||
|
|
4865b47393 | ||
|
|
3892fe1a67 | ||
|
|
39710aaea1 | ||
|
|
b57e11bec4 | ||
|
|
fdc8f98b21 | ||
|
|
2b71f2467c | ||
|
|
cd1caf0f53 | ||
|
|
3b77d3fc0b | ||
|
|
73525f1dc0 | ||
|
|
c256231cfc | ||
|
|
2fb41f7462 | ||
|
|
aa96b13ec2 | ||
|
|
f9429d1056 | ||
|
|
af9462398a | ||
|
|
6fd0b7aef3 | ||
|
|
bc47f9c001 | ||
|
|
4ea3a844ed | ||
|
|
ea8a3625ef | ||
|
|
3eb2f432fa | ||
|
|
5d10e6564d | ||
|
|
e518323d99 | ||
|
|
dbf262598f | ||
|
|
951ca5736e | ||
|
|
7960e7d8fb | ||
|
|
99039f8bf3 | ||
|
|
3c7df4e33c | ||
|
|
614a7a2a29 | ||
|
|
ec67f91263 | ||
|
|
aa4e13a18d | ||
|
|
23e26617e2 | ||
|
|
ef32226c6c | ||
|
|
c9174f37ef | ||
|
|
c99dbab4b6 | ||
|
|
eb985014be | ||
|
|
845bccbe2a | ||
|
|
3ec886c31d | ||
|
|
fa3929d5e9 | ||
|
|
b7940087b5 | ||
|
|
7fb0ed884c | ||
|
|
d93852e230 | ||
|
|
d04ea33616 | ||
|
|
eb38464aca | ||
|
|
cd85c71aec | ||
|
|
c464375414 | ||
|
|
82233c7d53 | ||
|
|
cc5be99b0f | ||
|
|
65dc3e92d0 | ||
|
|
addd117834 | ||
|
|
72b498eb85 | ||
|
|
0aba6355ed | ||
|
|
a0b8521f0a | ||
|
|
eb7c539a0e | ||
|
|
22928ce283 | ||
|
|
4a1a52bd61 | ||
|
|
af00f49adc | ||
|
|
8d37e60f5d | ||
|
|
74a53f55fd | ||
|
|
101605ad88 | ||
|
|
cf1b47fbd2 | ||
|
|
f9174c13c1 | ||
|
|
032d20b4b2 | ||
|
|
2146ef1a92 | ||
|
|
4928267036 | ||
|
|
f0f38b335f | ||
|
|
3e21538d02 | ||
|
|
33fd6ddf8f | ||
|
|
1bb5405894 | ||
|
|
44adc142f6 | ||
|
|
4379716e9b | ||
|
|
b4b078c8c9 | ||
|
|
6dbc2f5ed2 | ||
|
|
9a51096a94 | ||
|
|
1315e0b280 | ||
|
|
4505ea5ff8 | ||
|
|
192324b891 | ||
|
|
326d305a69 | ||
|
|
cfcff3cf3b | ||
|
|
202632ff58 | ||
|
|
7116892f9a | ||
|
|
c6f40f625b | ||
|
|
556f642112 | ||
|
|
22611121c6 | ||
|
|
720597e916 | ||
|
|
e74ded676e | ||
|
|
ee60eedd0d | ||
|
|
c37a1becbf | ||
|
|
b9cca483ec | ||
|
|
c758bdaf9e | ||
|
|
5ada588b16 | ||
|
|
eec1bb0c30 | ||
|
|
65f8368708 | ||
|
|
806e621c5b | ||
|
|
ce35b23714 | ||
|
|
2cd025b118 | ||
|
|
2e699d7e25 | ||
|
|
30da295c60 | ||
|
|
a82a399d52 | ||
|
|
505df31d6b | ||
|
|
a4d8ba72af | ||
|
|
2bfd0cb502 | ||
|
|
5ee071028c | ||
|
|
23b9452a3a | ||
|
|
0ce3a16d3a | ||
|
|
4315a7b17c | ||
|
|
247f192a0a | ||
|
|
9c944ae3d2 | ||
|
|
a390aa429d | ||
|
|
3367384791 | ||
|
|
158324a705 | ||
|
|
f9286d1793 | ||
|
|
2e376c37dd | ||
|
|
5bda9ed227 | ||
|
|
97b533bd3a | ||
|
|
75a45108a2 | ||
|
|
bfc0def20f | ||
|
|
9db33e6a51 | ||
|
|
ba5d926659 | ||
|
|
1cd4dfc397 | ||
|
|
bf5dd62b35 | ||
|
|
4e583d11b6 | ||
|
|
688505b4eb | ||
|
|
81b8ceb2b3 | ||
|
|
d93d36c392 | ||
|
|
3299acee36 | ||
|
|
abddead402 | ||
|
|
2d69fdac3c | ||
|
|
506ff2503e | ||
|
|
5d191730d2 | ||
|
|
346b131388 | ||
|
|
aba49e385f | ||
|
|
5789688eab | ||
|
|
f7da11bc69 | ||
|
|
10802f6bb5 | ||
|
|
37e356572b | ||
|
|
8669ef462d | ||
|
|
df1c0bad4d | ||
|
|
bcb1a0f914 | ||
|
|
bf1bdd935c | ||
|
|
edc9d4de2a | ||
|
|
229275aaee | ||
|
|
f0ff6ac691 | ||
|
|
878c7c2ef0 | ||
|
|
0a28c2bd41 | ||
|
|
38e48c90bb | ||
|
|
c6f35d7750 | ||
|
|
85f684dff5 | ||
|
|
d94a9bb88a | ||
|
|
1950d5f15d | ||
|
|
e84cc8ddd8 | ||
|
|
cf2fd06d39 | ||
|
|
b6015b6433 | ||
|
|
fea58a7b40 | ||
|
|
13284eab75 | ||
|
|
dd4e3a4694 | ||
|
|
eb55e65d91 | ||
|
|
cb75ba6056 | ||
|
|
859d9283a7 | ||
|
|
1a3437b333 | ||
|
|
bbbf17daa0 | ||
|
|
ae79aef132 | ||
|
|
c3e71b4389 | ||
|
|
2784117862 | ||
|
|
8162567e12 | ||
|
|
58300e32ff | ||
|
|
cb489bf0ca | ||
|
|
91bc91e725 | ||
|
|
ce086a8b22 | ||
|
|
6e71ee7cb0 | ||
|
|
21e58e3b9c | ||
|
|
b885dd46b5 | ||
|
|
0fc2df1070 | ||
|
|
cf91f1812d | ||
|
|
3289f27cd5 | ||
|
|
80939d16a5 | ||
|
|
11b5490af4 | ||
|
|
a31070d4a3 | ||
|
|
95c3f89911 | ||
|
|
2a58e0d33f | ||
|
|
afe59f5a3a | ||
|
|
7fd56357e0 | ||
|
|
a4a40b9145 | ||
|
|
48faee07f6 | ||
|
|
f0d7d7644b | ||
|
|
309dfba583 | ||
|
|
cf64a91651 | ||
|
|
0f47a8af70 | ||
|
|
d0310f7c2b | ||
|
|
f6a0a391b9 | ||
|
|
8dd4dad096 | ||
|
|
96baa2a6e0 | ||
|
|
8dd557a29b | ||
|
|
4e30eda06f | ||
|
|
12bb124d91 | ||
|
|
a71e6632d6 | ||
|
|
36f518afca | ||
|
|
a534126c61 | ||
|
|
752a46b247 | ||
|
|
663b1aae4f | ||
|
|
9b37b60be0 | ||
|
|
4347d0cabb | ||
|
|
0403773b8e | ||
|
|
8d99a6b03c | ||
|
|
02320b9484 | ||
|
|
fb077fd8cc | ||
|
|
b5a305485f | ||
|
|
8f5b27e9ce | ||
|
|
9ef04b822a | ||
|
|
a6160c3cf0 | ||
|
|
8f6639b7fc | ||
|
|
6a803fe137 | ||
|
|
d7f6a4dde7 | ||
|
|
6058e510de | ||
|
|
7208530879 | ||
|
|
9b6c545932 | ||
|
|
afb9071758 | ||
|
|
d50393930e | ||
|
|
03e1f2cfa3 | ||
|
|
877d2f359f | ||
|
|
45df9837e7 | ||
|
|
923319051c | ||
|
|
f6b4d6ad52 | ||
|
|
19d16c9cef | ||
|
|
daa27e41b3 | ||
|
|
916fa66446 | ||
|
|
10a3243756 | ||
|
|
a1c7f70329 | ||
|
|
bd2efb0ef5 | ||
|
|
34065f1f6e | ||
|
|
41873f80d7 | ||
|
|
a1b67c017d | ||
|
|
13fd7e1ee5 | ||
|
|
4996417218 | ||
|
|
60d436b5b9 | ||
|
|
8d39a20267 | ||
|
|
5d46d7e453 | ||
|
|
15f9fb320d | ||
|
|
494fc9bab6 | ||
|
|
0c5c024098 | ||
|
|
903a567805 | ||
|
|
df3929d5e6 | ||
|
|
6d62500596 | ||
|
|
e5e4e87752 | ||
|
|
0b3e686f3f | ||
|
|
3da87cce60 | ||
|
|
c9daba17e1 | ||
|
|
5cfd6d56a6 | ||
|
|
ec8c06ca94 | ||
|
|
77a22b3ab3 | ||
|
|
e79139174b | ||
|
|
61a86394ed | ||
|
|
f6741dd80e | ||
|
|
ce6708be6e | ||
|
|
b62cae2e3a | ||
|
|
d73b6d9d12 | ||
|
|
c11906a395 | ||
|
|
a29b0b56d9 | ||
|
|
53dbf99fba | ||
|
|
cb49e15cb0 | ||
|
|
0eddded560 | ||
|
|
11c6f70576 | ||
|
|
6712e89c47 | ||
|
|
9959cf4294 | ||
|
|
daec246835 | ||
|
|
8ea97ee944 | ||
|
|
975f4c8285 | ||
|
|
f0b85409c9 | ||
|
|
bdd862c633 | ||
|
|
4166781f7e | ||
|
|
1f8e9106de | ||
|
|
9e651358d5 | ||
|
|
5aed336c96 | ||
|
|
85b94512e9 | ||
|
|
906646ebce | ||
|
|
96108a4958 | ||
|
|
fb449f2054 | ||
|
|
d5ee3d9519 | ||
|
|
4e20ec6575 | ||
|
|
836b674076 | ||
|
|
5086c6fb09 | ||
|
|
489c9c3b7e | ||
|
|
e3ded29e77 | ||
|
|
16419a5584 | ||
|
|
3e3b24cc30 | ||
|
|
841698ba10 | ||
|
|
d50904611c | ||
|
|
e77fd16d26 | ||
|
|
649f24e4ae | ||
|
|
2f0cbfe74e | ||
|
|
d022bd078a | ||
|
|
c18afee9ad | ||
|
|
a65b72adad | ||
|
|
e13aea9f7d | ||
|
|
2920fa7f3a | ||
|
|
7af96ecccc | ||
|
|
70716b3483 | ||
|
|
d7bb64e7e0 | ||
|
|
dd19b5746c | ||
|
|
f967282f71 | ||
|
|
8b2459c304 | ||
|
|
72fb934d4f | ||
|
|
ed0b8bcb99 | ||
|
|
6f211d8435 | ||
|
|
b59589b855 | ||
|
|
db20feaa00 | ||
|
|
8fc2cf571e | ||
|
|
3128fea8c9 | ||
|
|
0e53b4a454 | ||
|
|
cbb61d18fe | ||
|
|
dff51cf6ea | ||
|
|
15dbadcc53 | ||
|
|
624a3fb88e | ||
|
|
00feee2179 | ||
|
|
0f8f9bc05b | ||
|
|
f76b7578a6 | ||
|
|
1a17689cd2 | ||
|
|
a958e2ff0d | ||
|
|
36b861266f | ||
|
|
771262fc18 | ||
|
|
0f03ce95e7 | ||
|
|
6a6e010daa | ||
|
|
13496387c4 | ||
|
|
4ecb21e0ae | ||
|
|
8663fe13bd | ||
|
|
de4638bc46 | ||
|
|
c9740fe8ee | ||
|
|
9b9b67c6cd | ||
|
|
fe2abaacae | ||
|
|
11e2ea3249 | ||
|
|
2de4b7c715 | ||
|
|
a8ffebe944 | ||
|
|
9ab7c3ed59 | ||
|
|
f374d91ef8 | ||
|
|
62ecc4e395 | ||
|
|
46764cacfa | ||
|
|
0b9e1bd734 | ||
|
|
bddb2ed18e | ||
|
|
e8fbeff77a | ||
|
|
b64593df90 | ||
|
|
2657cb409c | ||
|
|
329ed573b3 | ||
|
|
bb7558afb8 | ||
|
|
259ed03ee4 | ||
|
|
bf6c805487 | ||
|
|
1086e78936 | ||
|
|
7d0d930140 | ||
|
|
f02fff55e7 | ||
|
|
08e71c4dd8 | ||
|
|
6f5a74844c | ||
|
|
c4011cd456 | ||
|
|
5ef2568aa5 | ||
|
|
6d817e6d27 | ||
|
|
5decfb098d | ||
|
|
c2b6be4425 | ||
|
|
f320fee416 | ||
|
|
445e486cd2 | ||
|
|
ee26b50cf6 | ||
|
|
22f2b43692 | ||
|
|
29b2c8b3b8 | ||
|
|
51cc1e3f36 | ||
|
|
d9fce10538 | ||
|
|
bd74313bd5 | ||
|
|
18df890ef9 | ||
|
|
13ebb9bbd8 | ||
|
|
38c0c823e1 | ||
|
|
b50e15d1d9 | ||
|
|
969698d8b8 | ||
|
|
7d83ebc5c5 | ||
|
|
e99650ecd8 | ||
|
|
7287a9ce9a | ||
|
|
8cc7e6a57d | ||
|
|
0a24cb9978 | ||
|
|
a5c1286748 | ||
|
|
06684a4900 | ||
|
|
1823538058 | ||
|
|
60ccc822b5 | ||
|
|
9abd69c5e5 | ||
|
|
2667891bdd | ||
|
|
65485a0d1f | ||
|
|
74dd96d000 | ||
|
|
49ee3c45e5 | ||
|
|
49d2680a07 | ||
|
|
9dac7fd19e | ||
|
|
528299571c | ||
|
|
dcc630b8e5 | ||
|
|
be5125e5b0 | ||
|
|
0adf45c6ad | ||
|
|
d9b93a3470 | ||
|
|
83e4173750 | ||
|
|
e2d5f6ac9d | ||
|
|
37c3c6f7f4 | ||
|
|
3b4dfb9648 | ||
|
|
330c177ff9 | ||
|
|
0cff310354 | ||
|
|
87a1d7c288 | ||
|
|
8e1fe15a24 | ||
|
|
c95c0eff9b | ||
|
|
eaf94f458a | ||
|
|
ba85596e79 | ||
|
|
c6a478c406 | ||
|
|
2a27fbd02f | ||
|
|
a86ed9f76c | ||
|
|
20b52d049d | ||
|
|
165e33b188 | ||
|
|
04cbcbc4cb | ||
|
|
2feb9223c1 | ||
|
|
02d2d07f6c | ||
|
|
ecd66d61f2 | ||
|
|
424b72efaf | ||
|
|
79e51d6294 | ||
|
|
773480875f | ||
|
|
96d1b85f56 | ||
|
|
cf20920fd8 | ||
|
|
4114971244 | ||
|
|
bee20388d9 | ||
|
|
bd97529658 | ||
|
|
d3c24d738c | ||
|
|
eac43a160d | ||
|
|
24c3f506c6 | ||
|
|
3e13ed5830 | ||
|
|
9b5ff70037 | ||
|
|
d7f1a4f6b2 | ||
|
|
b663e5c706 | ||
|
|
efb99b31f2 | ||
|
|
03882d2a7e | ||
|
|
a3087567ea | ||
|
|
e37afd5bbc | ||
|
|
cf5e827ca7 | ||
|
|
bfa9d039e2 | ||
|
|
62b915fbc1 | ||
|
|
cdfafb3eea | ||
|
|
29cae5c3d2 | ||
|
|
04f97b62c3 | ||
|
|
52d309e7f4 | ||
|
|
dbf5b17f64 | ||
|
|
703fb0df5f | ||
|
|
be4d2de76f | ||
|
|
44c61c2e5d | ||
|
|
764064bc28 | ||
|
|
d87de9fea9 | ||
|
|
b63ba3f316 | ||
|
|
64b1d9266e | ||
|
|
b7cd1fb141 | ||
|
|
30cb2f460c | ||
|
|
6a38b7a32e | ||
|
|
2a1b5236ee | ||
|
|
a99f6fd20e | ||
|
|
c0c9d22864 | ||
|
|
718782cfd5 | ||
|
|
f643430068 | ||
|
|
2823af7ef8 | ||
|
|
57116f50e8 | ||
|
|
e382a09ae8 | ||
|
|
b4c7c9a911 | ||
|
|
86e920f102 | ||
|
|
6f12a4a1db | ||
|
|
a27a3c1fb0 | ||
|
|
63618405bc | ||
|
|
7ab67fdf15 | ||
|
|
17ec004a59 | ||
|
|
417bd7fecb | ||
|
|
e82895351d | ||
|
|
4802310474 | ||
|
|
dc3373be6a | ||
|
|
2e894622d0 | ||
|
|
1895b9e183 | ||
|
|
03f78ceb46 | ||
|
|
872cc62fe4 | ||
|
|
ce7032c8a7 | ||
|
|
71f07af2eb | ||
|
|
89250fb98e | ||
|
|
b09fe79cb7 | ||
|
|
870ed57166 | ||
|
|
2a9e204041 | ||
|
|
00f6aaf058 | ||
|
|
044a4f91aa | ||
|
|
65fe1ec8ed | ||
|
|
779fb76b8b | ||
|
|
4ec439492e | ||
|
|
c4b61c4787 | ||
|
|
934394b17f | ||
|
|
8baa25c445 | ||
|
|
f6166ca9e1 | ||
|
|
e6017854fd | ||
|
|
0bd8b0ab24 | ||
|
|
401d212d85 | ||
|
|
9383929ebb | ||
|
|
5dcab23fdb | ||
|
|
d111be2f70 | ||
|
|
00c171b161 | ||
|
|
53d3f843da | ||
|
|
8d7f312a83 | ||
|
|
6f11818876 | ||
|
|
81bc4e7a0c | ||
|
|
48265a8e54 | ||
|
|
0053105dd3 | ||
|
|
846d829d10 | ||
|
|
c0c3e37568 | ||
|
|
a872190e1b | ||
|
|
147a450be2 | ||
|
|
908ce5b5b9 | ||
|
|
0ec62c107c | ||
|
|
626655d0d0 | ||
|
|
16eeba76fd | ||
|
|
85729116e7 | ||
|
|
2de9636c8b | ||
|
|
bcad5b5646 | ||
|
|
4e40dc9c8c | ||
|
|
e3bcaf6b30 | ||
|
|
a35c85545e | ||
|
|
c4707d6426 | ||
|
|
3564d0af6b | ||
|
|
e7acdb5858 | ||
|
|
8bff64dd13 | ||
|
|
2c4168a014 | ||
|
|
800d04da37 | ||
|
|
b7b2718387 | ||
|
|
a862e59574 | ||
|
|
688d8ba0b2 | ||
|
|
8b7e550a70 | ||
|
|
cf1cb6f270 | ||
|
|
476a6b0188 | ||
|
|
01e55f970d | ||
|
|
bca73dff2e | ||
|
|
aef3800c08 | ||
|
|
a40c21ca53 | ||
|
|
34b1c7f25b | ||
|
|
7c641508ce | ||
|
|
4163076524 | ||
|
|
009c610033 | ||
|
|
c05df7d6b7 | ||
|
|
b881969bd4 | ||
|
|
5e6af11156 | ||
|
|
c1162c5e88 | ||
|
|
213bdd0c8f | ||
|
|
13401562fb | ||
|
|
4e199931aa | ||
|
|
3eafc799ab | ||
|
|
9b87764afb | ||
|
|
a969e90c98 | ||
|
|
c38c1d9ff6 | ||
|
|
bcacbbdd15 | ||
|
|
fa481dc50e | ||
|
|
710c7931aa | ||
|
|
d3f80603c4 | ||
|
|
fea2d311ae | ||
|
|
5f475fb7a7 | ||
|
|
bd0fab4c8f | ||
|
|
74d3f30c93 | ||
|
|
67c2e06575 | ||
|
|
506ee1e0e4 | ||
|
|
81943dbf42 | ||
|
|
c868ea8795 | ||
|
|
cfde8ac9f0 | ||
|
|
8c1da3a84a | ||
|
|
52143d2472 | ||
|
|
c7f303e410 | ||
|
|
da93b79c78 | ||
|
|
83b8ab7774 | ||
|
|
f6bb69f994 | ||
|
|
a97c40dc47 | ||
|
|
3de0357369 | ||
|
|
8eb8a7af46 | ||
|
|
9773f1da72 | ||
|
|
2ef86344cd | ||
|
|
5e8b6f96bb | ||
|
|
b757cbbed7 | ||
|
|
4e08afb259 | ||
|
|
68069d118f | ||
|
|
74dcccf089 | ||
|
|
b7ae9fb837 | ||
|
|
63d2baf35f | ||
|
|
c02a6a01f4 | ||
|
|
a646955493 | ||
|
|
7a577a7ca2 | ||
|
|
c26ff48b60 | ||
|
|
9ee09c8fda | ||
|
|
d4867fd9a2 | ||
|
|
13e52bfce6 | ||
|
|
5540e4a3e6 | ||
|
|
a18ee93909 | ||
|
|
0641d4250c | ||
|
|
f85a1f5601 | ||
|
|
6bcc303b74 | ||
|
|
8002c71b91 | ||
|
|
31d3232f19 | ||
|
|
4448c2019e | ||
|
|
01a9da3a5b | ||
|
|
d0b0dfb16f | ||
|
|
c5007a316f | ||
|
|
c68e206aae | ||
|
|
2bad3ad09f | ||
|
|
f9e037bd7b | ||
|
|
ccde1c84b7 | ||
|
|
367553eb44 | ||
|
|
576d2ac29d | ||
|
|
e13af65d88 | ||
|
|
294d319ab3 | ||
|
|
7572909b13 | ||
|
|
46b9fe50ef | ||
|
|
1335c14acc | ||
|
|
e47607597c | ||
|
|
b7b2dca2dd | ||
|
|
a14c9f8b3c | ||
|
|
e59d36e892 | ||
|
|
f5bdedee2f | ||
|
|
3f0821eb33 | ||
|
|
31e09c94c7 | ||
|
|
404e5a8a0c | ||
|
|
b7a3778f01 | ||
|
|
24ec336dca | ||
|
|
e324b37942 | ||
|
|
066baa9492 | ||
|
|
08aec9b54c | ||
|
|
10a480aa81 | ||
|
|
9baf3109c9 | ||
|
|
360e6f8f60 | ||
|
|
eadddbf505 | ||
|
|
be03760cb9 | ||
|
|
99758d860d | ||
|
|
8aca34e8b5 | ||
|
|
aaaf7f646d | ||
|
|
51dcb69b81 | ||
|
|
580ddfd9e6 | ||
|
|
9e6dc4b4c2 | ||
|
|
72b9e1f11d | ||
|
|
ad1dbaef27 | ||
|
|
6cdee9b268 | ||
|
|
7f4d82072f | ||
|
|
e365640620 | ||
|
|
27a4014f63 | ||
|
|
cb91acdded | ||
|
|
7714854338 | ||
|
|
5379cdb0d2 | ||
|
|
39ea11bc9b | ||
|
|
bb1a2e477a | ||
|
|
34c1041182 | ||
|
|
b2690f748b | ||
|
|
edbf405c30 | ||
|
|
84c42ccf3e | ||
|
|
5e283e358b | ||
|
|
c9ed3b5a72 | ||
|
|
3dfd65e161 | ||
|
|
040102c835 | ||
|
|
c781c10fe9 | ||
|
|
a91539ec61 | ||
|
|
f79857fabe | ||
|
|
14d8c1e294 | ||
|
|
fd1af3efee | ||
|
|
0c9f0b3dbd | ||
|
|
93d5015c99 | ||
|
|
356d7e6a9d | ||
|
|
2a4b7ed82d | ||
|
|
2ec7e85ace | ||
|
|
174398b4f7 | ||
|
|
b00bf19620 | ||
|
|
744aa1e788 | ||
|
|
cc0f9712d6 | ||
|
|
418221427a | ||
|
|
6c741f944d | ||
|
|
1aadc4647c | ||
|
|
4e378f0c71 | ||
|
|
f8bf58e57c | ||
|
|
271364a939 | ||
|
|
f8f8ee5e13 | ||
|
|
3b35a899e0 | ||
|
|
59d1a12439 | ||
|
|
e100c401e9 | ||
|
|
7b0f8c1c20 | ||
|
|
db2f5f2c0b | ||
|
|
0ed843125a | ||
|
|
14d19257df | ||
|
|
bdf65a7215 | ||
|
|
2540398ab0 | ||
|
|
cd8860f6ac | ||
|
|
2cd18376f2 | ||
|
|
0694950bba | ||
|
|
c6b15eaca1 | ||
|
|
647807a07c | ||
|
|
094fd05df7 | ||
|
|
1ea9d8e60f | ||
|
|
63998b50d6 | ||
|
|
0f029a21f7 | ||
|
|
7328f5c57f | ||
|
|
12d608879d | ||
|
|
9ceb71ae2f | ||
|
|
e6c82412bf | ||
|
|
5e8e46ff09 | ||
|
|
957400cb82 | ||
|
|
e687a2b3e5 | ||
|
|
7a297a6f6c | ||
|
|
432f4a735f | ||
|
|
a4f79d236d | ||
|
|
a4771d5d29 | ||
|
|
227de4ffc4 | ||
|
|
42fe650ae6 | ||
|
|
026730c077 | ||
|
|
35d1157b0c | ||
|
|
06dc92fdaa | ||
|
|
c9cac3539c | ||
|
|
d2276eba1d | ||
|
|
1c2c3fe402 | ||
|
|
d4b90b5fa4 | ||
|
|
383ddde7b5 | ||
|
|
e56636ca1f | ||
|
|
0b6a66b12d | ||
|
|
e0be2fd222 | ||
|
|
9e23e3e608 | ||
|
|
47ecc2be27 | ||
|
|
3ca0ad353e | ||
|
|
5447c89da4 | ||
|
|
c88757c869 | ||
|
|
8831729470 | ||
|
|
b3bb5a2337 | ||
|
|
b7ddee1db2 | ||
|
|
d85b9db535 | ||
|
|
d03d790327 | ||
|
|
79b159f948 | ||
|
|
3a0a9e1e99 | ||
|
|
cc2d0bf1b0 | ||
|
|
03a199983b | ||
|
|
a07e5a7312 | ||
|
|
fe5833b061 | ||
|
|
0c2200f49f | ||
|
|
cb73196503 | ||
|
|
c5fe405389 | ||
|
|
fddc3ff2f3 | ||
|
|
9dbe876d65 | ||
|
|
fd402bbd32 | ||
|
|
f2aa377cfe | ||
|
|
0f0223725e | ||
|
|
3ef29e43f5 | ||
|
|
60a7835040 | ||
|
|
1c645fcba2 | ||
|
|
938a5e9c7c | ||
|
|
cc655fed6c | ||
|
|
7f9692a3d9 | ||
|
|
cf90cae4eb | ||
|
|
fea8e0672e | ||
|
|
359748841f | ||
|
|
438778a03c | ||
|
|
c37bb2691b | ||
|
|
6c49409de8 | ||
|
|
2a335026de | ||
|
|
7712e5c71d | ||
|
|
861d97222a | ||
|
|
de862f635c | ||
|
|
ae058422aa | ||
|
|
44454d1e05 | ||
|
|
a2b9ba17a7 | ||
|
|
6f61fe1564 | ||
|
|
73d7ddc4af | ||
|
|
263f4afa82 | ||
|
|
45cf2dc279 | ||
|
|
786a425d85 | ||
|
|
d57223bd01 | ||
|
|
fbc2cff3f1 | ||
|
|
9ad4f077d1 | ||
|
|
e2b6061310 | ||
|
|
b77e97a9d2 | ||
|
|
67925c8a9e | ||
|
|
020ecff29c | ||
|
|
964660ed5d | ||
|
|
1390af62ab | ||
|
|
15947f942c | ||
|
|
7b3c3d15db | ||
|
|
1cff6fe242 | ||
|
|
4cbd045502 | ||
|
|
21b612eaa4 | ||
|
|
ef18e304a1 | ||
|
|
8e4223a9e7 | ||
|
|
7d696735ba | ||
|
|
e0ecc5be05 | ||
|
|
77af0b3495 | ||
|
|
e2e38284a7 | ||
|
|
ccd2560451 | ||
|
|
390658f2b0 | ||
|
|
450a4e9fe3 | ||
|
|
dfbbf0456d | ||
|
|
d46f92edb2 | ||
|
|
26c4368f31 | ||
|
|
ec56a5426b | ||
|
|
fe32584ff9 | ||
|
|
db7762c6e2 | ||
|
|
e70e26f84c | ||
|
|
7dc9d568d1 | ||
|
|
0049ab272b | ||
|
|
f48885bba6 | ||
|
|
5eaa0ac269 | ||
|
|
f7af21878e | ||
|
|
9d4071d4cd | ||
|
|
6f5dd86cd1 | ||
|
|
8b9537b272 | ||
|
|
a526e76c70 | ||
|
|
62b2f477f4 | ||
|
|
f36384fdb4 | ||
|
|
9c8d7988c5 | ||
|
|
18f163768c | ||
|
|
72083439af | ||
|
|
523149327b | ||
|
|
58c18133ec | ||
|
|
03520b650b | ||
|
|
556884058b | ||
|
|
73b0d5d41d | ||
|
|
7c589327f7 | ||
|
|
5c8867555d | ||
|
|
36be5267a2 | ||
|
|
4ebfd49cb9 | ||
|
|
96fe83de14 | ||
|
|
1746db3752 | ||
|
|
58b4883236 | ||
|
|
a3864eb7d3 | ||
|
|
1f0e5f4a08 | ||
|
|
c90234cefc | ||
|
|
f354a4f4fe | ||
|
|
7e0c071eee | ||
|
|
9bed726062 | ||
|
|
3878d4761e | ||
|
|
81f5af5629 | ||
|
|
5f76e430af | ||
|
|
facac33a89 | ||
|
|
f36c63f1b2 | ||
|
|
b1f07b877c | ||
|
|
70611305a7 | ||
|
|
fdedc2c5d3 | ||
|
|
75875b49e6 | ||
|
|
37e52886b5 | ||
|
|
a5dfe69220 | ||
|
|
1c36c7f1e1 | ||
|
|
9de39485de | ||
|
|
0fe2e0d393 | ||
|
|
dbb5e131fc | ||
|
|
ebda1e1717 | ||
|
|
8cbec131fe | ||
|
|
472d4a3331 | ||
|
|
c2f83d996a | ||
|
|
43bd6b24c5 | ||
|
|
ca89261e10 | ||
|
|
a9bbbe8b52 | ||
|
|
fa544bf4e8 | ||
|
|
7e91a989b3 | ||
|
|
c312260721 | ||
|
|
23f2bace5d | ||
|
|
7e2f1fcf9d | ||
|
|
6e420a8a82 | ||
|
|
cd81547022 | ||
|
|
a2baedb80c | ||
|
|
8072cefbe6 | ||
|
|
6bf666d01c | ||
|
|
7672e29063 | ||
|
|
51e7c535df | ||
|
|
d0f89cfe01 | ||
|
|
8de60aeb32 | ||
|
|
0e28473c31 | ||
|
|
52d4b831ae | ||
|
|
cdc8cfe46e | ||
|
|
4c7e8f56d8 | ||
|
|
4753b85ab5 | ||
|
|
13c8459d4b | ||
|
|
19b3bbe139 | ||
|
|
44a89c6645 | ||
|
|
4a51bd7dfa | ||
|
|
dc759a368e | ||
|
|
c28f7bb024 | ||
|
|
d412c1616f | ||
|
|
c2a807efc7 | ||
|
|
6056735c72 | ||
|
|
261ba74105 | ||
|
|
4328a1d72d | ||
|
|
82643b51d3 | ||
|
|
a38e5e2f0a | ||
|
|
19624e97bd | ||
|
|
536c1dfab3 | ||
|
|
c2acb39859 | ||
|
|
dd2ddc0e5b | ||
|
|
40f095191a | ||
|
|
37baa11987 | ||
|
|
3ecc2b7982 | ||
|
|
ba3588e97d | ||
|
|
bd6892dcf1 | ||
|
|
dc13a4a7b7 | ||
|
|
37c889a39a | ||
|
|
c5ece3a5f8 | ||
|
|
a20b980adb | ||
|
|
6e31a05f21 | ||
|
|
0aefbb85ec | ||
|
|
15f8d25bc9 | ||
|
|
c0269fca45 | ||
|
|
bdb0ffde95 | ||
|
|
8515eaf4ee | ||
|
|
3bb27a692f | ||
|
|
3528eb227e | ||
|
|
729204a095 | ||
|
|
cf5a9c9780 | ||
|
|
8b872020c6 | ||
|
|
e16a9873be | ||
|
|
faced0b20c | ||
|
|
7e576738ce | ||
|
|
e10aebf4c0 | ||
|
|
9f9e36f0cd | ||
|
|
913ed54cf9 | ||
|
|
960c5b8c6f | ||
|
|
57f2135848 | ||
|
|
171f328278 | ||
|
|
ffe534edd9 | ||
|
|
fb15668288 | ||
|
|
b00d155aa1 | ||
|
|
9b852bd6be | ||
|
|
550cdba5a7 | ||
|
|
aaa7d6deb3 | ||
|
|
e2567e128e | ||
|
|
5c11087cec | ||
|
|
635c92791c | ||
|
|
e8b44ee10e | ||
|
|
932a2e4081 | ||
|
|
11777b1bea | ||
|
|
69425d0b93 | ||
|
|
1895ffb5c3 | ||
|
|
f51dc450b9 | ||
|
|
18e12db7c5 | ||
|
|
ebb6bb2a1a | ||
|
|
348a020e7f | ||
|
|
ca96b37303 | ||
|
|
1a255b5a4d | ||
|
|
320aedefb1 | ||
|
|
da135d3e6f | ||
|
|
4c95d85cf9 | ||
|
|
1d27da71ec | ||
|
|
a84edcd237 | ||
|
|
25ce3bdf8f | ||
|
|
634a396434 | ||
|
|
1aa4f0ddfd | ||
|
|
0c9a49a9c3 | ||
|
|
cb3790dc1d | ||
|
|
d4c4546c88 | ||
|
|
ebe4c41f76 | ||
|
|
5e1b9ce2c7 | ||
|
|
2d095316a7 | ||
|
|
73d3922f18 | ||
|
|
925250d2c5 | ||
|
|
29914d3e89 | ||
|
|
1ccb9555b6 | ||
|
|
07e73b0d88 | ||
|
|
9239068cde | ||
|
|
0f0b7748d7 | ||
|
|
551faadd28 | ||
|
|
b58957e38e | ||
|
|
782976c14f | ||
|
|
2a68d37de8 | ||
|
|
a47ee28ca5 | ||
|
|
169ae2c959 | ||
|
|
a568950aa9 | ||
|
|
f9cd477114 | ||
|
|
75fb6ab197 | ||
|
|
7af607d476 | ||
|
|
41040f92c3 | ||
|
|
9dbf43cf22 | ||
|
|
4873832437 | ||
|
|
a362dc5a11 | ||
|
|
f3fea7fc66 | ||
|
|
64e7fcbcc7 | ||
|
|
733138f2be | ||
|
|
b0a11a5f8d | ||
|
|
8fb1d8e886 | ||
|
|
3491efb494 | ||
|
|
5564b4c181 | ||
|
|
7d4d228f0d | ||
|
|
8b7e7cf0ad | ||
|
|
cb5434d166 | ||
|
|
8b51e50f15 | ||
|
|
6dda49a917 | ||
|
|
7a957e4d78 | ||
|
|
a9ceecdc84 | ||
|
|
d46d0ab42f | ||
|
|
1bac6eb110 | ||
|
|
1e4316a57e | ||
|
|
9a45f53062 | ||
|
|
0ca2649040 | ||
|
|
395afbb4ee | ||
|
|
7cdff84d5e | ||
|
|
f5de8f5e10 | ||
|
|
4d364bd597 | ||
|
|
3e010572f6 | ||
|
|
68fb5e5bc7 | ||
|
|
efb341355d | ||
|
|
161d5236b4 | ||
|
|
91495d6a34 | ||
|
|
05ca96e476 | ||
|
|
dc8682e1c3 | ||
|
|
3a51185942 | ||
|
|
39a2813bde | ||
|
|
27c6eff590 | ||
|
|
8dcfb8a670 | ||
|
|
8cbf56b81a | ||
|
|
27ff2bd158 | ||
|
|
dadaa831ba | ||
|
|
27956d311c | ||
|
|
cca94db9bf | ||
|
|
42a0821677 | ||
|
|
9de62cc55f | ||
|
|
af70d36b7c | ||
|
|
b1461d1c04 | ||
|
|
f91cd0ca63 | ||
|
|
5211e92c65 | ||
|
|
af994cfadb | ||
|
|
caddc57ef8 | ||
|
|
578f42d9b1 | ||
|
|
1a0ec780b9 | ||
|
|
10b2f09c7f | ||
|
|
5263cc260d | ||
|
|
0013d86ef8 | ||
|
|
41d6303403 | ||
|
|
f2323b35b8 | ||
|
|
b3b804fc11 | ||
|
|
7dd96bf259 | ||
|
|
c2fb398707 | ||
|
|
f4b0d6822d | ||
|
|
511c30d635 | ||
|
|
084d19600a | ||
|
|
b75a0be52c | ||
|
|
c23c07ae38 | ||
|
|
17ff85b62b | ||
|
|
84a42d0e14 | ||
|
|
437c405c74 | ||
|
|
b4d856d32f | ||
|
|
3c711e0279 | ||
|
|
c879d5d8de | ||
|
|
82d2c548ef | ||
|
|
cdb4f329cf | ||
|
|
c91264e455 | ||
|
|
14a719b8b5 | ||
|
|
78c5b7027e | ||
|
|
cd71cf4833 | ||
|
|
93a5bcf40f | ||
|
|
dd0acbea61 | ||
|
|
ef736bc63e | ||
|
|
d9ca0e84a6 | ||
|
|
db54d58bab | ||
|
|
5099721b9b | ||
|
|
2c2fbffd8c | ||
|
|
3fee4292f1 | ||
|
|
7e9e28f134 | ||
|
|
d879f4afab | ||
|
|
d38ca76182 | ||
|
|
77692d270e | ||
|
|
f5c3abb310 | ||
|
|
02260d496c | ||
|
|
581adbb56e | ||
|
|
6ade34d243 | ||
|
|
16ea0b497e | ||
|
|
ea41875e36 | ||
|
|
eae0a4ae4e | ||
|
|
fea788bdc4 | ||
|
|
86c69e5993 | ||
|
|
f01794fed8 | ||
|
|
cc4b38fbbd | ||
|
|
121ac8ba4d | ||
|
|
2c10a203a5 | ||
|
|
6a2fab4f88 | ||
|
|
9637cb6477 | ||
|
|
ce90de1b74 | ||
|
|
49e24865a3 | ||
|
|
dceff807e9 | ||
|
|
3c4dba69db | ||
|
|
3fac92b54d | ||
|
|
139f527fdd |
7
.dockerignore
Normal file
@@ -0,0 +1,7 @@
|
||||
Dockerfile
|
||||
.dockerignore
|
||||
node_modules
|
||||
npm-debug.log
|
||||
README.md
|
||||
.next
|
||||
.git
|
||||
8
.gitignore
vendored
@@ -1,3 +1,5 @@
|
||||
src/constants/test_firebase.json
|
||||
|
||||
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||
|
||||
# dependencies
|
||||
@@ -36,4 +38,8 @@ yarn-error.log*
|
||||
next-env.d.ts
|
||||
|
||||
.env
|
||||
.yarn/*
|
||||
.yarn/*
|
||||
.history*
|
||||
__ENV.js
|
||||
|
||||
settings.json
|
||||
4
.husky/pre-commit
Executable file
@@ -0,0 +1,4 @@
|
||||
#!/usr/bin/env sh
|
||||
. "$(dirname -- "$0")/_/husky.sh"
|
||||
|
||||
yarn build
|
||||
28
.vscode/launch.json
vendored
Normal file
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"version": "0.2.0",
|
||||
"configurations": [
|
||||
{
|
||||
"name": "Next.js: debug server-side",
|
||||
"type": "node-terminal",
|
||||
"request": "launch",
|
||||
"command": "npm run dev"
|
||||
},
|
||||
{
|
||||
"name": "Next.js: debug client-side",
|
||||
"type": "chrome",
|
||||
"request": "launch",
|
||||
"url": "http://localhost:3000"
|
||||
},
|
||||
{
|
||||
"name": "Next.js: debug full stack",
|
||||
"type": "node-terminal",
|
||||
"request": "launch",
|
||||
"command": "npm run dev",
|
||||
"serverReadyAction": {
|
||||
"pattern": "- Local:.+(https?://.+)",
|
||||
"uriFormat": "%s",
|
||||
"action": "debugWithChrome"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
59
Dockerfile
Normal file
@@ -0,0 +1,59 @@
|
||||
#syntax=docker/dockerfile:1.4
|
||||
FROM node:18-alpine AS base
|
||||
|
||||
# Install dependencies only when needed
|
||||
FROM base AS deps
|
||||
# Check https://github.com/nodejs/docker-node/tree/b4117f9333da4138b03a546ec926ef50a31506c3#nodealpine to understand why libc6-compat might be needed.
|
||||
RUN apk add --no-cache libc6-compat
|
||||
WORKDIR /app
|
||||
|
||||
# Install dependencies based on the preferred package manager
|
||||
COPY package.json yarn.lock* ./
|
||||
RUN yarn --frozen-lockfile
|
||||
|
||||
|
||||
# Rebuild the source code only when needed
|
||||
FROM base AS builder
|
||||
WORKDIR /app
|
||||
COPY --from=deps /app/node_modules ./node_modules
|
||||
COPY . .
|
||||
|
||||
# Next.js collects completely anonymous telemetry data about general usage.
|
||||
# Learn more here: https://nextjs.org/telemetry
|
||||
# Uncomment the following line in case you want to disable telemetry during the build.
|
||||
# ENV NEXT_TELEMETRY_DISABLED 1
|
||||
|
||||
ENV MONGODB_URI "mongodb+srv://user:JKpFBymv0WLv3STj@encoach.lz18a.mongodb.net/?retryWrites=true&w=majority&appName=EnCoach"
|
||||
|
||||
RUN yarn build
|
||||
|
||||
# If using npm comment out above and use below instead
|
||||
# RUN npm run build
|
||||
|
||||
# Production image, copy all the files and run next
|
||||
FROM base AS runner
|
||||
WORKDIR /app
|
||||
|
||||
ENV NODE_ENV production
|
||||
# Uncomment the following line in case you want to disable telemetry during runtime.
|
||||
ENV NEXT_TELEMETRY_DISABLED 1
|
||||
|
||||
RUN \
|
||||
addgroup --system --gid 1001 nodejs; \
|
||||
adduser --system --uid 1001 nextjs
|
||||
|
||||
COPY --from=builder /app/public ./public
|
||||
|
||||
# Automatically leverage output traces to reduce image size
|
||||
# https://nextjs.org/docs/advanced-features/output-file-tracing
|
||||
COPY --from=builder --chown=1001:1001 /app/.next/standalone ./
|
||||
COPY --from=builder --chown=1001:1001 /app/.next/static ./.next/static
|
||||
|
||||
USER nextjs
|
||||
|
||||
EXPOSE 3000
|
||||
|
||||
ENV PORT 3000
|
||||
ENV HOSTNAME localhost
|
||||
|
||||
CMD HOSTNAME="0.0.0.0" node server.js
|
||||
17
components.json
Normal file
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"$schema": "https://ui.shadcn.com/schema.json",
|
||||
"style": "new-york",
|
||||
"rsc": false,
|
||||
"tsx": true,
|
||||
"tailwind": {
|
||||
"config": "tailwind.config.ts",
|
||||
"css": "src/styles/globals.css",
|
||||
"baseColor": "neutral",
|
||||
"cssVariables": false,
|
||||
"prefix": ""
|
||||
},
|
||||
"aliases": {
|
||||
"components": "@/components",
|
||||
"utils": "@/lib/utils"
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,57 @@
|
||||
/** @type {import('next').NextConfig} */
|
||||
const websiteUrl = process.env.NODE_ENV === 'production' ? "https://encoach.com" : "http://localhost:3000";
|
||||
const nextConfig = {
|
||||
reactStrictMode: true,
|
||||
}
|
||||
reactStrictMode: false,
|
||||
output: "standalone",
|
||||
async headers() {
|
||||
return [
|
||||
{
|
||||
source: "/api/packages",
|
||||
headers: [
|
||||
{key: "Access-Control-Allow-Credentials", value: "false"},
|
||||
{key: "Access-Control-Allow-Origin", value: websiteUrl},
|
||||
{
|
||||
key: "Access-Control-Allow-Methods",
|
||||
value: "GET",
|
||||
},
|
||||
{
|
||||
key: "Access-Control-Allow-Headers",
|
||||
value: "Accept, Accept-Version, Content-Length, Content-MD5, Content-Type, Date",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
source: "/api/tickets",
|
||||
headers: [
|
||||
{key: "Access-Control-Allow-Credentials", value: "false"},
|
||||
{key: "Access-Control-Allow-Origin", value: websiteUrl},
|
||||
{
|
||||
key: "Access-Control-Allow-Methods",
|
||||
value: "POST,OPTIONS",
|
||||
},
|
||||
{
|
||||
key: "Access-Control-Allow-Headers",
|
||||
value: "Accept, Accept-Version, Content-Length, Content-MD5, Content-Type, Date",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
source: "/api/users/agents",
|
||||
headers: [
|
||||
{key: "Access-Control-Allow-Credentials", value: "false"},
|
||||
{key: "Access-Control-Allow-Origin", value: websiteUrl},
|
||||
{
|
||||
key: "Access-Control-Allow-Methods",
|
||||
value: "POST,OPTIONS",
|
||||
},
|
||||
{
|
||||
key: "Access-Control-Allow-Headers",
|
||||
value: "Accept, Accept-Version, Content-Length, Content-MD5, Content-Type, Date",
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
},
|
||||
};
|
||||
|
||||
module.exports = nextConfig
|
||||
module.exports = nextConfig;
|
||||
|
||||
10089
package-lock.json
generated
174
package.json
@@ -1,59 +1,119 @@
|
||||
{
|
||||
"name": "next-wind",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"lint": "next lint"
|
||||
},
|
||||
"dependencies": {
|
||||
"@headlessui/react": "^1.7.13",
|
||||
"@mdi/js": "^7.1.96",
|
||||
"@mdi/react": "^1.6.1",
|
||||
"@next/font": "13.1.6",
|
||||
"@types/node": "18.13.0",
|
||||
"@types/react": "18.0.27",
|
||||
"@types/react-dom": "18.0.10",
|
||||
"axios": "^1.3.5",
|
||||
"chart.js": "^4.2.1",
|
||||
"clsx": "^1.2.1",
|
||||
"daisyui": "^3.1.5",
|
||||
"eslint": "8.33.0",
|
||||
"eslint-config-next": "13.1.6",
|
||||
"firebase": "9.19.1",
|
||||
"framer-motion": "^9.0.2",
|
||||
"iron-session": "^6.3.1",
|
||||
"lodash": "^4.17.21",
|
||||
"moment": "^2.29.4",
|
||||
"next": "13.1.6",
|
||||
"primeicons": "^6.0.1",
|
||||
"primereact": "^9.2.3",
|
||||
"react": "18.2.0",
|
||||
"react-chartjs-2": "^5.2.0",
|
||||
"react-dom": "18.2.0",
|
||||
"react-firebase-hooks": "^5.1.1",
|
||||
"react-icons": "^4.8.0",
|
||||
"react-lineto": "^3.3.0",
|
||||
"react-media-recorder": "1.6.5",
|
||||
"react-player": "^2.12.0",
|
||||
"react-string-replace": "^1.1.0",
|
||||
"react-toastify": "^9.1.2",
|
||||
"react-xarrows": "^2.0.2",
|
||||
"swr": "^2.1.3",
|
||||
"typescript": "4.9.5",
|
||||
"uuid": "^9.0.0",
|
||||
"wavesurfer.js": "^6.6.4",
|
||||
"zustand": "^4.3.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/lodash": "^4.14.191",
|
||||
"@types/uuid": "^9.0.1",
|
||||
"@types/wavesurfer.js": "^6.0.6",
|
||||
"@wixc3/react-board": "^2.2.0",
|
||||
"autoprefixer": "^10.4.13",
|
||||
"postcss": "^8.4.21",
|
||||
"tailwindcss": "^3.2.4"
|
||||
}
|
||||
"name": "next-wind",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"lint": "next lint",
|
||||
"prepare": "husky install"
|
||||
},
|
||||
"dependencies": {
|
||||
"@beam-australia/react-env": "^3.1.1",
|
||||
"@dnd-kit/core": "^6.1.0",
|
||||
"@dnd-kit/modifiers": "^7.0.0",
|
||||
"@dnd-kit/sortable": "^8.0.0",
|
||||
"@firebase/util": "^1.9.7",
|
||||
"@headlessui/react": "^2.1.2",
|
||||
"@mdi/js": "^7.1.96",
|
||||
"@mdi/react": "^1.6.1",
|
||||
"@paypal/paypal-js": "^7.1.0",
|
||||
"@paypal/react-paypal-js": "^8.1.3",
|
||||
"@radix-ui/react-icons": "^1.3.0",
|
||||
"@radix-ui/react-popover": "^1.1.1",
|
||||
"@react-pdf/renderer": "^3.1.14",
|
||||
"@react-spring/web": "^9.7.4",
|
||||
"@tanstack/react-table": "^8.10.1",
|
||||
"@types/node": "18.13.0",
|
||||
"@types/react": "^18.3.3",
|
||||
"@types/react-dom": "^18.3.0",
|
||||
"@use-gesture/react": "^10.3.1",
|
||||
"axios": "^1",
|
||||
"axios-cache-interceptor": "^1",
|
||||
"bcrypt": "^5.1.1",
|
||||
"chart.js": "^4.2.1",
|
||||
"class-variance-authority": "^0.7.0",
|
||||
"clsx": "^2.1.1",
|
||||
"countries-list": "^3.0.1",
|
||||
"country-codes-list": "^1.6.11",
|
||||
"currency-symbol-map": "^5.1.0",
|
||||
"daisyui": "^3.1.5",
|
||||
"deep-diff": "^1.0.2",
|
||||
"eslint": "8.33.0",
|
||||
"eslint-config-next": "13.1.6",
|
||||
"exceljs": "^4.4.0",
|
||||
"express-handlebars": "^7.1.2",
|
||||
"firebase": "9.19.1",
|
||||
"firebase-admin": "^11.10.1",
|
||||
"firebase-scrypt": "^2.2.0",
|
||||
"formidable": "^3.5.0",
|
||||
"formidable-serverless": "^1.1.1",
|
||||
"framer-motion": "^9.0.2",
|
||||
"howler": "^2.2.4",
|
||||
"immer": "^10.1.1",
|
||||
"iron-session": "^6.3.1",
|
||||
"lodash": "^4.17.21",
|
||||
"moment": "^2.29.4",
|
||||
"moment-timezone": "^0.5.44",
|
||||
"mongodb": "^6.8.1",
|
||||
"next": "^14.2.5",
|
||||
"nodemailer": "^6.9.5",
|
||||
"nodemailer-express-handlebars": "^6.1.0",
|
||||
"primeicons": "^6.0.1",
|
||||
"primereact": "^9.2.3",
|
||||
"qrcode": "^1.5.3",
|
||||
"random-words": "^2.0.0",
|
||||
"react": "18.2.0",
|
||||
"react-chartjs-2": "^5.2.0",
|
||||
"react-csv": "^2.2.2",
|
||||
"react-currency-input-field": "^3.6.12",
|
||||
"react-datepicker": "^4.18.0",
|
||||
"react-diff-viewer": "^3.1.1",
|
||||
"react-dom": "18.2.0",
|
||||
"react-firebase-hooks": "^5.1.1",
|
||||
"react-icons": "^5.3.0",
|
||||
"react-lineto": "^3.3.0",
|
||||
"react-media-recorder": "1.6.5",
|
||||
"react-phone-number-input": "^3.3.6",
|
||||
"react-player": "^2.12.0",
|
||||
"react-select": "^5.7.5",
|
||||
"react-string-replace": "^1.1.0",
|
||||
"react-toastify": "^9.1.2",
|
||||
"react-tooltip": "^5.27.1",
|
||||
"react-xarrows": "^2.0.2",
|
||||
"read-excel-file": "^5.7.1",
|
||||
"short-unique-id": "5.0.2",
|
||||
"stripe": "^13.10.0",
|
||||
"swr": "^2.2.5",
|
||||
"tailwind-merge": "^2.5.2",
|
||||
"tailwind-scrollbar-hide": "^1.1.7",
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
"typescript": "4.9.5",
|
||||
"use-file-picker": "^2.1.0",
|
||||
"uuid": "^9.0.0",
|
||||
"wavesurfer.js": "^6.6.4",
|
||||
"zustand": "^4.3.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@simbathesailor/use-what-changed": "^2.0.0",
|
||||
"@types/blob-stream": "^0.1.33",
|
||||
"@types/deep-diff": "^1.0.5",
|
||||
"@types/formidable": "^3.4.0",
|
||||
"@types/howler": "^2.2.11",
|
||||
"@types/lodash": "^4.14.191",
|
||||
"@types/nodemailer": "^6.4.11",
|
||||
"@types/nodemailer-express-handlebars": "^4.0.3",
|
||||
"@types/qrcode": "^1.5.5",
|
||||
"@types/react-csv": "^1.1.10",
|
||||
"@types/react-datepicker": "^4.15.1",
|
||||
"@types/uuid": "^9.0.1",
|
||||
"@types/wavesurfer.js": "^6.0.6",
|
||||
"@wixc3/react-board": "^2.2.0",
|
||||
"autoprefixer": "^10.4.13",
|
||||
"husky": "^8.0.3",
|
||||
"postcss": "^8.4.21",
|
||||
"tailwindcss": "^3.2.4"
|
||||
},
|
||||
"packageManager": "yarn@1.22.22+sha512.a6b2f7906b721bba3d67d4aff083df04dad64c399707841b7acf00f6b133b7ac24255f2652fa22ae3534329dc6180534e98d17432037ff6fd140556e2bb3137e"
|
||||
}
|
||||
|
||||
BIN
public/audio/check.mp3
Normal file
BIN
public/audio/error.mp3
Normal file
BIN
public/audio/sent.mp3
Normal file
BIN
public/blue-stock-photo.png
Normal file
|
After Width: | Height: | Size: 419 KiB |
BIN
public/defaultAvatar.png
Normal file
|
After Width: | Height: | Size: 48 KiB |
|
Before Width: | Height: | Size: 13 KiB After Width: | Height: | Size: 5.5 KiB |
BIN
public/logo.png
Normal file
|
After Width: | Height: | Size: 6.4 KiB |
BIN
public/logo_title.png
Normal file
|
After Width: | Height: | Size: 48 KiB |
BIN
public/manuals/corporate.pdf
Normal file
BIN
public/manuals/student.pdf
Normal file
BIN
public/manuals/teacher.pdf
Normal file
1
public/mat-icon-info.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 -960 960 960" width="24px" fill="#e8eaed"><path d="M440-280h80v-240h-80v240Zm40-320q17 0 28.5-11.5T520-640q0-17-11.5-28.5T480-680q-17 0-28.5 11.5T440-640q0 17 11.5 28.5T480-600Zm0 520q-83 0-156-31.5T197-197q-54-54-85.5-127T80-480q0-83 31.5-156T197-763q54-54 127-85.5T480-880q83 0 156 31.5T763-763q54 54 85.5 127T880-480q0 83-31.5 156T763-197q-54 54-127 85.5T480-80Zm0-80q134 0 227-93t93-227q0-134-93-227t-227-93q-134 0-227 93t-93 227q0 134 93 227t227 93Zm0-320Z"/></svg>
|
||||
|
After Width: | Height: | Size: 535 B |
BIN
public/microsoft-word-icon.png
Normal file
|
After Width: | Height: | Size: 16 KiB |
BIN
public/orange-stock-photo.jpg
Normal file
|
After Width: | Height: | Size: 3.0 MiB |
BIN
public/purple-stock-photo.png
Normal file
|
After Width: | Height: | Size: 418 KiB |
BIN
public/radial_progress/azul_0.png
Normal file
|
After Width: | Height: | Size: 2.7 KiB |
BIN
public/radial_progress/azul_10.png
Normal file
|
After Width: | Height: | Size: 3.1 KiB |
BIN
public/radial_progress/azul_100.png
Normal file
|
After Width: | Height: | Size: 2.8 KiB |
BIN
public/radial_progress/azul_20.png
Normal file
|
After Width: | Height: | Size: 3.2 KiB |
BIN
public/radial_progress/azul_30.png
Normal file
|
After Width: | Height: | Size: 3.3 KiB |
BIN
public/radial_progress/azul_40.png
Normal file
|
After Width: | Height: | Size: 3.5 KiB |
BIN
public/radial_progress/azul_50.png
Normal file
|
After Width: | Height: | Size: 3.4 KiB |
BIN
public/radial_progress/azul_60.png
Normal file
|
After Width: | Height: | Size: 3.5 KiB |
BIN
public/radial_progress/azul_70.png
Normal file
|
After Width: | Height: | Size: 4.0 KiB |
BIN
public/radial_progress/azul_80.png
Normal file
|
After Width: | Height: | Size: 3.9 KiB |
BIN
public/radial_progress/azul_90.png
Normal file
|
After Width: | Height: | Size: 4.4 KiB |
BIN
public/radial_progress/laranja_0.png
Normal file
|
After Width: | Height: | Size: 2.9 KiB |
BIN
public/radial_progress/laranja_10.png
Normal file
|
After Width: | Height: | Size: 3.2 KiB |
BIN
public/radial_progress/laranja_100.png
Normal file
|
After Width: | Height: | Size: 2.9 KiB |
BIN
public/radial_progress/laranja_20.png
Normal file
|
After Width: | Height: | Size: 3.3 KiB |
BIN
public/radial_progress/laranja_30.png
Normal file
|
After Width: | Height: | Size: 3.4 KiB |
BIN
public/radial_progress/laranja_40.png
Normal file
|
After Width: | Height: | Size: 3.4 KiB |
BIN
public/radial_progress/laranja_50.png
Normal file
|
After Width: | Height: | Size: 3.4 KiB |
BIN
public/radial_progress/laranja_60.png
Normal file
|
After Width: | Height: | Size: 3.6 KiB |
BIN
public/radial_progress/laranja_70.png
Normal file
|
After Width: | Height: | Size: 4.0 KiB |
BIN
public/radial_progress/laranja_80.png
Normal file
|
After Width: | Height: | Size: 3.8 KiB |
BIN
public/radial_progress/laranja_90.png
Normal file
|
After Width: | Height: | Size: 4.2 KiB |
BIN
public/red-stock-photo.jpg
Normal file
|
After Width: | Height: | Size: 7.3 MiB |
51
scripts/updatePrivateFieldExams.js
Normal file
@@ -0,0 +1,51 @@
|
||||
import dotenv from "dotenv";
|
||||
dotenv.config();
|
||||
import { MongoClient } from "mongodb";
|
||||
const uri = process.env.MONGODB_URI || "";
|
||||
const options = {
|
||||
maxPoolSize: 10,
|
||||
};
|
||||
const dbName = process.env.MONGODB_DB; // change this to prod db when needed
|
||||
async function migrateData() {
|
||||
const MODULE_ARRAY = ["reading", "listening", "writing", "speaking", "level"];
|
||||
const client = new MongoClient(uri, options);
|
||||
|
||||
try {
|
||||
await client.connect();
|
||||
console.log("Connected to MongoDB");
|
||||
if (!process.env.MONGODB_DB) {
|
||||
throw new Error("Missing env var: MONGODB_DB");
|
||||
}
|
||||
const db = client.db(dbName);
|
||||
for (const string of MODULE_ARRAY) {
|
||||
const collection = db.collection(string);
|
||||
const result = await collection.updateMany(
|
||||
{ private: { $exists: false } },
|
||||
{ $set: { access: "public" } }
|
||||
);
|
||||
const result2 = await collection.updateMany(
|
||||
{ private: true },
|
||||
{ $set: { access: "private" }, $unset: { private: "" } }
|
||||
);
|
||||
const result1 = await collection.updateMany(
|
||||
{ private: { $exists: true } },
|
||||
{ $set: { access: "public" } }
|
||||
);
|
||||
console.log(
|
||||
`Updated ${
|
||||
result.modifiedCount + result1.modifiedCount
|
||||
} documents to "access: public" in ${string}`
|
||||
);
|
||||
console.log(
|
||||
`Updated ${result2.modifiedCount} documents to "access: private" and removed private var in ${string}`
|
||||
);
|
||||
}
|
||||
console.log("Migration completed successfully!");
|
||||
} catch (error) {
|
||||
console.error("Migration failed:", error);
|
||||
} finally {
|
||||
await client.close();
|
||||
console.log("MongoDB connection closed.");
|
||||
}
|
||||
}
|
||||
//migrateData(); // uncomment to run the migration
|
||||
193
src/components/AIDetection.tsx
Normal file
@@ -0,0 +1,193 @@
|
||||
import Image from "next/image";
|
||||
import clsx from "clsx";
|
||||
import RadialProgressBar from "./RadialProgressBar";
|
||||
import { AIDetectionAttributes } from "@/interfaces/exam";
|
||||
import { Tooltip } from 'react-tooltip';
|
||||
import SegmentedProgressBar from "./SegmentedProgressBar";
|
||||
|
||||
|
||||
// Colors and texts scrapped from gpt's zero react bundle
|
||||
const AIDetection: React.FC<AIDetectionAttributes> = ({ predicted_class, confidence_category, class_probabilities, sentences }) => {
|
||||
const probabilityTooltipContent = `
|
||||
Encoach's deep learning model predicts the <br/>
|
||||
probability this text has been entirely <br/>
|
||||
generated by AI. For instance, a 40% AI <br/>
|
||||
probability does not indicate that the text<br/>
|
||||
contains 40% AI-written content. Rather, it<br/>
|
||||
indicates the text is more likely to be partially<br/>
|
||||
human written than be entirely AI-written.
|
||||
`;
|
||||
const confidenceTooltipContent = `
|
||||
Confidence scores are a safeguard to better<br/>
|
||||
understand AI identification results. Encoach<br/>
|
||||
trained it's deep learning model on a diverse<br/>
|
||||
dataset of millions of human and AI-written<br/>
|
||||
documents. Green scores indicate that you can scan<br/>
|
||||
with confidence that the model has classified<br/>
|
||||
many similar documents with high accuracy.<br/>
|
||||
Red scores indicate that this text is dissimilar<br/>
|
||||
to the ones in their training set, which can impact<br/>
|
||||
the model's accuracy, and to proceed with caution.
|
||||
`;
|
||||
const confidenceKeywords = ["moderately", "highly", "confident", "uncertain"];
|
||||
var confidence = {
|
||||
low: {
|
||||
ai: "Encoach is uncertain about this text. If Encoach had to classify it, it would be considered",
|
||||
human: "Encoach is uncertain about this text. If Encoach had to classify it, it would likely be considered",
|
||||
mixed: "Encoach is uncertain about this text. If Encoach had to classify it, it would likely be a"
|
||||
},
|
||||
medium: {
|
||||
ai: "Encoach is moderately confident this text was",
|
||||
human: "Encoach is moderately confident this text is entirely",
|
||||
mixed: "Encoach is moderately confident this text is a"
|
||||
},
|
||||
high: {
|
||||
ai: "Encoach is highly confident this text was",
|
||||
human: "Encoach is highly confident this text is entirely",
|
||||
mixed: "Encoach is highly confident this text is a"
|
||||
}
|
||||
}
|
||||
var classPrediction = {
|
||||
ai: {
|
||||
background: "bg-ai-detection-result-ai-bg",
|
||||
color: "text-ai-detection-result-ai",
|
||||
text: "ai generated"
|
||||
},
|
||||
mixed: {
|
||||
background: "bg-ai-detection-result-mixed-bg",
|
||||
color: "text-ai-detection-result-mixed",
|
||||
text: "mix of ai and human"
|
||||
},
|
||||
human: {
|
||||
background: "bg-ai-detection-result-human-bg",
|
||||
color: "text-ai-detection-result-human",
|
||||
text: "human"
|
||||
}
|
||||
}
|
||||
const segments = [
|
||||
{ percentage: Math.round(class_probabilities["human"] * 100), subtitle: 'human', color: "ai-detection-result-human" },
|
||||
{ percentage: Math.round(class_probabilities["mixed"] * 100), subtitle: 'mixed', color: "ai-detection-result-mixed" },
|
||||
{ percentage: Math.round(class_probabilities["ai"] * 100), subtitle: 'ai', color: "ai-detection-result-ai" }
|
||||
];
|
||||
const styleConfidenceText = (text: string): [string, string[]] => {
|
||||
const keywords: string[] = [];
|
||||
const styledText = text.split(" ").map(word => {
|
||||
if (confidenceKeywords.includes(word)) {
|
||||
keywords.push(word);
|
||||
return `<span style="font-weight: 500; text-decoration: underline;">${word}</span>`;
|
||||
}
|
||||
return word
|
||||
}).join(" ");
|
||||
return [styledText, keywords];
|
||||
};
|
||||
const confidenceText = confidence[confidence_category][predicted_class];
|
||||
const [styledText, keywords] = styleConfidenceText(confidenceText);
|
||||
const tooltipStyle = {
|
||||
"backgroundColor": "rgb(255, 255, 255)",
|
||||
"color": "#8992B1",
|
||||
boxShadow: '0 4px 6px rgba(0, 0, 0, 0.1)',
|
||||
borderRadius: '0.125rem'
|
||||
}
|
||||
const highestProbability = Math.max(class_probabilities["ai"], class_probabilities["human"], class_probabilities["mixed"]);
|
||||
const spanTextColor = highestProbability === class_probabilities["ai"]
|
||||
? "#f4bf4f"
|
||||
: highestProbability === class_probabilities["human"]
|
||||
? "#50c08a"
|
||||
: "#93aafb";
|
||||
let spanClassName = highestProbability === class_probabilities["ai"]
|
||||
? "text-ai-detection-result-ai"
|
||||
: highestProbability === class_probabilities["human"]
|
||||
? "text-ai-detection-result-human"
|
||||
: "text-ai-detection-result-mixed";
|
||||
spanClassName = `${spanClassName} font-bold text-lg`
|
||||
const percentage = Math.round(highestProbability * 100)
|
||||
const hasHighlightedForAI = sentences.some(item => item.highlight_sentence_for_ai);
|
||||
return (
|
||||
<>
|
||||
<Tooltip id="probability-tooltip" className="z-50 bg-white shadow-md rounded-sm" style={tooltipStyle} />
|
||||
<Tooltip id="confidence-tooltip" className="z-50 bg-white shadow-md rounded-sm" style={tooltipStyle} />
|
||||
<div className="flex flex-col bg-white p-6 rounded-lg shadow-lg gap-16">
|
||||
<h1 className="text-lg font-semibold">Encoach Detection Results</h1>
|
||||
<div className="flex flex-row -md:flex-col -lg:gap-0 -xl:gap-10 gap-20 items-stretch -md:items-center">
|
||||
<div className="flex -md:w-5/6 w-1/2 justify-center">
|
||||
<div className="flex flex-col border rounded-xl">
|
||||
<h1 className="border-b p-6 font-medium">Text Classification</h1>
|
||||
<div className="flex flex-row gap-8 items-center p-6">
|
||||
<RadialProgressBar
|
||||
percentage={percentage}
|
||||
text={predicted_class}
|
||||
color={spanTextColor}
|
||||
spanClassName={spanClassName}
|
||||
/>
|
||||
<div className="flex flex-col gap-1 text-sm">
|
||||
<div className="flex flex-row items-center">
|
||||
<span className="mr-2 text-ai-detection-result-ai-text font-semibold text-xl">
|
||||
{`${Math.round(class_probabilities["ai"] * 100)}%`}
|
||||
</span>
|
||||
<span className="text-sm -md:text-xs text-ai-detection-text">Probability AI generated</span>
|
||||
<a data-tooltip-id="probability-tooltip" data-tooltip-html={probabilityTooltipContent} className='ml-1 flex items-center justify-center'>
|
||||
<Image src="/mat-icon-info.svg" width={24} height={24} alt="Probability Tooltip" />
|
||||
</a>
|
||||
</div>
|
||||
<div className="flex flex-row items-center gap-1">
|
||||
<div className={clsx(
|
||||
"rounded-full w-3 h-3",
|
||||
confidence_category == 'low' ?
|
||||
"bg-ai-detection-confidence-low border border-ai-detection-confidence-border" : "bg-ai-detection-confidence-low-transparent"
|
||||
)}></div>
|
||||
<div className={clsx(
|
||||
"rounded-full w-3 h-3",
|
||||
confidence_category == 'medium' ?
|
||||
"bg-ai-detection-confidence-medium border border-ai-detection-confidence-border" : "bg-ai-detection-confidence-medium-transparent"
|
||||
)}></div>
|
||||
<div className={clsx(
|
||||
"rounded-full w-3 h-3 mr-2",
|
||||
confidence_category == 'high' ?
|
||||
"bg-ai-detection-confidence-high border border-ai-detection-confidence-border" : "bg-ai-detection-confidence-high-transparent"
|
||||
)}></div>
|
||||
<span className="text-sm -md:text-xs text-ai-detection-text">{keywords.join(' ')}</span>
|
||||
<a data-tooltip-id="confidence-tooltip" data-tooltip-html={confidenceTooltipContent} className='ml-1 flex items-center justify-center'>
|
||||
<Image src="/mat-icon-info.svg" width={24} height={24} alt="Probability Tooltip" />
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col border rounded-xl -md:w-5/6 w-2/6">
|
||||
<h1 className="border-b p-6 font-medium">Probability Breakdown</h1>
|
||||
<div className="flex items-center w-full h-full">
|
||||
<SegmentedProgressBar segments={segments} className="w-full px-8 -md:py-8 text-ai-detection-text" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex flex-row items-center">
|
||||
<div dangerouslySetInnerHTML={{ __html: styledText }} className="mr-2"></div>
|
||||
<div className={clsx(
|
||||
"flex items-center justify-center p-2 rounded",
|
||||
classPrediction[predicted_class]['color'],
|
||||
classPrediction[predicted_class]['background']
|
||||
)}>
|
||||
<span className="text-sm">{classPrediction[predicted_class]['text']}</span>
|
||||
</div>
|
||||
</div>
|
||||
{(hasHighlightedForAI && <div>
|
||||
Sentences that are likely written by AI are <span className="font-semibold bg-ai-detection-highlight">highlighted</span>.
|
||||
</div>)}
|
||||
</div>
|
||||
</div >
|
||||
<div>
|
||||
{sentences.map((item, index) => (
|
||||
<span
|
||||
key={index}
|
||||
className={item.highlight_sentence_for_ai ? 'bg-ai-detection-highlight' : ''}
|
||||
>
|
||||
{item.sentence}{' '}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
export default AIDetection;
|
||||
99
src/components/AbandonPopup.tsx
Normal file
@@ -0,0 +1,99 @@
|
||||
import { Dialog, Transition } from "@headlessui/react";
|
||||
import { Fragment, useCallback, useEffect, useState } from "react";
|
||||
import Button from "./Low/Button";
|
||||
|
||||
interface Props {
|
||||
isOpen: boolean;
|
||||
abandonPopupTitle: string;
|
||||
abandonPopupDescription: string;
|
||||
abandonConfirmButtonText: string;
|
||||
onAbandon: () => void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
export default function AbandonPopup({ isOpen, abandonPopupTitle, abandonPopupDescription, abandonConfirmButtonText, onAbandon, onCancel }: Props) {
|
||||
const [isClosing, setIsClosing] = useState(false);
|
||||
const [mounted, setMounted] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
setMounted(true);
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen && mounted) {
|
||||
const timer = setTimeout(() => {
|
||||
setMounted(false);
|
||||
setIsClosing(false);
|
||||
}, 300);
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
}, [isOpen, mounted]);
|
||||
|
||||
const blockMultipleClicksClose = useCallback((cancel: boolean) => {
|
||||
if (isClosing) return;
|
||||
|
||||
setIsClosing(true);
|
||||
const func = cancel ? onCancel : onAbandon;
|
||||
func();
|
||||
|
||||
const timer = setTimeout(() => {
|
||||
setIsClosing(false);
|
||||
}, 300);
|
||||
|
||||
return () => clearTimeout(timer);
|
||||
}, [isClosing, onCancel, onAbandon]);
|
||||
|
||||
if (!mounted && !isOpen) return null;
|
||||
|
||||
return (
|
||||
<Transition
|
||||
show={isOpen}
|
||||
as={Fragment}
|
||||
beforeEnter={() => setIsClosing(false)}
|
||||
beforeLeave={() => setIsClosing(true)}
|
||||
afterLeave={() => {
|
||||
setIsClosing(false);
|
||||
setMounted(false);
|
||||
}}
|
||||
>
|
||||
<Dialog onClose={() => blockMultipleClicksClose(true)} className="relative z-50">
|
||||
<Transition.Child
|
||||
as={Fragment}
|
||||
enter="ease-out duration-300"
|
||||
enterFrom="opacity-0"
|
||||
enterTo="opacity-100"
|
||||
leave="ease-in duration-200"
|
||||
leaveFrom="opacity-100"
|
||||
leaveTo="opacity-0">
|
||||
<div className="fixed inset-0 bg-black/30" />
|
||||
</Transition.Child>
|
||||
|
||||
<Transition.Child
|
||||
as={Fragment}
|
||||
enter="ease-out duration-300"
|
||||
enterFrom="opacity-0 scale-95"
|
||||
enterTo="opacity-100 scale-100"
|
||||
leave="ease-in duration-200"
|
||||
leaveFrom="opacity-100 scale-100"
|
||||
leaveTo="opacity-0 scale-95">
|
||||
<div className="fixed inset-0 flex items-center justify-center p-4">
|
||||
<Dialog.Panel className="w-full max-w-2xl h-fit p-8 rounded-xl bg-white flex flex-col gap-4">
|
||||
<Dialog.Title className="font-bold text-xl">{abandonPopupTitle}</Dialog.Title>
|
||||
<span>{abandonPopupDescription}</span>
|
||||
<div className="w-full flex justify-between mt-8">
|
||||
<Button color="purple" onClick={() => blockMultipleClicksClose(true)} variant="outline" className="max-w-[200px] self-end w-full">
|
||||
Cancel
|
||||
</Button>
|
||||
<Button color="purple" onClick={() => blockMultipleClicksClose(false)} className="max-w-[200px] self-end w-full">
|
||||
{abandonConfirmButtonText}
|
||||
</Button>
|
||||
</div>
|
||||
</Dialog.Panel>
|
||||
</div>
|
||||
</Transition.Child>
|
||||
</Dialog>
|
||||
</Transition>
|
||||
);
|
||||
}
|
||||
32
src/components/ApprovalWorkflows/RequestedBy.tsx
Normal file
@@ -0,0 +1,32 @@
|
||||
import Image from "next/image";
|
||||
import React from "react";
|
||||
import { FaRegUser } from "react-icons/fa";
|
||||
|
||||
interface Props {
|
||||
prefix: string;
|
||||
name: string;
|
||||
profileImage: string;
|
||||
}
|
||||
|
||||
export default function RequestedBy({ prefix, name, profileImage }: Props) {
|
||||
return (
|
||||
<div className="flex items-center space-x-3">
|
||||
<div className="flex items-center justify-center w-12 h-12 bg-gray-100 rounded-lg border border-gray-300">
|
||||
<FaRegUser className="text-mti-purple-dark size-5"/>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-800">Requested by</p>
|
||||
<div className="flex items-center space-x-2">
|
||||
<p className="text-xs font-medium text-gray-800">{prefix} {name}</p>
|
||||
<img
|
||||
src={profileImage ? profileImage : "/defaultAvatar.png"}
|
||||
alt={name}
|
||||
width={24}
|
||||
height={24}
|
||||
className="w-6 h-6 rounded-full border-[1px] border-gray-400 border-opacity-50"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
41
src/components/ApprovalWorkflows/StartedOn.tsx
Normal file
@@ -0,0 +1,41 @@
|
||||
import React from "react";
|
||||
import { PiCalendarDots } from "react-icons/pi";
|
||||
|
||||
interface Props {
|
||||
date: number;
|
||||
}
|
||||
|
||||
export default function StartedOn({ date }: Props) {
|
||||
const formattedDate = new Date(date);
|
||||
|
||||
const yearMonthDay = formattedDate.toISOString().split("T")[0];
|
||||
|
||||
const fullDateTime = formattedDate.toLocaleString("en-US", {
|
||||
year: "numeric",
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
second: "2-digit",
|
||||
hour12: false,
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="flex items-center space-x-3">
|
||||
<div className="flex items-center justify-center w-12 h-12 bg-gray-100 rounded-lg border border-gray-300">
|
||||
<PiCalendarDots className="text-mti-purple-dark size-7" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="pb-1 text-sm font-medium text-gray-800">Started on</p>
|
||||
<div className="flex items-center">
|
||||
<p
|
||||
className="text-xs font-medium text-gray-800"
|
||||
title={fullDateTime}
|
||||
>
|
||||
{yearMonthDay}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
23
src/components/ApprovalWorkflows/Status.tsx
Normal file
@@ -0,0 +1,23 @@
|
||||
import { ApprovalWorkflowStatus, ApprovalWorkflowStatusLabel } from "@/interfaces/approval.workflow";
|
||||
import React from "react";
|
||||
import { RiProgress5Line } from "react-icons/ri";
|
||||
|
||||
interface Props {
|
||||
status: ApprovalWorkflowStatus;
|
||||
}
|
||||
|
||||
export default function Status({ status }: Props) {
|
||||
return (
|
||||
<div className="flex items-center space-x-3">
|
||||
<div className="flex items-center justify-center w-12 h-12 bg-gray-100 rounded-lg border border-gray-300">
|
||||
<RiProgress5Line className="text-mti-purple-dark size-7"/>
|
||||
</div>
|
||||
<div>
|
||||
<p className="pb-1 text-sm font-medium text-gray-800">Status</p>
|
||||
<div className="flex items-center">
|
||||
<p className="text-xs font-medium text-gray-800">{ApprovalWorkflowStatusLabel[status]}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
14
src/components/ApprovalWorkflows/Tip.tsx
Normal file
@@ -0,0 +1,14 @@
|
||||
import { MdTipsAndUpdates } from "react-icons/md";
|
||||
|
||||
interface Props {
|
||||
text: string;
|
||||
}
|
||||
|
||||
export default function Tip({ text }: Props) {
|
||||
return (
|
||||
<div className="flex flex-row gap-3 text-gray-500 font-medium">
|
||||
<MdTipsAndUpdates size={25} />
|
||||
<p>{text}</p>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
24
src/components/ApprovalWorkflows/UserWithProfilePic.tsx
Normal file
@@ -0,0 +1,24 @@
|
||||
import Image from "next/image";
|
||||
|
||||
interface Props {
|
||||
prefix: string;
|
||||
name: string;
|
||||
profileImage: string;
|
||||
textSize?: string;
|
||||
}
|
||||
|
||||
export default function UserWithProfilePic({ prefix, name, profileImage, textSize }: Props) {
|
||||
const textClassName = `${textSize ? textSize : "text-xs"} font-medium`
|
||||
return (
|
||||
<div className="flex items-center space-x-2">
|
||||
<p className={textClassName}>{prefix} {name}</p>
|
||||
<img
|
||||
src={profileImage ? profileImage : "/defaultAvatar.png"}
|
||||
alt={name}
|
||||
width={24}
|
||||
height={24}
|
||||
className="rounded-full h-auto border-[1px] border-gray-400 border-opacity-50"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,136 @@
|
||||
import { EditableWorkflowStep } from "@/interfaces/approval.workflow";
|
||||
import Option from "@/interfaces/option";
|
||||
import { CorporateUser, DeveloperUser, MasterCorporateUser, TeacherUser } from "@/interfaces/user";
|
||||
import Image from "next/image";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { AiOutlineUserAdd } from "react-icons/ai";
|
||||
import { BsTrash } from "react-icons/bs";
|
||||
import { LuGripHorizontal } from "react-icons/lu";
|
||||
import WorkflowStepNumber from "./WorkflowStepNumber";
|
||||
import WorkflowStepSelects from "./WorkflowStepSelects";
|
||||
|
||||
interface Props extends Pick<EditableWorkflowStep, 'stepNumber' | 'assignees' | 'finalStep' | 'onDelete'> {
|
||||
entityApprovers: (TeacherUser | CorporateUser | MasterCorporateUser | DeveloperUser)[];
|
||||
onSelectChange: (numberOfSelects: number, index: number, value: Option | null) => void;
|
||||
isCompleted: boolean,
|
||||
}
|
||||
|
||||
export default function WorkflowEditableStepComponent({
|
||||
stepNumber,
|
||||
assignees = [null],
|
||||
finalStep,
|
||||
onDelete,
|
||||
onSelectChange,
|
||||
entityApprovers,
|
||||
isCompleted,
|
||||
}: Props) {
|
||||
|
||||
const [selects, setSelects] = useState<(Option | null | undefined)[]>([null]);
|
||||
const [isAdding, setIsAdding] = useState(false);
|
||||
|
||||
const approverOptions: Option[] = useMemo(() =>
|
||||
entityApprovers
|
||||
.map((approver) => ({
|
||||
value: approver.id,
|
||||
label: approver.name,
|
||||
icon: () => <img src={approver.profilePicture} alt={approver.name} />
|
||||
}))
|
||||
.sort((a, b) => a.label.localeCompare(b.label)),
|
||||
[entityApprovers]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (assignees && assignees.length > 0) {
|
||||
const initialSelects = assignees.map((assignee) =>
|
||||
typeof assignee === 'string' ? approverOptions.find(option => option.value === assignee) || null : null
|
||||
);
|
||||
|
||||
setSelects((prevSelects) => {
|
||||
// This is needed to avoid unnecessary re-renders which can cause warning of a child component being re-rendered while parent is in the midle of also re-rendering.
|
||||
const areEqual = initialSelects.length === prevSelects.length && initialSelects.every((option, idx) => option?.value === prevSelects[idx]?.value);
|
||||
|
||||
if (!areEqual) {
|
||||
return initialSelects;
|
||||
}
|
||||
return prevSelects;
|
||||
});
|
||||
}
|
||||
}, [assignees, approverOptions]);
|
||||
|
||||
const selectedValues = useMemo(() =>
|
||||
selects.filter((opt): opt is Option => !!opt).map(opt => opt.value),
|
||||
[selects]
|
||||
);
|
||||
|
||||
const availableApproverOptions = useMemo(() =>
|
||||
approverOptions.filter(opt => !selectedValues.includes(opt.value)),
|
||||
[approverOptions, selectedValues]
|
||||
);
|
||||
|
||||
const handleAddSelectComponent = () => {
|
||||
setIsAdding(true); // I hate to use flags... but it was the only way i was able to prevent onSelectChange to cause parent component from re-rendering in the midle of EditableWorkflowStep rerender.
|
||||
setSelects(prev => [...prev, null]);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (isAdding) {
|
||||
onSelectChange(selects.length, selects.length - 1, null);
|
||||
setIsAdding(false);
|
||||
}
|
||||
}, [selects.length, isAdding, onSelectChange]);
|
||||
|
||||
const handleSelectChangeAt = (numberOfSelects: number, index: number, option: Option | null) => {
|
||||
const updated = [...selects];
|
||||
updated[index] = option;
|
||||
setSelects(updated);
|
||||
onSelectChange(numberOfSelects, index, option);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex w-full">
|
||||
<div className="flex flex-col items-center">
|
||||
<WorkflowStepNumber stepNumber={stepNumber} completed={false} selected={false} />
|
||||
|
||||
{/* Vertical Bar connecting steps */}
|
||||
{!finalStep && (
|
||||
<div className="w-1 h-full min-h-10 bg-mti-purple-dark"></div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{stepNumber !== 1 && !finalStep && !isCompleted
|
||||
? <LuGripHorizontal className="ml-3 mt-2 cursor-grab active:cursor-grabbing min-w-[25px] min-h-[25px]" />
|
||||
: <div className="ml-3 mt-2" style={{ width: 25, height: 25 }}></div>
|
||||
}
|
||||
|
||||
<div className="ml-10 mb-12">
|
||||
<WorkflowStepSelects
|
||||
approvers={availableApproverOptions}
|
||||
selects={selects}
|
||||
placeholder={stepNumber === 1 ? "Form Intake By:" : "Approval By:"}
|
||||
onSelectChange={handleSelectChangeAt}
|
||||
isCompleted={isCompleted}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-row items-start mt-1.5 ml-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleAddSelectComponent}
|
||||
className="cursor-pointer"
|
||||
>
|
||||
<AiOutlineUserAdd className="size-7 hover:text-mti-purple-light transition ease-in-out duration-300" />
|
||||
</button>
|
||||
{stepNumber !== 1 && !finalStep && (
|
||||
<button
|
||||
className="cursor-pointer"
|
||||
onClick={onDelete}
|
||||
type="button"
|
||||
>
|
||||
<BsTrash className="size-6 mt-0.5 ml-3 hover:text-mti-purple-light transition ease-in-out duration-300" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
);
|
||||
};
|
||||
203
src/components/ApprovalWorkflows/WorkflowForm.tsx
Normal file
@@ -0,0 +1,203 @@
|
||||
import { EditableApprovalWorkflow, EditableWorkflowStep } from "@/interfaces/approval.workflow";
|
||||
import Option from "@/interfaces/option";
|
||||
import { CorporateUser, DeveloperUser, MasterCorporateUser, TeacherUser } from "@/interfaces/user";
|
||||
import { AnimatePresence, Reorder, motion } from "framer-motion";
|
||||
import { FaRegCheckCircle, FaSpinner } from "react-icons/fa";
|
||||
import { IoIosAddCircleOutline } from "react-icons/io";
|
||||
import Button from "../Low/Button";
|
||||
import Tip from "./Tip";
|
||||
import WorkflowEditableStepComponent from "./WorkflowEditableStepComponent";
|
||||
|
||||
interface Props {
|
||||
workflow: EditableApprovalWorkflow;
|
||||
onWorkflowChange: (workflow: EditableApprovalWorkflow) => void;
|
||||
entityApprovers: (TeacherUser | CorporateUser | MasterCorporateUser | DeveloperUser)[];
|
||||
entityAvailableFormIntakers?: (TeacherUser | CorporateUser | MasterCorporateUser | DeveloperUser)[];
|
||||
isLoading: boolean;
|
||||
isRedirecting?: boolean;
|
||||
}
|
||||
|
||||
export default function WorkflowForm({ workflow, onWorkflowChange, entityApprovers, entityAvailableFormIntakers, isLoading, isRedirecting }: Props) {
|
||||
const lastStep = workflow.steps[workflow.steps.length - 1];
|
||||
|
||||
const renumberSteps = (steps: EditableWorkflowStep[]): EditableWorkflowStep[] => {
|
||||
return steps.map((step, index) => ({
|
||||
...step,
|
||||
stepNumber: index + 1,
|
||||
}));
|
||||
};
|
||||
|
||||
const addStep = () => {
|
||||
const newStep: EditableWorkflowStep = {
|
||||
key: Date.now(),
|
||||
stepType: "approval-by",
|
||||
stepNumber: workflow.steps.length,
|
||||
completed: false,
|
||||
assignees: [null],
|
||||
firstStep: false,
|
||||
finalStep: false,
|
||||
};
|
||||
|
||||
const updatedSteps = [
|
||||
...workflow.steps.slice(0, -1),
|
||||
newStep,
|
||||
lastStep
|
||||
];
|
||||
onWorkflowChange({ ...workflow, steps: renumberSteps(updatedSteps) });
|
||||
};
|
||||
|
||||
const handleDelete = (key: number | undefined) => {
|
||||
if (!key) return;
|
||||
|
||||
const updatedSteps = workflow.steps.filter((step) => step.key !== key);
|
||||
onWorkflowChange({ ...workflow, steps: renumberSteps(updatedSteps) });
|
||||
};
|
||||
|
||||
const handleSelectChange = (key: number | undefined, numberOfSelects: number, index: number, selectedOption: Option | null) => {
|
||||
if (!key) return;
|
||||
|
||||
const updatedSteps = workflow.steps.map((step) => {
|
||||
if (step.key !== key) return step;
|
||||
|
||||
const assignees = step.assignees ?? [];
|
||||
let newAssignees = [...assignees];
|
||||
|
||||
if (numberOfSelects === assignees.length) { // means no new select was added and instead one was changed
|
||||
newAssignees[index] = selectedOption?.value;
|
||||
} else if (numberOfSelects === assignees.length + 1) { // means a new select was added
|
||||
newAssignees.push(selectedOption?.value || null);
|
||||
}
|
||||
|
||||
return { ...step, assignees: newAssignees };
|
||||
});
|
||||
onWorkflowChange({ ...workflow, steps: updatedSteps });
|
||||
};
|
||||
|
||||
const handleReorder = (newOrder: EditableWorkflowStep[]) => {
|
||||
let draggableIndex = 0;
|
||||
const updatedSteps = workflow.steps.map((step) => {
|
||||
if (!step.firstStep && !step.finalStep && !step.completed) {
|
||||
return newOrder[draggableIndex++];
|
||||
}
|
||||
// Keep static steps as-is
|
||||
return step;
|
||||
});
|
||||
onWorkflowChange({ ...workflow, steps: renumberSteps(updatedSteps) });
|
||||
};
|
||||
|
||||
|
||||
return (
|
||||
<>
|
||||
{workflow.entityId && workflow.name &&
|
||||
<div>
|
||||
<div
|
||||
className="flex flex-col gap-6"
|
||||
>
|
||||
<Tip text="Introduce here all the steps associated with this instance." />
|
||||
<Button
|
||||
color="purple"
|
||||
variant="solid"
|
||||
onClick={addStep}
|
||||
type="button"
|
||||
className="max-w-fit text-lg font-medium flex items-center gap-2 text-left mb-7"
|
||||
>
|
||||
<IoIosAddCircleOutline className="size-6" />
|
||||
Add Step
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Reorder.Group
|
||||
axis="y"
|
||||
values={workflow.steps}
|
||||
onReorder={handleReorder}
|
||||
className="flex flex-col gap-0"
|
||||
>
|
||||
<AnimatePresence>
|
||||
{workflow.steps.map((step, index) =>
|
||||
step.completed || step.firstStep || step.finalStep ? (
|
||||
<motion.div
|
||||
key={step.key}
|
||||
layout
|
||||
initial={{ opacity: 0, y: -30 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, x: 30 }}
|
||||
transition={{ duration: 0.20 }}
|
||||
>
|
||||
<WorkflowEditableStepComponent
|
||||
stepNumber={index + 1}
|
||||
assignees={step.assignees}
|
||||
finalStep={step.finalStep}
|
||||
onDelete={() => handleDelete(step.key)}
|
||||
onSelectChange={(numberOfSelects, idx, option) =>
|
||||
handleSelectChange(step.key, numberOfSelects, idx, option)
|
||||
}
|
||||
entityApprovers={
|
||||
step.stepNumber === 1 && entityAvailableFormIntakers
|
||||
? entityAvailableFormIntakers
|
||||
: entityApprovers
|
||||
}
|
||||
isCompleted={step.completed}
|
||||
/>
|
||||
</motion.div>
|
||||
) : (
|
||||
// Render non-completed steps as draggable items
|
||||
<Reorder.Item
|
||||
key={step.key}
|
||||
value={step}
|
||||
initial={{ opacity: 0, y: -30 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, x: 30 }}
|
||||
transition={{ duration: 0.20 }}
|
||||
layout
|
||||
drag={!step.firstStep && !step.finalStep}
|
||||
dragListener={!step.firstStep && !step.finalStep}
|
||||
>
|
||||
<WorkflowEditableStepComponent
|
||||
stepNumber={index + 1}
|
||||
assignees={step.assignees}
|
||||
finalStep={step.finalStep}
|
||||
onDelete={() => handleDelete(step.key)}
|
||||
onSelectChange={(numberOfSelects, idx, option) =>
|
||||
handleSelectChange(step.key, numberOfSelects, idx, option)
|
||||
}
|
||||
entityApprovers={
|
||||
step.stepNumber === 1 && entityAvailableFormIntakers
|
||||
? entityAvailableFormIntakers
|
||||
: entityApprovers
|
||||
}
|
||||
isCompleted={step.completed}
|
||||
/>
|
||||
</Reorder.Item>
|
||||
)
|
||||
)}
|
||||
<Button
|
||||
type="submit"
|
||||
color="purple"
|
||||
variant="solid"
|
||||
disabled={isLoading}
|
||||
className="max-w-fit text-lg font-medium flex items-center gap-2 text-left -mt-4"
|
||||
>
|
||||
{isRedirecting ? (
|
||||
<>
|
||||
<FaSpinner className="animate-spin size-5" />
|
||||
Redirecting...
|
||||
</>
|
||||
) : isLoading ? (
|
||||
<>
|
||||
<FaSpinner className="animate-spin size-5" />
|
||||
Loading...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<FaRegCheckCircle className="size-5" />
|
||||
Confirm Exam Workflow Pipeline
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</AnimatePresence>
|
||||
</Reorder.Group>
|
||||
</div>
|
||||
}
|
||||
</>
|
||||
);
|
||||
};
|
||||
101
src/components/ApprovalWorkflows/WorkflowStepComponent.tsx
Normal file
@@ -0,0 +1,101 @@
|
||||
import { getUserTypeLabel, getUserTypeLabelShort, WorkflowStep } from "@/interfaces/approval.workflow";
|
||||
import WorkflowStepNumber from "./WorkflowStepNumber";
|
||||
import clsx from "clsx";
|
||||
import { RiThumbUpLine } from "react-icons/ri";
|
||||
import { FaWpforms } from "react-icons/fa6";
|
||||
import { User } from "@/interfaces/user";
|
||||
import UserWithProfilePic from "./UserWithProfilePic";
|
||||
|
||||
interface Props extends WorkflowStep {
|
||||
workflowAssignees: User[],
|
||||
currentStep: boolean,
|
||||
}
|
||||
|
||||
export default function WorkflowStepComponent({
|
||||
workflowAssignees,
|
||||
currentStep,
|
||||
stepType,
|
||||
stepNumber,
|
||||
completed,
|
||||
rejected = false,
|
||||
completedBy,
|
||||
assignees,
|
||||
finalStep,
|
||||
selected = false,
|
||||
onClick,
|
||||
}: Props) {
|
||||
|
||||
const completedByUser = workflowAssignees.find((assignee) => assignee.id === completedBy);
|
||||
const assigneesUsers = workflowAssignees.filter(user => assignees.includes(user.id));
|
||||
|
||||
return (
|
||||
<div
|
||||
onClick={onClick}
|
||||
className={clsx("flex flex-row gap-5 w-[600px] p-6 mb-5 rounded-2xl transition ease-in-out duration-300 cursor-pointer", {
|
||||
"bg-mti-red-ultralight": rejected && selected,
|
||||
"bg-mti-purple-ultralight": selected,
|
||||
})}
|
||||
>
|
||||
<div className="relative flex flex-col items-center">
|
||||
<WorkflowStepNumber stepNumber={stepNumber} selected={selected} completed={completed} finalStep={finalStep} rejected={rejected} />
|
||||
|
||||
{/* Vertical Bar connecting steps */}
|
||||
{!finalStep && (
|
||||
<div className="absolute w-1 bg-mti-purple-dark -bottom-20 top-11"></div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mt-1.5">
|
||||
{stepType === "approval-by" ? (
|
||||
<RiThumbUpLine size={25} />
|
||||
) : (
|
||||
<FaWpforms size={25} />
|
||||
)
|
||||
}
|
||||
</div>
|
||||
|
||||
<div className="mt-1 flex flex-col gap-0">
|
||||
{completed && completedBy && rejected ? (
|
||||
<div className={clsx("text-xs font-medium", { "text-mti-purple-ultradark": selected, "text-gray-800": !selected })}>
|
||||
<p className="text-sm font-medium text-gray-800">{stepType === "approval-by" ? `Approval: ${getUserTypeLabel(completedByUser!.type)} Approval` : `Form Intake: ${getUserTypeLabel(completedByUser!.type)} Intake`} </p>
|
||||
<UserWithProfilePic
|
||||
prefix={`Rejected by: ${getUserTypeLabelShort(completedByUser!.type)}`}
|
||||
name={completedByUser!.name}
|
||||
profileImage={completedByUser!.profilePicture}
|
||||
/>
|
||||
</div>
|
||||
) : completed && completedBy && !rejected ? (
|
||||
<div className={clsx("text-xs font-medium", { "text-mti-purple-ultradark": selected, "text-gray-800": !selected })}>
|
||||
<p className="text-sm font-medium text-gray-800">{stepType === "approval-by" ? `Approval: ${getUserTypeLabel(completedByUser!.type)} Approval` : `Form Intake: ${getUserTypeLabel(completedByUser!.type)} Intake`} </p>
|
||||
<UserWithProfilePic
|
||||
prefix={`Completed by: ${getUserTypeLabelShort(completedByUser!.type)}`}
|
||||
name={completedByUser!.name}
|
||||
profileImage={completedByUser!.profilePicture}
|
||||
/>
|
||||
</div>
|
||||
) : !completed && currentStep ? (
|
||||
<div className={clsx("text-xs font-medium", { "text-mti-purple-ultradark": selected, "text-gray-800": !selected })}>
|
||||
<p className="text-sm font-medium text-gray-800">{stepType === "approval-by" ? `Approval:` : `Form Intake:`} </p>
|
||||
In Progress... Assignees:
|
||||
<div className="flex flex-row flex-wrap gap-3 items-center">
|
||||
{assigneesUsers.map(user => (
|
||||
<span key={user.id}>
|
||||
<UserWithProfilePic
|
||||
prefix={getUserTypeLabelShort(user.type)}
|
||||
name={user.name}
|
||||
profileImage={user.profilePicture}
|
||||
/>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className={clsx("text-xs font-medium", { "text-mti-purple-ultradark": selected, "text-gray-800": !selected })}>
|
||||
<p className="text-sm font-medium text-gray-800">{stepType === "approval-by" ? `Approval:` : `Form Intake:`} </p>
|
||||
Waiting for previous steps...
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
31
src/components/ApprovalWorkflows/WorkflowStepNumber.tsx
Normal file
@@ -0,0 +1,31 @@
|
||||
import { WorkflowStep } from "@/interfaces/approval.workflow";
|
||||
import clsx from "clsx";
|
||||
import { IoCheckmarkDoneSharp, IoCheckmarkSharp } from "react-icons/io5";
|
||||
import { RxCross2 } from "react-icons/rx";
|
||||
|
||||
type Props = Pick<WorkflowStep, 'stepNumber' | 'completed' | 'finalStep' | 'selected' | 'rejected'>
|
||||
|
||||
export default function WorkflowStepNumber({ stepNumber, selected = false, completed, rejected, finalStep }: Props) {
|
||||
return (
|
||||
<div
|
||||
className={clsx(
|
||||
'flex items-center justify-center min-w-11 min-h-11 rounded-full',
|
||||
{
|
||||
'bg-mti-red-dark text-mti-red-ultralight': rejected,
|
||||
'bg-mti-purple-dark text-mti-purple-ultralight': selected,
|
||||
'bg-mti-purple-ultralight text-gray-500': !selected,
|
||||
}
|
||||
)}
|
||||
>
|
||||
{rejected ? (
|
||||
<RxCross2 className="text-xl font-bold" size={25}/>
|
||||
) : completed && finalStep ? (
|
||||
<IoCheckmarkDoneSharp className="text-xl font-bold" size={25} />
|
||||
) : completed && !finalStep ? (
|
||||
<IoCheckmarkSharp className="text-xl font-bold" size={25} />
|
||||
) : (
|
||||
<span className="text-lg font-semibold">{stepNumber}</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
51
src/components/ApprovalWorkflows/WorkflowStepSelects.tsx
Normal file
@@ -0,0 +1,51 @@
|
||||
import Option from "@/interfaces/option";
|
||||
import Select from "../Low/Select";
|
||||
|
||||
interface Props {
|
||||
approvers: Option[];
|
||||
selects: (Option | null | undefined)[];
|
||||
placeholder: string;
|
||||
onSelectChange: (numberOfSelects: number, index: number, value: Option | null) => void;
|
||||
isCompleted: boolean;
|
||||
}
|
||||
|
||||
export default function WorkflowStepSelects({
|
||||
approvers,
|
||||
selects,
|
||||
placeholder,
|
||||
onSelectChange,
|
||||
isCompleted,
|
||||
}: Props) {
|
||||
|
||||
return (
|
||||
<div
|
||||
className={"flex flex-wrap gap-0"}
|
||||
>
|
||||
{selects.map((option, index) => {
|
||||
let classes = "px-2 rounded-none";
|
||||
if (index === 0 && selects.length === 1) {
|
||||
classes += " rounded-l-2xl rounded-r-2xl";
|
||||
} else if (index === 0) {
|
||||
classes += " rounded-l-2xl";
|
||||
} else if (index === selects.length - 1) {
|
||||
classes += " rounded-r-2xl";
|
||||
}
|
||||
|
||||
return (
|
||||
<div key={index} className="w-[275px]">
|
||||
<Select
|
||||
options={approvers}
|
||||
value={option}
|
||||
onChange={(option) => onSelectChange(selects.length, index, option)}
|
||||
placeholder={placeholder}
|
||||
flat
|
||||
isClearable
|
||||
className={classes}
|
||||
disabled={isCompleted}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
148
src/components/AssignmentCard.tsx
Normal file
@@ -0,0 +1,148 @@
|
||||
import ProgressBar from "@/components/Low/ProgressBar";
|
||||
import useUsers from "@/hooks/useUsers";
|
||||
import { Module } from "@/interfaces";
|
||||
import { Assignment } from "@/interfaces/results";
|
||||
import { calculateBandScore } from "@/utils/score";
|
||||
import clsx from "clsx";
|
||||
import moment from "moment";
|
||||
import { BsBook, BsClipboard, BsHeadphones, BsMegaphone, BsPen } from "react-icons/bs";
|
||||
import { usePDFDownload } from "@/hooks/usePDFDownload";
|
||||
import { useAssignmentArchive } from "@/hooks/useAssignmentArchive";
|
||||
import { uniqBy } from "lodash";
|
||||
import { useAssignmentUnarchive } from "@/hooks/useAssignmentUnarchive";
|
||||
import { useAssignmentRelease } from "@/hooks/useAssignmentRelease";
|
||||
import { getUserName } from "@/utils/users";
|
||||
import { User } from "@/interfaces/user";
|
||||
import { EntityWithRoles } from "@/interfaces/entity";
|
||||
|
||||
interface Props {
|
||||
users: User[];
|
||||
onClick?: () => void;
|
||||
allowDownload?: boolean;
|
||||
reload?: Function;
|
||||
allowArchive?: boolean;
|
||||
allowUnarchive?: boolean;
|
||||
allowExcelDownload?: boolean;
|
||||
entityObj?: EntityWithRoles
|
||||
}
|
||||
|
||||
export default function AssignmentCard({
|
||||
id,
|
||||
name,
|
||||
assigner,
|
||||
startDate,
|
||||
endDate,
|
||||
entityObj,
|
||||
assignees,
|
||||
results,
|
||||
exams,
|
||||
archived,
|
||||
onClick,
|
||||
allowDownload,
|
||||
reload,
|
||||
allowArchive,
|
||||
allowUnarchive,
|
||||
allowExcelDownload,
|
||||
users,
|
||||
released,
|
||||
}: Assignment & Props) {
|
||||
const renderPdfIcon = usePDFDownload("assignments");
|
||||
const renderExcelIcon = usePDFDownload("assignments", "excel");
|
||||
const renderArchiveIcon = useAssignmentArchive(id, reload);
|
||||
const renderUnarchiveIcon = useAssignmentUnarchive(id, reload);
|
||||
const renderReleaseIcon = useAssignmentRelease(id, reload);
|
||||
|
||||
const calculateAverageModuleScore = (module: Module) => {
|
||||
const resultModuleBandScores = results.map((r) => {
|
||||
const moduleStats = r.stats.filter((s) => s.module === module);
|
||||
|
||||
const correct = moduleStats.reduce((acc, curr) => acc + curr.score.correct, 0);
|
||||
const total = moduleStats.reduce((acc, curr) => acc + curr.score.total, 0);
|
||||
return calculateBandScore(correct, total, module, r.type);
|
||||
});
|
||||
|
||||
return resultModuleBandScores.length === 0 ? -1 : resultModuleBandScores.reduce((acc, curr) => acc + curr, 0) / results.length;
|
||||
};
|
||||
|
||||
const uniqModules = uniqBy(exams, (x) => x.module);
|
||||
|
||||
const shouldRenderPDF = () => {
|
||||
if (released && allowDownload) {
|
||||
// in order to be downloadable, the assignment has to be released
|
||||
// the component should have the allowDownload prop
|
||||
// and the assignment should not have the level module
|
||||
return uniqModules.every(({ module }) => module !== "level");
|
||||
}
|
||||
|
||||
return false;
|
||||
};
|
||||
|
||||
const shouldRenderExcel = () => {
|
||||
if (released && allowExcelDownload) {
|
||||
// in order to be downloadable, the assignment has to be released
|
||||
// the component should have the allowExcelDownload prop
|
||||
// and the assignment should have the level module
|
||||
return uniqModules.some(({ module }) => module === "level");
|
||||
}
|
||||
|
||||
return false;
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
onClick={onClick}
|
||||
className="border-mti-gray-platinum flex h-fit w-[350px] cursor-pointer flex-col gap-6 rounded-xl border bg-white p-4 transition duration-300 ease-in-out hover:drop-shadow">
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex flex-row justify-between">
|
||||
<h3 className="text-xl font-semibold">{name}</h3>
|
||||
<div className="flex gap-2">
|
||||
{shouldRenderPDF() && renderPdfIcon(id, "text-mti-gray-dim", "text-mti-gray-dim")}
|
||||
{shouldRenderExcel() && renderExcelIcon(id, "text-mti-gray-dim", "text-mti-gray-dim")}
|
||||
{allowArchive && !archived && renderArchiveIcon("text-mti-gray-dim", "text-mti-gray-dim")}
|
||||
{allowUnarchive && archived && renderUnarchiveIcon("text-mti-gray-dim", "text-mti-gray-dim")}
|
||||
{!released && renderReleaseIcon("text-mti-gray-dim", "text-mti-gray-dim")}
|
||||
</div>
|
||||
</div>
|
||||
<ProgressBar
|
||||
color={results.length / assignees.length < 0.5 ? "red" : "purple"}
|
||||
percentage={(results.length / assignees.length) * 100}
|
||||
label={`${results.length}/${assignees.length}`}
|
||||
className="h-5"
|
||||
textClassName={results.length / assignees.length < 0.5 ? "!text-mti-gray-dim font-light" : "text-white"}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="flex justify-between gap-1">
|
||||
<span>{moment(startDate).format("DD/MM/YY, HH:mm")}</span>
|
||||
<span>-</span>
|
||||
<span>{moment(endDate).format("DD/MM/YY, HH:mm")}</span>
|
||||
</span>
|
||||
<span>Assigner: {getUserName(users.find((x) => x.id === assigner))}</span>
|
||||
{entityObj && <span>Entity: {entityObj.label}</span>}
|
||||
</div>
|
||||
<div className="-md:mt-2 grid w-full grid-cols-4 place-items-start gap-2">
|
||||
{uniqModules.map(({ module }) => (
|
||||
<div
|
||||
key={module}
|
||||
className={clsx(
|
||||
"-md:px-4 flex w-fit items-center gap-2 rounded-xl py-2 text-white md:px-2 xl:px-4",
|
||||
module === "reading" && "bg-ielts-reading",
|
||||
module === "listening" && "bg-ielts-listening",
|
||||
module === "writing" && "bg-ielts-writing",
|
||||
module === "speaking" && "bg-ielts-speaking",
|
||||
module === "level" && "bg-ielts-level",
|
||||
)}>
|
||||
{module === "reading" && <BsBook className="h-4 w-4" />}
|
||||
{module === "listening" && <BsHeadphones className="h-4 w-4" />}
|
||||
{module === "writing" && <BsPen className="h-4 w-4" />}
|
||||
{module === "speaking" && <BsMegaphone className="h-4 w-4" />}
|
||||
{module === "level" && <BsClipboard className="h-4 w-4" />}
|
||||
{calculateAverageModuleScore(module) > -1 && (
|
||||
<span className="text-sm">{calculateAverageModuleScore(module).toFixed(1)}</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
339
src/components/AssignmentView.tsx
Normal file
@@ -0,0 +1,339 @@
|
||||
import Button from "@/components/Low/Button";
|
||||
import ProgressBar from "@/components/Low/ProgressBar";
|
||||
import Modal from "@/components/Modal";
|
||||
import useUsers from "@/hooks/useUsers";
|
||||
import { Module } from "@/interfaces";
|
||||
import { Assignment } from "@/interfaces/results";
|
||||
import { Stat, User } from "@/interfaces/user";
|
||||
import useExamStore from "@/stores/exam";
|
||||
import { getExamById } from "@/utils/exams";
|
||||
import { sortByModule } from "@/utils/moduleUtils";
|
||||
import { calculateBandScore } from "@/utils/score";
|
||||
import { getUserName } from "@/utils/users";
|
||||
import axios from "axios";
|
||||
import clsx from "clsx";
|
||||
import { capitalize, uniqBy } from "lodash";
|
||||
import moment from "moment";
|
||||
import { useRouter } from "next/router";
|
||||
import { BsBook, BsClipboard, BsHeadphones, BsMegaphone, BsPen } from "react-icons/bs";
|
||||
import { toast } from "react-toastify";
|
||||
import { futureAssignmentFilter } from "@/utils/assignments";
|
||||
|
||||
interface Props {
|
||||
isOpen: boolean;
|
||||
users: User[];
|
||||
assignment?: Assignment;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export default function AssignmentView({ isOpen, users, assignment, onClose }: Props) {
|
||||
const router = useRouter();
|
||||
|
||||
const dispatch = useExamStore((s) => s.dispatch);
|
||||
|
||||
const deleteAssignment = async () => {
|
||||
if (!confirm("Are you sure you want to delete this assignment?")) return;
|
||||
|
||||
axios
|
||||
.delete(`/api/assignments/${assignment?.id}`)
|
||||
.then(() => toast.success(`Successfully deleted the assignment "${assignment?.name}".`))
|
||||
.catch(() => toast.error("Something went wrong, please try again later."))
|
||||
.finally(onClose);
|
||||
};
|
||||
|
||||
const startAssignment = () => {
|
||||
if (assignment) {
|
||||
axios
|
||||
.post(`/api/assignments/${assignment.id}/start`)
|
||||
.then(() => {
|
||||
toast.success(`The assignment "${assignment.name}" has been started successfully!`);
|
||||
})
|
||||
.catch((e) => {
|
||||
console.log(e);
|
||||
toast.error("Something went wrong, please try again later!");
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const formatTimestamp = (timestamp: string) => {
|
||||
const date = moment(parseInt(timestamp));
|
||||
const formatter = "YYYY/MM/DD - HH:mm";
|
||||
|
||||
return date.format(formatter);
|
||||
};
|
||||
|
||||
const calculateAverageModuleScore = (module: Module) => {
|
||||
if (!assignment) return -1;
|
||||
|
||||
const resultModuleBandScores = assignment.results.map((r) => {
|
||||
const moduleStats = r.stats.filter((s) => s.module === module);
|
||||
|
||||
const correct = moduleStats.reduce((acc, curr) => acc + curr.score.correct, 0);
|
||||
const total = moduleStats.reduce((acc, curr) => acc + curr.score.total, 0);
|
||||
return calculateBandScore(correct, total, module, r.type);
|
||||
});
|
||||
|
||||
return resultModuleBandScores.length === 0 ? -1 : resultModuleBandScores.reduce((acc, curr) => acc + curr, 0) / assignment.results.length;
|
||||
};
|
||||
|
||||
const aggregateScoresByModule = (stats: Stat[]): { module: Module; total: number; missing: number; correct: number }[] => {
|
||||
const scores: {
|
||||
[key in Module]: { total: number; missing: number; correct: number };
|
||||
} = {
|
||||
reading: {
|
||||
total: 0,
|
||||
correct: 0,
|
||||
missing: 0,
|
||||
},
|
||||
listening: {
|
||||
total: 0,
|
||||
correct: 0,
|
||||
missing: 0,
|
||||
},
|
||||
writing: {
|
||||
total: 0,
|
||||
correct: 0,
|
||||
missing: 0,
|
||||
},
|
||||
speaking: {
|
||||
total: 0,
|
||||
correct: 0,
|
||||
missing: 0,
|
||||
},
|
||||
level: {
|
||||
total: 0,
|
||||
correct: 0,
|
||||
missing: 0,
|
||||
},
|
||||
};
|
||||
|
||||
stats.filter(x => !x.isPractice).forEach((x) => {
|
||||
scores[x.module!] = {
|
||||
total: scores[x.module!].total + x.score.total,
|
||||
correct: scores[x.module!].correct + x.score.correct,
|
||||
missing: scores[x.module!].missing + x.score.missing,
|
||||
};
|
||||
});
|
||||
|
||||
return Object.keys(scores)
|
||||
.filter((x) => scores[x as Module].total > 0)
|
||||
.map((x) => ({ module: x as Module, ...scores[x as Module] }));
|
||||
};
|
||||
|
||||
const customContent = (stats: Stat[], user: string, focus: "academic" | "general") => {
|
||||
const correct = stats.reduce((accumulator, current) => accumulator + current.score.correct, 0);
|
||||
const total = stats.reduce((accumulator, current) => accumulator + current.score.total, 0);
|
||||
const aggregatedScores = aggregateScoresByModule(stats).filter((x) => x.total > 0);
|
||||
|
||||
const aggregatedLevels = aggregatedScores.map((x) => ({
|
||||
module: x.module,
|
||||
level: calculateBandScore(x.correct, x.total, x.module, focus),
|
||||
}));
|
||||
|
||||
const timeSpent = stats[0].timeSpent;
|
||||
|
||||
const selectExam = () => {
|
||||
const examPromises = uniqBy(stats, "exam").map((stat) => getExamById(stat.module, stat.exam));
|
||||
|
||||
Promise.all(examPromises).then((exams) => {
|
||||
if (exams.every((x) => !!x)) {
|
||||
dispatch({
|
||||
type: 'INIT_SOLUTIONS', payload: {
|
||||
exams: exams.map((x) => x!).sort(sortByModule),
|
||||
modules: exams
|
||||
.map((x) => x!)
|
||||
.sort(sortByModule)
|
||||
.map((x) => x!.module),
|
||||
stats
|
||||
}
|
||||
});
|
||||
router.push("/exam");
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const content = (
|
||||
<>
|
||||
<div className="-md:items-center flex w-full justify-between 2xl:items-center">
|
||||
<div className="-md:gap-2 -md:items-center flex md:flex-col md:gap-1 2xl:flex-row 2xl:items-center 2xl:gap-2">
|
||||
<span className="font-medium">{formatTimestamp(stats[0].date.toString())}</span>
|
||||
{timeSpent && (
|
||||
<>
|
||||
<span className="md:hidden 2xl:flex">• </span>
|
||||
<span className="text-sm">{Math.floor(timeSpent / 60)} minutes</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<span
|
||||
className={clsx(
|
||||
correct / total >= 0.7 && "text-mti-purple",
|
||||
correct / total >= 0.3 && correct / total < 0.7 && "text-mti-red",
|
||||
correct / total < 0.3 && "text-mti-rose",
|
||||
)}>
|
||||
Level{" "}
|
||||
{(aggregatedLevels.reduce((accumulator, current) => accumulator + current.level, 0) / aggregatedLevels.length).toFixed(1)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex w-full flex-col gap-1">
|
||||
<div className="-md:mt-2 grid w-full grid-cols-4 place-items-start gap-2">
|
||||
{aggregatedLevels.map(({ module, level }) => (
|
||||
<div
|
||||
key={module}
|
||||
className={clsx(
|
||||
"-md:px-4 flex w-fit items-center gap-2 rounded-xl py-2 text-white md:px-2 xl:px-4",
|
||||
module === "reading" && "bg-ielts-reading",
|
||||
module === "listening" && "bg-ielts-listening",
|
||||
module === "writing" && "bg-ielts-writing",
|
||||
module === "speaking" && "bg-ielts-speaking",
|
||||
module === "level" && "bg-ielts-level",
|
||||
)}>
|
||||
{module === "reading" && <BsBook className="h-4 w-4" />}
|
||||
{module === "listening" && <BsHeadphones className="h-4 w-4" />}
|
||||
{module === "writing" && <BsPen className="h-4 w-4" />}
|
||||
{module === "speaking" && <BsMegaphone className="h-4 w-4" />}
|
||||
{module === "level" && <BsClipboard className="h-4 w-4" />}
|
||||
<span className="text-sm">{level.toFixed(1)}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
<span>
|
||||
{(() => {
|
||||
const student = users.find((u) => u.id === user);
|
||||
return `${student?.name} (${student?.email})`;
|
||||
})()}
|
||||
</span>
|
||||
<div
|
||||
key={user}
|
||||
className={clsx(
|
||||
"border-mti-gray-platinum -md:hidden flex cursor-pointer flex-col gap-4 rounded-xl border p-4 transition duration-300 ease-in-out",
|
||||
correct / total >= 0.7 && "hover:border-mti-purple",
|
||||
correct / total >= 0.3 && correct / total < 0.7 && "hover:border-mti-red",
|
||||
correct / total < 0.3 && "hover:border-mti-rose",
|
||||
)}
|
||||
onClick={selectExam}
|
||||
role="button">
|
||||
{content}
|
||||
</div>
|
||||
<div
|
||||
key={user}
|
||||
className={clsx(
|
||||
"border-mti-gray-platinum -md:tooltip flex cursor-pointer flex-col gap-4 rounded-xl border p-4 transition duration-300 ease-in-out md:hidden",
|
||||
correct / total >= 0.7 && "hover:border-mti-purple",
|
||||
correct / total >= 0.3 && correct / total < 0.7 && "hover:border-mti-red",
|
||||
correct / total < 0.3 && "hover:border-mti-rose",
|
||||
)}
|
||||
data-tip="Your screen size is too small to view previous exams."
|
||||
role="button">
|
||||
{content}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const shouldRenderStart = () => {
|
||||
if (assignment) {
|
||||
if (futureAssignmentFilter(assignment)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal isOpen={isOpen} onClose={onClose} title={assignment?.name}>
|
||||
<div className="mt-4 flex w-full flex-col gap-4">
|
||||
<ProgressBar
|
||||
color="purple"
|
||||
label={`${assignment?.results.length}/${assignment?.assignees.length} assignees completed`}
|
||||
className="h-6"
|
||||
textClassName={
|
||||
(assignment?.results.length || 0) / (assignment?.assignees.length || 1) < 0.5 ? "!text-mti-gray-dim font-light" : "text-white"
|
||||
}
|
||||
percentage={((assignment?.results.length || 0) / (assignment?.assignees.length || 1)) * 100}
|
||||
/>
|
||||
<div className="flex items-start gap-8">
|
||||
<div className="flex flex-col gap-2">
|
||||
<span>Start Date: {moment(assignment?.startDate).format("DD/MM/YY, HH:mm")}</span>
|
||||
<span>End Date: {moment(assignment?.endDate).format("DD/MM/YY, HH:mm")}</span>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<span>
|
||||
Assignees:{" "}
|
||||
{users
|
||||
.filter((u) => assignment?.assignees.includes(u.id))
|
||||
.map((u) => `${u.name} (${u.email})`)
|
||||
.join(", ")}
|
||||
</span>
|
||||
<span>Assigner: {getUserName(users.find((x) => x.id === assignment?.assigner))}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<span className="text-xl font-bold">Average Scores</span>
|
||||
<div className="-md:mt-2 flex w-full items-center gap-4">
|
||||
{assignment &&
|
||||
uniqBy(assignment.exams, (x) => x.module).map(({ module }) => (
|
||||
<div
|
||||
data-tip={capitalize(module)}
|
||||
key={module}
|
||||
className={clsx(
|
||||
"-md:px-4 tooltip flex w-fit items-center gap-2 rounded-xl py-2 text-white md:px-2 xl:px-4",
|
||||
module === "reading" && "bg-ielts-reading",
|
||||
module === "listening" && "bg-ielts-listening",
|
||||
module === "writing" && "bg-ielts-writing",
|
||||
module === "speaking" && "bg-ielts-speaking",
|
||||
module === "level" && "bg-ielts-level",
|
||||
)}>
|
||||
{module === "reading" && <BsBook className="h-4 w-4" />}
|
||||
{module === "listening" && <BsHeadphones className="h-4 w-4" />}
|
||||
{module === "writing" && <BsPen className="h-4 w-4" />}
|
||||
{module === "speaking" && <BsMegaphone className="h-4 w-4" />}
|
||||
{module === "level" && <BsClipboard className="h-4 w-4" />}
|
||||
{calculateAverageModuleScore(module) > -1 && (
|
||||
<span className="text-sm">{calculateAverageModuleScore(module).toFixed(1)}</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<span className="text-xl font-bold">
|
||||
Results ({assignment?.results.length}/{assignment?.assignees.length})
|
||||
</span>
|
||||
<div>
|
||||
{assignment && assignment?.results.length > 0 && (
|
||||
<div className="grid w-full grid-cols-1 gap-4 md:grid-cols-2 xl:grid-cols-3 xl:gap-6">
|
||||
{assignment.results.map((r) => customContent(r.stats, r.user, r.type))}
|
||||
</div>
|
||||
)}
|
||||
{assignment && assignment?.results.length === 0 && <span className="ml-1 font-semibold">No results yet...</span>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-4 w-full items-center justify-end">
|
||||
{assignment && (assignment.results.length === assignment.assignees.length || moment().isAfter(moment(assignment.endDate))) && (
|
||||
<Button variant="outline" color="red" className="w-full max-w-[200px]" onClick={deleteAssignment}>
|
||||
Delete
|
||||
</Button>
|
||||
)}
|
||||
{/** if the assignment is not deemed as active yet, display start */}
|
||||
{shouldRenderStart() && (
|
||||
<Button variant="outline" color="green" className="w-full max-w-[200px]" onClick={startAssignment}>
|
||||
Start
|
||||
</Button>
|
||||
)}
|
||||
<Button onClick={onClose} className="w-full max-w-[200px]">
|
||||
Close
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
63
src/components/BottomBar.tsx
Normal file
@@ -0,0 +1,63 @@
|
||||
import clsx from "clsx";
|
||||
import {IconType} from "react-icons";
|
||||
import {MdSpaceDashboard} from "react-icons/md";
|
||||
import {BsFileEarmarkText, BsClockHistory, BsPencil, BsGraphUp} from "react-icons/bs";
|
||||
import {RiLogoutBoxFill} from "react-icons/ri";
|
||||
import {SlPencil} from "react-icons/sl";
|
||||
import {FaAward} from "react-icons/fa";
|
||||
import Link from "next/link";
|
||||
import {useRouter} from "next/router";
|
||||
import axios from "axios";
|
||||
import FocusLayer from "@/components/FocusLayer";
|
||||
import {preventNavigation} from "@/utils/navigation.disabled";
|
||||
interface Props {
|
||||
path: string;
|
||||
navDisabled?: boolean;
|
||||
focusMode?: boolean;
|
||||
onFocusLayerMouseEnter?: () => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
interface NavProps {
|
||||
Icon: IconType;
|
||||
label: string;
|
||||
path: string;
|
||||
keyPath: string;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
const Nav = ({Icon, label, path, keyPath, disabled = false}: NavProps) => (
|
||||
<Link
|
||||
href={!disabled ? keyPath : ""}
|
||||
className={clsx(
|
||||
"p-4 rounded-full flex gap-4 items-center cursor-pointer text-gray-500 hover:bg-mti-purple-light hover:text-white transition duration-300 ease-in-out",
|
||||
path === keyPath && "bg-mti-purple-light text-white",
|
||||
)}>
|
||||
<Icon size={20} />
|
||||
</Link>
|
||||
);
|
||||
|
||||
export default function BottomBar({path, navDisabled = false, focusMode = false, onFocusLayerMouseEnter, className}: Props) {
|
||||
const router = useRouter();
|
||||
|
||||
const logout = async () => {
|
||||
axios.post("/api/logout").finally(() => {
|
||||
setTimeout(() => router.reload(), 500);
|
||||
});
|
||||
};
|
||||
|
||||
const disableNavigation = preventNavigation(navDisabled, focusMode);
|
||||
|
||||
return (
|
||||
<section className={clsx("w-full bg-white py-2 drop-shadow-2xl shadow-2xl rounded-t-2xl", className)}>
|
||||
<div className="flex justify-around gap-3">
|
||||
<Nav disabled={disableNavigation} Icon={MdSpaceDashboard} label="Dashboard" path={path} keyPath="/" />
|
||||
<Nav disabled={disableNavigation} Icon={BsFileEarmarkText} label="Exams" path={path} keyPath="/exam" />
|
||||
<Nav disabled={disableNavigation} Icon={BsPencil} label="Exercises" path={path} keyPath="/exercises" />
|
||||
<Nav disabled={disableNavigation} Icon={BsGraphUp} label="Stats" path={path} keyPath="/stats" />
|
||||
<Nav disabled={disableNavigation} Icon={BsClockHistory} label="Record" path={path} keyPath="/record" />
|
||||
</div>
|
||||
{focusMode && <FocusLayer onFocusLayerMouseEnter={onFocusLayerMouseEnter} />}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
149
src/components/DemographicInformationInput.tsx
Normal file
@@ -0,0 +1,149 @@
|
||||
import {EmploymentStatus, EMPLOYMENT_STATUS, Gender, User} from "@/interfaces/user";
|
||||
import {FormEvent, useEffect, useState} from "react";
|
||||
import countryCodes from "country-codes-list";
|
||||
import {RadioGroup} from "@headlessui/react";
|
||||
import Input from "./Low/Input";
|
||||
import clsx from "clsx";
|
||||
import Button from "./Low/Button";
|
||||
import {BsArrowRepeat} from "react-icons/bs";
|
||||
import axios from "axios";
|
||||
import {toast} from "react-toastify";
|
||||
import {KeyedMutator} from "swr";
|
||||
import CountrySelect from "./Low/CountrySelect";
|
||||
import GenderInput from "@/components/High/GenderInput";
|
||||
import EmploymentStatusInput from "@/components/High/EmploymentStatusInput";
|
||||
import TimezoneSelect from "./Low/TImezoneSelect";
|
||||
import moment from "moment";
|
||||
|
||||
interface Props {
|
||||
user: User;
|
||||
mutateUser: (user: User) => void;
|
||||
}
|
||||
|
||||
export default function DemographicInformationInput({user, mutateUser}: Props) {
|
||||
const [country, setCountry] = useState(user.demographicInformation?.country);
|
||||
const [phone, setPhone] = useState(user.demographicInformation?.phone);
|
||||
const [passport_id, setPassportID] = useState<string | undefined>(user.type === "student" ? user.demographicInformation?.passport_id : undefined);
|
||||
const [gender, setGender] = useState<Gender>();
|
||||
const [employment, setEmployment] = useState<EmploymentStatus>();
|
||||
const [timezone, setTimezone] = useState<string>(moment.tz.guess());
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [position, setPosition] = useState(
|
||||
user.type === "corporate" || user.type === "mastercorporate"
|
||||
? user.demographicInformation?.position
|
||||
: user.demographicInformation?.employment,
|
||||
);
|
||||
|
||||
const [companyName, setCompanyName] = useState<string>();
|
||||
const [commercialRegistration, setCommercialRegistration] = useState<string>();
|
||||
|
||||
const save = (e?: FormEvent) => {
|
||||
if (e) e.preventDefault();
|
||||
setIsLoading(true);
|
||||
|
||||
axios
|
||||
.patch<{user: User}>("/api/users/update", {
|
||||
demographicInformation: {
|
||||
country,
|
||||
phone: `+${countryCodes.findOne("countryCode" as any, country!).countryCallingCode}${phone}`,
|
||||
gender,
|
||||
employment: user.type === "corporate" ? undefined : employment,
|
||||
position: user.type === "corporate" ? position : undefined,
|
||||
passport_id,
|
||||
timezone,
|
||||
},
|
||||
agentInformation: user.type === "agent" ? {companyName, commercialRegistration} : undefined,
|
||||
})
|
||||
.then((response) => mutateUser(response.data.user))
|
||||
.catch(() => {
|
||||
toast.error("Something went wrong, please try again later!", {toastId: "user-update-error"});
|
||||
})
|
||||
.finally(() => setIsLoading(false));
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center gap-12 w-full">
|
||||
<h2 className="font-semibold text-center text-xl max-w-[800px]">
|
||||
Welcome to EnCoach, the ultimate platform dedicated to helping you master the IELTS ! We are thrilled that you have chosen us as your
|
||||
learning companion on this journey towards achieving your desired IELTS score.
|
||||
<br />
|
||||
<br />
|
||||
To make the most of your learning experience, we kindly request you to complete your profile. By providing some essential information
|
||||
about yourself.
|
||||
</h2>
|
||||
<form className="flex flex-col items-center justify-items-center gap-6 w-full h-full -md:px-4 lg:w-1/2 mb-32" onSubmit={save}>
|
||||
{user.type === "agent" && (
|
||||
<div className="w-full flex gap-8">
|
||||
<Input type="text" onChange={setCompanyName} name="companyName" label="Corporate Name" required />
|
||||
<Input
|
||||
type="text"
|
||||
onChange={setCommercialRegistration}
|
||||
name="commercialRegistration"
|
||||
label="Commercial Registration"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className="w-full grid grid-cols-2 gap-6">
|
||||
<div className="relative flex flex-col gap-3 w-full">
|
||||
<label className="font-normal text-base text-mti-gray-dim">Country *</label>
|
||||
<CountrySelect value={country} onChange={setCountry} />
|
||||
</div>
|
||||
<Input
|
||||
type="tel"
|
||||
name="phone"
|
||||
label="Phone number"
|
||||
onChange={(e) => setPhone(e)}
|
||||
value={phone}
|
||||
placeholder="Enter phone number"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
{user.type === "student" && (
|
||||
<Input
|
||||
type="text"
|
||||
name="passport_id"
|
||||
label="Passport/National ID"
|
||||
onChange={(e) => setPassportID(e)}
|
||||
value={passport_id}
|
||||
placeholder="Enter National ID or Passport number"
|
||||
required
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="flex flex-col gap-3 w-full">
|
||||
<label className="font-normal text-base text-mti-gray-dim">Timezone</label>
|
||||
<TimezoneSelect value={timezone} onChange={setTimezone} />
|
||||
</div>
|
||||
|
||||
<GenderInput value={gender} onChange={setGender} />
|
||||
{user.type === "corporate" && (
|
||||
<Input name="position" onChange={setPosition} type="text" label="Department" placeholder="CEO, Head of Marketing..." required />
|
||||
)}
|
||||
{user.type !== "corporate" && <EmploymentStatusInput value={employment} onChange={setEmployment} />}
|
||||
</form>
|
||||
|
||||
<div className="self-end flex justify-end w-full gap-8 absolute bottom-8 left-0 px-8">
|
||||
<Button
|
||||
className="lg:mt-8 max-w-[400px] w-full self-end"
|
||||
color="purple"
|
||||
onClick={save}
|
||||
disabled={
|
||||
isLoading ||
|
||||
!country ||
|
||||
!phone ||
|
||||
!gender ||
|
||||
(user.type === "corporate" ? !position : !employment) ||
|
||||
(user.type === "agent" ? !companyName || !commercialRegistration : false)
|
||||
}>
|
||||
{!isLoading && "Save information"}
|
||||
{isLoading && (
|
||||
<div className="flex items-center justify-center">
|
||||
<BsArrowRepeat className="text-white animate-spin" size={25} />
|
||||
</div>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,46 +1,41 @@
|
||||
import {infoButtonStyle} from "@/constants/buttonStyles";
|
||||
import {BAND_SCORES} from "@/constants/ielts";
|
||||
import {Module} from "@/interfaces";
|
||||
import {User} from "@/interfaces/user";
|
||||
import useExamStore from "@/stores/examStore";
|
||||
import {getExamById} from "@/utils/exams";
|
||||
import useExamStore from "@/stores/exam";
|
||||
import {getExam} from "@/utils/exams";
|
||||
import {MODULE_ARRAY} from "@/utils/moduleUtils";
|
||||
import axios from "axios";
|
||||
import clsx from "clsx";
|
||||
import {capitalize} from "lodash";
|
||||
import {useRouter} from "next/router";
|
||||
import {useState} from "react";
|
||||
import { useState} from "react";
|
||||
import { BsQuestionSquare} from "react-icons/bs";
|
||||
import {toast} from "react-toastify";
|
||||
import Button from "./Low/Button";
|
||||
import ModuleLevelSelector from "./Medium/ModuleLevelSelector";
|
||||
|
||||
interface Props {
|
||||
user: User;
|
||||
onFinish: () => void;
|
||||
}
|
||||
|
||||
const DIAGNOSTIC_EXAMS = [
|
||||
["reading", "CurQtQoxWmHaJHeN0JW2"],
|
||||
["listening", "Y6cMao8kUcVnPQOo6teV"],
|
||||
["writing", "hbueuDaEZXV37EW7I12A"],
|
||||
["speaking", "QVFm4pdcziJQZN2iUTDo"],
|
||||
];
|
||||
|
||||
export default function Diagnostic({onFinish}: Props) {
|
||||
const [focus, setFocus] = useState<"academic" | "general">();
|
||||
const [isInsert, setIsInsert] = useState(false);
|
||||
const [levels, setLevels] = useState({reading: 0, listening: 0, writing: 0, speaking: 0});
|
||||
const [desiredLevels, setDesiredLevels] = useState({reading: 9, listening: 9, writing: 9, speaking: 9});
|
||||
const [levels, setLevels] = useState({reading: -1, listening: -1, writing: -1, speaking: -1, level: 0});
|
||||
const [desiredLevels, setDesiredLevels] = useState({reading: 9, listening: 9, writing: 9, speaking: 9, level: 9});
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
const setExams = useExamStore((state) => state.setExams);
|
||||
const setSelectedModules = useExamStore((state) => state.setSelectedModules);
|
||||
const dispatch = useExamStore((state) => state.dispatch);
|
||||
|
||||
const isNextDisabled = () => {
|
||||
if (!focus) return true;
|
||||
return Object.values(levels).includes(-1);
|
||||
};
|
||||
|
||||
const selectExam = () => {
|
||||
const examPromises = DIAGNOSTIC_EXAMS.map((exam) => getExamById(exam[0] as Module, exam[1]));
|
||||
const examPromises = MODULE_ARRAY.map((module) => getExam(module, true, "partial"));
|
||||
|
||||
Promise.all(examPromises).then((exams) => {
|
||||
if (exams.every((x) => !!x)) {
|
||||
setExams(exams.map((x) => x!));
|
||||
setSelectedModules(exams.map((x) => x!.module));
|
||||
dispatch({type: 'INIT_EXAM', payload: {exams: exams.map((x) => x!), modules: exams.map((x) => x!.module)}})
|
||||
router.push("/exam");
|
||||
}
|
||||
});
|
||||
@@ -48,72 +43,85 @@ export default function Diagnostic({onFinish}: Props) {
|
||||
|
||||
const updateUser = (callback: () => void) => {
|
||||
axios
|
||||
.patch("/api/users/update", {focus, levels, desiredLevels, isFirstLogin: false})
|
||||
.patch("/api/users/update", {
|
||||
focus,
|
||||
levels: Object.values(levels).includes(-1) ? {reading: 0, listening: 0, writing: 0, speaking: 0, level: 0} : levels,
|
||||
desiredLevels,
|
||||
isFirstLogin: false,
|
||||
})
|
||||
.then(callback)
|
||||
.catch(() => {
|
||||
toast.error("Something went wrong, please try again later!", {toastId: "user-update-error"});
|
||||
});
|
||||
};
|
||||
|
||||
if (!focus) {
|
||||
return (
|
||||
<div className="bg-white p-16 rounded-2xl flex flex-col items-center justify-center gap-8 h-96 relative shadow-md">
|
||||
<h2 className="absolute top-8 font-semibold text-xl">What is your focus?</h2>
|
||||
<div className="flex flex-col gap-4 justify-self-stretch">
|
||||
<button onClick={() => setFocus("academic")} className={clsx("btn btn-wide gap-4 relative text-white", infoButtonStyle)}>
|
||||
Academic
|
||||
</button>
|
||||
<button onClick={() => setFocus("general")} className={clsx("btn btn-wide gap-4 relative text-white", infoButtonStyle)}>
|
||||
General
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (isInsert) {
|
||||
return (
|
||||
<div className="bg-white p-16 rounded-2xl flex flex-col items-center justify-center gap-8 shadow-md">
|
||||
<h2 className="font-semibold text-xl">What is your level?</h2>
|
||||
<div className="flex w-full flex-col gap-4 justify-self-stretch">
|
||||
{Object.keys(levels).map((module) => (
|
||||
<div key={module} className="flex items-center gap-4 justify-between">
|
||||
<span className="font-medium text-lg">{capitalize(module)}</span>
|
||||
<input
|
||||
type="number"
|
||||
className={clsx(
|
||||
"input input-bordered bg-white w-24",
|
||||
!BAND_SCORES[module as Module].includes(levels[module as keyof typeof levels]) && "input-error",
|
||||
)}
|
||||
value={levels[module as keyof typeof levels]}
|
||||
min={0}
|
||||
max={9}
|
||||
step={0.5}
|
||||
onChange={(e) => setLevels((prev) => ({...prev, [module]: parseFloat(e.target.value)}))}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<button
|
||||
onClick={() => updateUser(onFinish)}
|
||||
className={clsx("btn btn-wide gap-4 relative text-white", infoButtonStyle)}
|
||||
disabled={!Object.keys(levels).every((module) => BAND_SCORES[module as Module].includes(levels[module as keyof typeof levels]))}>
|
||||
Next
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-white p-16 rounded-2xl flex flex-col items-center justify-center gap-8 h-96 relative shadow-md">
|
||||
<h2 className="absolute top-8 font-semibold text-xl">What is your current IELTS level?</h2>
|
||||
<div className="flex flex-col gap-4">
|
||||
<button onClick={() => setIsInsert(true)} className={clsx("btn btn-wide gap-4 relative text-white", infoButtonStyle)}>
|
||||
Insert my IELTS level
|
||||
</button>
|
||||
<button onClick={() => updateUser(selectExam)} className={clsx("btn btn-wide gap-4 relative text-white", infoButtonStyle)}>
|
||||
Perform a Diagnosis Test
|
||||
</button>
|
||||
<div className="flex flex-col items-center justify-center gap-12 w-full">
|
||||
<div className="flex flex-col items-center justify-center gap-8 w-full">
|
||||
<h2 className="font-semibold text-xl">What is your current focus?</h2>
|
||||
<div className="flex flex-col gap-16 w-full">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-y-4 gap-x-16">
|
||||
<button
|
||||
onClick={() => setFocus("academic")}
|
||||
className={clsx(
|
||||
"w-full border border-mti-gray-platinum rounded-full px-6 py-4 flex justify-center items-center gap-12 bg-white",
|
||||
"hover:bg-mti-purple-light hover:text-white",
|
||||
focus === "academic" && "!bg-mti-purple-light !text-white",
|
||||
"transition duration-300 ease-in-out",
|
||||
)}>
|
||||
Academic
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setFocus("general")}
|
||||
className={clsx(
|
||||
"w-full border border-mti-gray-platinum rounded-full px-6 py-4 flex justify-center items-center gap-12 bg-white",
|
||||
"hover:bg-mti-purple-light hover:text-white",
|
||||
focus === "general" && "!bg-mti-purple-light !text-white",
|
||||
"transition duration-300 ease-in-out",
|
||||
)}>
|
||||
General
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col items-center justify-center gap-8 w-full">
|
||||
<h2 className="font-semibold text-xl">What is your current IELTS level?</h2>
|
||||
<ModuleLevelSelector levels={levels} setLevels={setLevels} />
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col items-center justify-center gap-8 w-full mb-44">
|
||||
<h2 className="font-semibold text-xl">What is your desired IELTS level?</h2>
|
||||
<ModuleLevelSelector levels={desiredLevels} setLevels={setDesiredLevels} />
|
||||
</div>
|
||||
|
||||
<div className="md:self-end flex -md:flex-col justify-between w-full gap-8 absolute bottom-8 left-0 px-4 md:px-8">
|
||||
<div className="w-full tooltip" data-tip="Your screen size is too small to perform a diagnostic test">
|
||||
<Button
|
||||
color="purple"
|
||||
variant="outline"
|
||||
className="group flex items-center justify-center gap-6 relative md:max-w-[400px] w-full md:hidden"
|
||||
disabled>
|
||||
<BsQuestionSquare className="text-mti-purple-light transition duration-300 ease-in-out" size={20} />
|
||||
<span>Perform diagnostic test instead</span>
|
||||
</Button>
|
||||
</div>
|
||||
<Button
|
||||
onClick={() => updateUser(selectExam)}
|
||||
color="purple"
|
||||
variant="outline"
|
||||
className="group flex items-center justify-center gap-6 relative md:max-w-[400px] w-full -md:hidden"
|
||||
disabled={!focus}>
|
||||
<BsQuestionSquare
|
||||
className="text-mti-purple-light group-hover:text-white transition duration-300 ease-in-out"
|
||||
size={20}
|
||||
onClick={() => updateUser(selectExam)}
|
||||
/>
|
||||
<span onClick={() => updateUser(selectExam)}>Perform diagnostic test instead</span>
|
||||
</Button>
|
||||
<Button color="purple" className="md:max-w-[400px] w-full" onClick={() => updateUser(onFinish)} disabled={isNextDisabled()}>
|
||||
Next Step
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
104
src/components/Dropdown.tsx
Normal file
@@ -0,0 +1,104 @@
|
||||
import React, { useState, ReactNode, useRef, useEffect } from 'react';
|
||||
import { animated, useSpring } from '@react-spring/web';
|
||||
|
||||
interface DropdownProps {
|
||||
title?: ReactNode;
|
||||
open?: boolean;
|
||||
setIsOpen?: React.Dispatch<React.SetStateAction<boolean>> | ((isOpen: boolean) => void);
|
||||
className?: string;
|
||||
contentWrapperClassName?: string;
|
||||
titleClassName?: string;
|
||||
bottomPadding?: number;
|
||||
disabled?: boolean,
|
||||
wrapperClassName?: string;
|
||||
customTitle?: ReactNode;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
const Dropdown: React.FC<DropdownProps> = ({
|
||||
title,
|
||||
open = false,
|
||||
titleClassName = "",
|
||||
setIsOpen: externalSetIsOpen,
|
||||
className = "w-full text-left font-semibold flex justify-between items-center p-4",
|
||||
contentWrapperClassName = "px-6",
|
||||
bottomPadding = 12,
|
||||
disabled = false,
|
||||
customTitle = undefined,
|
||||
wrapperClassName,
|
||||
children
|
||||
}) => {
|
||||
const [internalIsOpen, setInternalIsOpen] = useState<boolean>(open);
|
||||
const isOpen = externalSetIsOpen !== undefined ? open : internalIsOpen;
|
||||
const toggleOpen = externalSetIsOpen !== undefined ? externalSetIsOpen : setInternalIsOpen;
|
||||
|
||||
const contentRef = useRef<HTMLDivElement>(null);
|
||||
const [contentHeight, setContentHeight] = useState<number>(0);
|
||||
|
||||
useEffect(() => {
|
||||
let resizeObserver: ResizeObserver | null = null;
|
||||
|
||||
if (contentRef.current) {
|
||||
resizeObserver = new ResizeObserver(entries => {
|
||||
for (let entry of entries) {
|
||||
if (entry.borderBoxSize && entry.borderBoxSize.length > 0) {
|
||||
const height = entry.borderBoxSize[0].blockSize;
|
||||
setContentHeight(height + bottomPadding);
|
||||
} else {
|
||||
// Fallback for browsers that don't support borderBoxSize
|
||||
const height = entry.contentRect.height;
|
||||
setContentHeight(height + bottomPadding);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
resizeObserver.observe(contentRef.current);
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (resizeObserver) {
|
||||
resizeObserver.disconnect();
|
||||
}
|
||||
};
|
||||
}, [bottomPadding]);
|
||||
|
||||
const springProps = useSpring({
|
||||
height: isOpen ? contentHeight : 0,
|
||||
opacity: isOpen ? 1 : 0,
|
||||
config: { tension: 300, friction: 30 }
|
||||
});
|
||||
|
||||
return (
|
||||
<div className={wrapperClassName}>
|
||||
<button
|
||||
onClick={() => toggleOpen(!isOpen)}
|
||||
className={className}
|
||||
disabled={disabled}
|
||||
>
|
||||
<div className='flex flex-row w-full justify-between items-center'>
|
||||
{customTitle ? (
|
||||
customTitle
|
||||
) : (
|
||||
<p className={titleClassName}>{title}</p>
|
||||
)}
|
||||
<svg
|
||||
className={`w-4 h-4 transform transition-transform ${isOpen ? 'rotate-180' : ''}`}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
|
||||
</svg>
|
||||
</div>
|
||||
</button>
|
||||
<animated.div style={springProps} className="overflow-hidden">
|
||||
<div ref={contentRef} className={contentWrapperClassName} style={{ paddingBottom: bottomPadding }}>
|
||||
{children}
|
||||
</div>
|
||||
</animated.div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Dropdown;
|
||||
307
src/components/ExamEditor/ExercisePicker/ExerciseWizard.tsx
Normal file
@@ -0,0 +1,307 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { Tooltip } from 'react-tooltip';
|
||||
import { ExerciseGen } from './generatedExercises';
|
||||
import Image from 'next/image';
|
||||
import clsx from 'clsx';
|
||||
import { GiBrain } from 'react-icons/gi';
|
||||
import { IoTextOutline } from 'react-icons/io5';
|
||||
import { Switch } from '@headlessui/react';
|
||||
import useExamEditorStore from '@/stores/examEditor';
|
||||
import { Module } from '@/interfaces';
|
||||
import { capitalize } from 'lodash';
|
||||
import Select from '@/components/Low/Select';
|
||||
import { Difficulty } from '@/interfaces/exam';
|
||||
|
||||
interface Props {
|
||||
module: Module;
|
||||
sectionId: number;
|
||||
exercises: ExerciseGen[];
|
||||
extraArgs?: Record<string, any>;
|
||||
onSubmit: (configurations: ExerciseConfig[]) => void;
|
||||
onDiscard: () => void;
|
||||
selectedExercises: string[];
|
||||
}
|
||||
|
||||
export interface ExerciseConfig {
|
||||
type: string;
|
||||
params: {
|
||||
[key: string]: string | number | boolean;
|
||||
};
|
||||
}
|
||||
|
||||
const ExerciseWizard: React.FC<Props> = ({
|
||||
module,
|
||||
exercises,
|
||||
extraArgs,
|
||||
sectionId,
|
||||
selectedExercises,
|
||||
onSubmit,
|
||||
onDiscard,
|
||||
}) => {
|
||||
const [configurations, setConfigurations] = useState<ExerciseConfig[]>([]);
|
||||
const { currentModule } = useExamEditorStore();
|
||||
const { difficulty } = useExamEditorStore(state => state.modules[currentModule]);
|
||||
|
||||
const randomDiff = difficulty.length === 1
|
||||
? capitalize(difficulty[0])
|
||||
: difficulty.length == 0 ?
|
||||
"Random" :
|
||||
`Selected (${difficulty.sort().map(dif => capitalize(dif)).join(", ")})` as Difficulty;
|
||||
|
||||
const DIFFICULTIES = difficulty.length === 1
|
||||
? ["A1", "A2", "B1", "B2", "C1", "C2", "Random"]
|
||||
: ["A1", "A2", "B1", "B2", "C1", "C2", randomDiff, "Random"];
|
||||
|
||||
useEffect(() => {
|
||||
const initialConfigs = selectedExercises.map(exerciseType => {
|
||||
const exercise = exercises.find(ex => {
|
||||
const fullType = ex.extra?.find(e => e.param === 'name')?.value
|
||||
? `${ex.type}/?name=${ex.extra.find(e => e.param === 'name')?.value}`
|
||||
: ex.type;
|
||||
return fullType === exerciseType;
|
||||
});
|
||||
|
||||
const params: { [key: string]: string | number | boolean } = {};
|
||||
exercise?.extra?.forEach(param => {
|
||||
if (param.param !== 'name') {
|
||||
if (exerciseType.includes('paragraphMatch') && param.param === 'quantity') {
|
||||
params[param.param] = extraArgs?.text.split("\n\n").length || 1;
|
||||
} else {
|
||||
params[param.param || ''] = param.value ?? '';
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
type: exerciseType,
|
||||
params
|
||||
};
|
||||
});
|
||||
|
||||
setConfigurations(initialConfigs);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [selectedExercises, exercises]);
|
||||
|
||||
const handleParameterChange = (
|
||||
exerciseIndex: number,
|
||||
paramName: string,
|
||||
value: string | number | boolean
|
||||
) => {
|
||||
setConfigurations(prev => {
|
||||
const newConfigs = [...prev];
|
||||
newConfigs[exerciseIndex] = {
|
||||
...newConfigs[exerciseIndex],
|
||||
params: {
|
||||
...newConfigs[exerciseIndex].params,
|
||||
[paramName]: value
|
||||
}
|
||||
};
|
||||
return newConfigs;
|
||||
});
|
||||
};
|
||||
|
||||
const renderParameterInput = (
|
||||
param: NonNullable<ExerciseGen['extra']>[0],
|
||||
exerciseIndex: number,
|
||||
config: ExerciseConfig
|
||||
) => {
|
||||
if (typeof param.value === 'boolean') {
|
||||
const currentValue = Boolean(config.params[param.param || '']);
|
||||
return (
|
||||
<div className="flex flex-row items-center ml-auto">
|
||||
<GiBrain
|
||||
className="mx-4"
|
||||
size={28}
|
||||
color={currentValue ? `#F3F4F6` : `#1F2937`}
|
||||
/>
|
||||
<Switch
|
||||
checked={currentValue}
|
||||
onChange={(value) => handleParameterChange(
|
||||
exerciseIndex,
|
||||
param.param || '',
|
||||
value
|
||||
)}
|
||||
className={clsx(
|
||||
"relative inline-flex h-[30px] w-[58px] shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus-visible:ring-2 focus-visible:ring-white/75",
|
||||
currentValue ? `bg-[#F3F4F6]` : `bg-[#1F2937]`
|
||||
)}
|
||||
>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={clsx(
|
||||
"pointer-events-none inline-block h-[26px] w-[26px] transform rounded-full bg-white shadow-lg ring-0 transition duration-200 ease-in-out",
|
||||
currentValue ? 'translate-x-7' : 'translate-x-0'
|
||||
)}
|
||||
/>
|
||||
</Switch>
|
||||
<IoTextOutline
|
||||
className="mx-4"
|
||||
size={28}
|
||||
color={!currentValue ? `#F3F4F6` : `#1F2937`}
|
||||
/>
|
||||
|
||||
<Tooltip id={`${exerciseIndex}`} className="z-50 bg-white shadow-md rounded-sm" />
|
||||
<a data-tooltip-id={`${exerciseIndex}`} data-tooltip-html="Generate or use placeholder?" className='ml-1 flex items-center justify-center'>
|
||||
<Image src="/mat-icon-info.svg" width={24} height={24} alt={"AI Generated?"} />
|
||||
</a>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if ('type' in param && param.type === 'text') {
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<label className="text-sm font-medium text-white">
|
||||
{param.label}
|
||||
</label>
|
||||
{param.tooltip && (
|
||||
<>
|
||||
<Tooltip id={config.type} className="z-50 bg-white shadow-md rounded-sm" />
|
||||
<a data-tooltip-id={config.type} data-tooltip-html={param.tooltip} className='ml-1 flex items-center justify-center'>
|
||||
<Image src="/mat-icon-info.svg" width={24} height={24} alt={param.tooltip} />
|
||||
</a>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
value={config.params[param.param || ''] as string}
|
||||
onChange={(e) => handleParameterChange(
|
||||
exerciseIndex,
|
||||
param.param || '',
|
||||
e.target.value
|
||||
)}
|
||||
className="px-3 py-2 shadow-lg rounded-md text-mti-gray-dim w-full"
|
||||
placeholder="Enter here..."
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const inputValue = Number(config.params[param.param || '1'].toString()) || config.params[param.param!];
|
||||
const isParagraphMatch = config.type.split("?name=")[1] === "paragraphMatch";
|
||||
const maxParagraphs = isParagraphMatch ? extraArgs!.text.split("\n\n").length : 50;
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<label className="text-sm font-medium text-white">
|
||||
{`${param.label}${isParagraphMatch ? ` (out of ${extraArgs!.text.split("\n\n").length} paragraphs)` : ""}`}
|
||||
</label>
|
||||
{param.tooltip && (
|
||||
<>
|
||||
<Tooltip id={config.type} className="z-50 bg-white shadow-md rounded-sm" />
|
||||
<a data-tooltip-id={config.type} data-tooltip-html={param.tooltip} className='ml-1 flex items-center justify-center'>
|
||||
<Image src="/mat-icon-info.svg" width={24} height={24} alt={param.tooltip} />
|
||||
</a>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{param.param === "difficulty" ?
|
||||
<Select
|
||||
options={DIFFICULTIES.map((x) => ({ value: x, label: x }))}
|
||||
onChange={(value) => {
|
||||
handleParameterChange(
|
||||
exerciseIndex,
|
||||
param.param || '',
|
||||
value?.value || ''
|
||||
);
|
||||
}}
|
||||
value={{ value: config.params[param.param] !== "" ? config.params[param.param] as string : randomDiff , label: config.params[param.param] !== "" ? config.params[param.param] as string : randomDiff }}
|
||||
flat
|
||||
/>
|
||||
:
|
||||
<input
|
||||
type="number"
|
||||
value={inputValue as number}
|
||||
onChange={(e) => handleParameterChange(
|
||||
exerciseIndex,
|
||||
param.param || '',
|
||||
e.target.value ? Number(e.target.value) : ''
|
||||
)}
|
||||
className="px-3 py-2 shadow-lg rounded-md text-mti-gray-dim w-full"
|
||||
min={1}
|
||||
max={maxParagraphs}
|
||||
/>
|
||||
}
|
||||
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const renderExerciseHeader = (
|
||||
exercise: ExerciseGen,
|
||||
exerciseIndex: number,
|
||||
config: ExerciseConfig,
|
||||
extraParams: boolean,
|
||||
) => {
|
||||
const generateParam = exercise.extra?.find(param => param.param === 'generate');
|
||||
|
||||
return (
|
||||
<div className={clsx("flex items-center w-full", extraParams ? "mb-4" : "py-4")}>
|
||||
<div className="flex items-center gap-2">
|
||||
<exercise.icon className="h-5 w-5" />
|
||||
<h3 className="font-medium text-lg">{exercise.label}</h3>
|
||||
</div>
|
||||
{/* when placeholders are done uncomment this*/}
|
||||
{/*generateParam && renderParameterInput(generateParam, exerciseIndex, config)*/}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6 px-4 py-6">
|
||||
{configurations.map((config, exerciseIndex) => {
|
||||
const exercise = exercises.find(ex => {
|
||||
const fullType = ex.extra?.find(e => e.param === 'name')?.value
|
||||
? `${ex.type}/?name=${ex.extra.find(e => e.param === 'name')?.value}`
|
||||
: ex.type;
|
||||
return fullType === config.type;
|
||||
});
|
||||
|
||||
if (!exercise) return null;
|
||||
|
||||
const nonGenerateParams = exercise.extra?.filter(
|
||||
param => param.param !== 'name' && param.param !== 'generate'
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={config.type}
|
||||
className={`bg-ielts-${module}/70 text-white rounded-lg p-4 shadow-xl`}
|
||||
>
|
||||
{renderExerciseHeader(exercise, exerciseIndex, config, (exercise.extra || []).length > 2)}
|
||||
|
||||
{nonGenerateParams && nonGenerateParams.length > 0 && (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{nonGenerateParams.map(param => (
|
||||
<div key={param.param}>
|
||||
{renderParameterInput(param, exerciseIndex, config)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
<div className="flex justify-between">
|
||||
<button
|
||||
onClick={onDiscard}
|
||||
className={`px-4 py-2 bg-red-500 text-white rounded-md hover:bg-red-400 transition-colors`}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={() => onSubmit(configurations)}
|
||||
className={`px-4 py-2 bg-ielts-${module} text-white rounded-md hover:bg-ielts-${module}/80 transition-colors`}
|
||||
>
|
||||
Add Exercises
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ExerciseWizard;
|
||||
471
src/components/ExamEditor/ExercisePicker/exercises.ts
Normal file
@@ -0,0 +1,471 @@
|
||||
import {
|
||||
FaListUl,
|
||||
FaUnderline,
|
||||
FaPen,
|
||||
FaBookOpen,
|
||||
FaEnvelope,
|
||||
FaComments,
|
||||
FaHandshake,
|
||||
FaParagraph,
|
||||
FaLightbulb,
|
||||
FaHeadphones,
|
||||
FaWpforms,
|
||||
} from 'react-icons/fa6';
|
||||
|
||||
import {
|
||||
FaEdit,
|
||||
FaFileAlt,
|
||||
FaUserFriends,
|
||||
FaCheckSquare,
|
||||
FaQuestionCircle,
|
||||
} from 'react-icons/fa';
|
||||
import { ExerciseGen } from './generatedExercises';
|
||||
import { BsListCheck } from 'react-icons/bs';
|
||||
|
||||
const quantity = (quantity: number, tooltip?: string) => {
|
||||
return {
|
||||
param: "quantity",
|
||||
label: "Quantity",
|
||||
tooltip: tooltip ? tooltip : "Exercise Quantity",
|
||||
value: quantity
|
||||
}
|
||||
}
|
||||
|
||||
const difficulty = () => {
|
||||
return {
|
||||
param: "difficulty",
|
||||
label: "Difficulty",
|
||||
tooltip: "Exercise difficulty",
|
||||
}
|
||||
}
|
||||
|
||||
const generate = () => {
|
||||
return {
|
||||
param: "generate",
|
||||
value: true
|
||||
}
|
||||
}
|
||||
|
||||
const reading = (passage: number) => {
|
||||
const readingExercises = [
|
||||
{
|
||||
label: `Passage ${passage} - Multiple Choice`,
|
||||
type: `reading_${passage}`,
|
||||
icon: BsListCheck,
|
||||
sectionId: passage,
|
||||
extra: [
|
||||
{
|
||||
param: "name",
|
||||
value: "multipleChoice"
|
||||
},
|
||||
quantity(5, "Quantity of Multiple Choice Questions"),
|
||||
difficulty(),
|
||||
generate()
|
||||
],
|
||||
module: "reading"
|
||||
},
|
||||
{
|
||||
label: `Passage ${passage} - Fill Blanks`,
|
||||
type: `reading_${passage}`,
|
||||
icon: FaEdit,
|
||||
sectionId: passage,
|
||||
extra: [
|
||||
{
|
||||
param: "name",
|
||||
value: "fillBlanks"
|
||||
},
|
||||
{
|
||||
param: "num_random_words",
|
||||
label: "Random Words",
|
||||
tooltip: "Words that are not the solution",
|
||||
value: 1
|
||||
},
|
||||
quantity(4, "Quantity of Blanks"),
|
||||
difficulty(),
|
||||
generate()
|
||||
],
|
||||
module: "reading"
|
||||
},
|
||||
{
|
||||
label: `Passage ${passage} - Write Blanks`,
|
||||
type: `reading_${passage}`,
|
||||
icon: FaPen,
|
||||
sectionId: passage,
|
||||
extra: [
|
||||
{
|
||||
param: "name",
|
||||
value: "writeBlanks"
|
||||
},
|
||||
{
|
||||
param: "max_words",
|
||||
label: "Word Limit",
|
||||
tooltip: "How many words a solution can have",
|
||||
value: 3
|
||||
},
|
||||
quantity(4, "Quantity of Blanks"),
|
||||
difficulty(),
|
||||
generate()
|
||||
],
|
||||
module: "reading"
|
||||
},
|
||||
{
|
||||
label: `Passage ${passage} - True False`,
|
||||
type: `reading_${passage}`,
|
||||
icon: FaCheckSquare,
|
||||
sectionId: passage,
|
||||
extra: [
|
||||
{
|
||||
param: "name",
|
||||
value: "trueFalse"
|
||||
},
|
||||
quantity(4, "Quantity of Statements"),
|
||||
difficulty(),
|
||||
generate()
|
||||
],
|
||||
module: "reading"
|
||||
},
|
||||
{
|
||||
label: `Passage ${passage} - Paragraph Match`,
|
||||
type: `reading_${passage}`,
|
||||
icon: FaParagraph,
|
||||
sectionId: passage,
|
||||
extra: [
|
||||
{
|
||||
param: "name",
|
||||
value: "paragraphMatch"
|
||||
},
|
||||
quantity(5, "Quantity of Matches"),
|
||||
difficulty(),
|
||||
generate()
|
||||
],
|
||||
module: "reading"
|
||||
},
|
||||
];
|
||||
|
||||
if (passage === 3) {
|
||||
readingExercises.push(
|
||||
{
|
||||
label: `Passage 3 - Idea Match`,
|
||||
type: `reading_3`,
|
||||
icon: FaLightbulb,
|
||||
sectionId: passage,
|
||||
extra: [
|
||||
{
|
||||
param: "name",
|
||||
value: "ideaMatch"
|
||||
},
|
||||
quantity(5, "Quantity of Ideas"),
|
||||
difficulty(),
|
||||
generate()
|
||||
],
|
||||
module: "reading"
|
||||
},
|
||||
);
|
||||
}
|
||||
return readingExercises;
|
||||
}
|
||||
|
||||
const listening = (section: number) => {
|
||||
const listeningExercises = [
|
||||
{
|
||||
label: `Section ${section} - Multiple Choice`,
|
||||
type: `listening_${section}`,
|
||||
icon: FaHeadphones,
|
||||
sectionId: section,
|
||||
extra: [
|
||||
{
|
||||
param: "name",
|
||||
value: section == 3 ? "multipleChoice3Options" : "multipleChoice"
|
||||
},
|
||||
quantity(5, "Quantity of Multiple Choice Questions"),
|
||||
difficulty(),
|
||||
generate()
|
||||
],
|
||||
module: "listening"
|
||||
},
|
||||
{
|
||||
label: `Section ${section} - Write Blanks: Questions`,
|
||||
type: `listening_${section}`,
|
||||
icon: FaQuestionCircle,
|
||||
sectionId: section,
|
||||
extra: [
|
||||
{
|
||||
param: "name",
|
||||
value: "writeBlanksQuestions"
|
||||
},
|
||||
quantity(5, "Quantity of Blanks"),
|
||||
difficulty(),
|
||||
generate()
|
||||
],
|
||||
module: "listening"
|
||||
},
|
||||
{
|
||||
label: `Section ${section} - True False`,
|
||||
type: `listening_${section}`,
|
||||
icon: FaCheckSquare,
|
||||
sectionId: section,
|
||||
extra: [
|
||||
{
|
||||
param: "name",
|
||||
value: "trueFalse"
|
||||
},
|
||||
quantity(4, "Quantity of Statements"),
|
||||
difficulty(),
|
||||
generate()
|
||||
],
|
||||
module: "listening"
|
||||
},
|
||||
];
|
||||
|
||||
if (section === 1 || section === 4) {
|
||||
listeningExercises.push(
|
||||
{
|
||||
label: `Section ${section} - Write Blanks: Fill`,
|
||||
type: `listening_${section}`,
|
||||
icon: FaEdit,
|
||||
sectionId: section,
|
||||
extra: [
|
||||
{
|
||||
param: "name",
|
||||
value: "writeBlanksFill"
|
||||
},
|
||||
quantity(5, "Quantity of Blanks"),
|
||||
difficulty(),
|
||||
generate()
|
||||
],
|
||||
module: "listening"
|
||||
}
|
||||
);
|
||||
listeningExercises.push(
|
||||
{
|
||||
label: `Section ${section} - Write Blanks: Form`,
|
||||
type: `listening_${section}`,
|
||||
sectionId: section,
|
||||
icon: FaWpforms,
|
||||
extra: [
|
||||
{
|
||||
param: "name",
|
||||
value: "writeBlanksForm"
|
||||
},
|
||||
quantity(5, "Quantity of Blanks"),
|
||||
difficulty(),
|
||||
generate()
|
||||
],
|
||||
module: "listening"
|
||||
}
|
||||
);
|
||||
}
|
||||
return listeningExercises;
|
||||
}
|
||||
|
||||
const EXERCISES: ExerciseGen[] = [
|
||||
/*{
|
||||
label: "Multiple Choice",
|
||||
type: "multipleChoice",
|
||||
icon: FaListUl,
|
||||
extra: [
|
||||
{
|
||||
param: "name",
|
||||
value: "multipleChoice"
|
||||
},
|
||||
quantity(10, "Amount"),
|
||||
difficulty(),
|
||||
generate()
|
||||
],
|
||||
module: "level"
|
||||
},*/
|
||||
{
|
||||
label: "Multiple Choice: Blank Space",
|
||||
type: "mcBlank",
|
||||
icon: FaEdit,
|
||||
extra: [
|
||||
{
|
||||
param: "name",
|
||||
value: "mcBlank"
|
||||
},
|
||||
quantity(10, "Amount"),
|
||||
difficulty(),
|
||||
generate()
|
||||
],
|
||||
module: "level"
|
||||
},
|
||||
{
|
||||
label: "Multiple Choice: Underlined",
|
||||
type: "mcUnderline",
|
||||
icon: FaUnderline,
|
||||
extra: [
|
||||
{
|
||||
param: "name",
|
||||
value: "mcUnderline"
|
||||
},
|
||||
quantity(10, "Amount"),
|
||||
difficulty(),
|
||||
generate()
|
||||
],
|
||||
module: "level"
|
||||
},
|
||||
/*{
|
||||
label: "Blank Space", <- Assuming this is FillBlanks aswell
|
||||
type: "blankSpaceText",
|
||||
icon: FaPen,
|
||||
extra: [
|
||||
quantity(10, "Nº of Blanks"),
|
||||
{
|
||||
label: "Passage Word Size",
|
||||
param: "text_size",
|
||||
value: "250"
|
||||
},
|
||||
difficulty(),
|
||||
generate()
|
||||
],
|
||||
module: "level"
|
||||
},*/
|
||||
{
|
||||
label: "Fill Blanks: Multiple Choice",
|
||||
type: "fillBlanksMC",
|
||||
icon: FaPen,
|
||||
extra: [
|
||||
{
|
||||
param: "name",
|
||||
value: "fillBlanksMC"
|
||||
},
|
||||
quantity(10, "Nº of Blanks"),
|
||||
{
|
||||
label: "Passage Word Size",
|
||||
param: "text_size",
|
||||
value: "250"
|
||||
},
|
||||
difficulty(),
|
||||
generate()
|
||||
],
|
||||
module: "level"
|
||||
},
|
||||
// Removing this since level supports reading aswell
|
||||
/*{
|
||||
label: "Reading Passage: Multiple Choice",
|
||||
type: "passageUtas",
|
||||
icon: FaBookOpen,
|
||||
extra: [
|
||||
{
|
||||
param: "name",
|
||||
value: "passageUtas"
|
||||
},
|
||||
// in the utas exam there was only mc so I'm assuming short answers are deprecated
|
||||
//{
|
||||
// label: "Short Answers",
|
||||
// param: "sa_qty",
|
||||
// value: "10"
|
||||
//},
|
||||
quantity(10, "Multiple Choice Quantity"),
|
||||
{
|
||||
label: "Reading Passage Topic",
|
||||
param: "topic",
|
||||
value: "",
|
||||
type: "text"
|
||||
},
|
||||
{
|
||||
label: "Passage Word Size",
|
||||
param: "text_size",
|
||||
value: "700"
|
||||
},
|
||||
difficulty(),
|
||||
generate()
|
||||
],
|
||||
module: "level"
|
||||
},*/
|
||||
{
|
||||
label: "Task 1 - Letter",
|
||||
type: "writing_letter",
|
||||
icon: FaEnvelope,
|
||||
extra: [
|
||||
{
|
||||
label: "Letter Topic",
|
||||
param: "topic",
|
||||
value: "",
|
||||
type: "text"
|
||||
},
|
||||
difficulty(),
|
||||
generate()
|
||||
],
|
||||
module: "writing"
|
||||
},
|
||||
{
|
||||
label: "Task 2 - Essay",
|
||||
type: "writing_2",
|
||||
icon: FaFileAlt,
|
||||
extra: [
|
||||
{
|
||||
label: "Essay Topic",
|
||||
param: "topic",
|
||||
value: "",
|
||||
type: "text"
|
||||
},
|
||||
difficulty(),
|
||||
generate()
|
||||
],
|
||||
module: "writing"
|
||||
},
|
||||
{
|
||||
label: "Exercise 1",
|
||||
type: "speaking_1",
|
||||
icon: FaComments,
|
||||
extra: [
|
||||
difficulty(),
|
||||
generate(),
|
||||
{
|
||||
label: "First Topic",
|
||||
param: "first_topic",
|
||||
value: "",
|
||||
type: "text"
|
||||
},
|
||||
{
|
||||
label: "Second Topic",
|
||||
param: "second_topic",
|
||||
value: "",
|
||||
type: "text"
|
||||
},
|
||||
],
|
||||
module: "speaking"
|
||||
},
|
||||
{
|
||||
label: "Exercise 2",
|
||||
type: "speaking_2",
|
||||
icon: FaUserFriends,
|
||||
extra: [
|
||||
difficulty(),
|
||||
generate(),
|
||||
{
|
||||
label: "Topic",
|
||||
param: "topic",
|
||||
value: "",
|
||||
type: "text"
|
||||
},
|
||||
],
|
||||
module: "speaking"
|
||||
},
|
||||
{
|
||||
label: "Interactive",
|
||||
type: "speaking_3",
|
||||
icon: FaHandshake,
|
||||
extra: [
|
||||
difficulty(),
|
||||
generate(),
|
||||
{
|
||||
label: "Topic",
|
||||
param: "topic",
|
||||
value: "",
|
||||
type: "text"
|
||||
},
|
||||
],
|
||||
module: "speaking"
|
||||
},
|
||||
...reading(1),
|
||||
...reading(2),
|
||||
...reading(3),
|
||||
...listening(1),
|
||||
...listening(2),
|
||||
...listening(3),
|
||||
...listening(4),
|
||||
]
|
||||
|
||||
export default EXERCISES;
|
||||
@@ -0,0 +1,22 @@
|
||||
import { IconType } from "react-icons";
|
||||
|
||||
export interface GeneratedExercises {
|
||||
exercises: Record<string, string>[];
|
||||
sectionId: number;
|
||||
module: string;
|
||||
}
|
||||
|
||||
export interface GeneratorState {
|
||||
loading: boolean;
|
||||
sectionId: number;
|
||||
}
|
||||
|
||||
|
||||
export interface ExerciseGen {
|
||||
label: string;
|
||||
type: string;
|
||||
icon: IconType;
|
||||
sectionId?: number;
|
||||
extra?: { param: string; value?: string | number | boolean; label?: string; tooltip?: string, type?: string}[];
|
||||
module: string
|
||||
}
|
||||
275
src/components/ExamEditor/ExercisePicker/index.tsx
Normal file
@@ -0,0 +1,275 @@
|
||||
import EXERCISES from "./exercises";
|
||||
import clsx from "clsx";
|
||||
import { ExerciseGen, GeneratedExercises, GeneratorState } from "./generatedExercises";
|
||||
import Modal from "@/components/Modal";
|
||||
import { useCallback, useState } from "react";
|
||||
import ExerciseWizard, { ExerciseConfig } from "./ExerciseWizard";
|
||||
import { generate } from "../SettingsEditor/Shared/Generate";
|
||||
import { Module } from "@/interfaces";
|
||||
import useExamEditorStore from "@/stores/examEditor";
|
||||
import { LevelPart, ListeningPart, Message, ReadingPart } from "@/interfaces/exam";
|
||||
import { BsArrowRepeat } from "react-icons/bs";
|
||||
|
||||
interface ExercisePickerProps {
|
||||
module: string;
|
||||
sectionId: number;
|
||||
extraArgs?: Record<string, any>;
|
||||
levelSectionId?: number;
|
||||
level?: boolean;
|
||||
}
|
||||
|
||||
const DIFFICULTIES: string[] = ["A1", "A2", "B1", "B2", "C1", "C2"];
|
||||
|
||||
const ExercisePicker: React.FC<ExercisePickerProps> = ({
|
||||
module,
|
||||
sectionId,
|
||||
extraArgs = undefined,
|
||||
levelSectionId,
|
||||
level = false
|
||||
}) => {
|
||||
const { currentModule } = useExamEditorStore();
|
||||
const { difficulty, sections } = useExamEditorStore((store) => store.modules[level ? "level" : currentModule]);
|
||||
const section = sections.find((s) => s.sectionId === (level ? levelSectionId : sectionId));
|
||||
|
||||
const [pickerOpen, setPickerOpen] = useState(false);
|
||||
const [localSelectedExercises, setLocalSelectedExercises] = useState<string[]>([]);
|
||||
|
||||
const state = section?.state;
|
||||
|
||||
const getFullExerciseType = (exercise: ExerciseGen): string => {
|
||||
if (exercise.extra && exercise.extra.length > 0) {
|
||||
const extraValue = exercise.extra.find(e => e.param === 'name')?.value;
|
||||
return extraValue ? `${exercise.type}/?name=${extraValue}` : exercise.type;
|
||||
}
|
||||
return exercise.type;
|
||||
};
|
||||
|
||||
const handleChange = (exercise: ExerciseGen) => {
|
||||
const fullType = getFullExerciseType(exercise);
|
||||
|
||||
setLocalSelectedExercises(prev => {
|
||||
const newSelected = prev.includes(fullType)
|
||||
? prev.filter(type => type !== fullType)
|
||||
: [...prev, fullType];
|
||||
return newSelected;
|
||||
});
|
||||
};
|
||||
|
||||
const moduleExercises = (sectionId && !["level", "writing", "speaking"].includes(module) ? EXERCISES.filter((ex) => ex.module === module && ex.sectionId == sectionId) : EXERCISES.filter((ex) => ex.module === module));
|
||||
|
||||
const onModuleSpecific = useCallback((configurations: ExerciseConfig[]) => {
|
||||
const exercises = configurations.map(config => {
|
||||
const exerciseType = config.type.split('name=')[1];
|
||||
return {
|
||||
type: exerciseType,
|
||||
quantity: Number(config.params.quantity || 1),
|
||||
...(config.params.num_random_words !== undefined && {
|
||||
num_random_words: Number(config.params.num_random_words)
|
||||
}),
|
||||
...(config.params.max_words !== undefined && {
|
||||
max_words: Number(config.params.max_words)
|
||||
}),
|
||||
...((DIFFICULTIES.includes(config.params.difficulty as string) || config.params.difficulty === "Random") && {
|
||||
difficulty: config.params.difficulty
|
||||
})
|
||||
};
|
||||
});
|
||||
|
||||
let context = {};
|
||||
if (module === 'reading') {
|
||||
const readingState = state as ReadingPart | LevelPart;
|
||||
context = {
|
||||
text: readingState.text!.content
|
||||
};
|
||||
} else if (module === 'listening') {
|
||||
const listeningState = state as ListeningPart | LevelPart;
|
||||
const script = listeningState.script;
|
||||
if (sectionId === 1 || sectionId === 3) {
|
||||
const dialog = script as Message[];
|
||||
context = {
|
||||
text: dialog.map((d) => `${d.name}: ${d.text}`).join("\n")
|
||||
};
|
||||
} else if (sectionId === 2 || sectionId === 4) {
|
||||
context = {
|
||||
text: script as string
|
||||
};
|
||||
}
|
||||
}
|
||||
if (!["speaking", "writing"].includes(module)) {
|
||||
generate(
|
||||
sectionId,
|
||||
module as Module,
|
||||
level ? `exercises-${module}` : "exercises",
|
||||
{
|
||||
method: 'POST',
|
||||
body: {
|
||||
...context,
|
||||
exercises,
|
||||
difficulty
|
||||
}
|
||||
},
|
||||
(data: any) => [{
|
||||
exercises: data.exercises
|
||||
}],
|
||||
levelSectionId,
|
||||
level
|
||||
);
|
||||
} else if (module === "writing") {
|
||||
configurations.forEach((config) => {
|
||||
let queryParams = {
|
||||
difficulty: config.params.difficulty ? config.params.difficulty as string: difficulty,
|
||||
...(config.params.topic !== '' && { topic: config.params.topic as string })
|
||||
};
|
||||
|
||||
generate(
|
||||
config.type === 'writing_letter' ? 1 : 2,
|
||||
"writing",
|
||||
config.type,
|
||||
{
|
||||
method: 'GET',
|
||||
queryParams
|
||||
},
|
||||
(data: any) => [{
|
||||
prompt: data.question,
|
||||
difficulty: data.difficulty
|
||||
}],
|
||||
levelSectionId,
|
||||
level
|
||||
);
|
||||
});
|
||||
} else {
|
||||
configurations.forEach((config) => {
|
||||
let queryParams = Object.fromEntries(
|
||||
Object.entries({
|
||||
topic: config.params.topic as string,
|
||||
first_topic: config.params.first_topic as string,
|
||||
second_topic: config.params.second_topic as string,
|
||||
difficulty: config.params.difficulty ? config.params.difficulty as string: difficulty,
|
||||
}).filter(([_, value]) => value && value !== '')
|
||||
);
|
||||
let query = Object.keys(queryParams).length === 0 ? undefined : queryParams;
|
||||
generate(
|
||||
Number(config.type.split('_')[1]),
|
||||
"speaking",
|
||||
config.type,
|
||||
{
|
||||
method: 'GET',
|
||||
queryParams: query
|
||||
},
|
||||
(data: any) => {
|
||||
switch (Number(config.type.split('_')[1])) {
|
||||
case 1:
|
||||
return [{
|
||||
prompts: data.questions,
|
||||
first_topic: data.first_topic,
|
||||
second_topic: data.second_topic,
|
||||
difficulty: data.difficulty
|
||||
}];
|
||||
case 2:
|
||||
return [{
|
||||
topic: data.topic,
|
||||
question: data.question,
|
||||
prompts: data.prompts,
|
||||
suffix: data.suffix,
|
||||
difficulty: data.difficulty
|
||||
}];
|
||||
case 3:
|
||||
return [{
|
||||
topic: data.topic,
|
||||
questions: data.questions,
|
||||
difficulty: data.difficulty
|
||||
}];
|
||||
default:
|
||||
return [data];
|
||||
}
|
||||
},
|
||||
levelSectionId,
|
||||
level
|
||||
);
|
||||
});
|
||||
}
|
||||
setLocalSelectedExercises([]);
|
||||
setPickerOpen(false);
|
||||
}, [
|
||||
sectionId,
|
||||
levelSectionId,
|
||||
level,
|
||||
module,
|
||||
state,
|
||||
difficulty,
|
||||
setPickerOpen
|
||||
]);
|
||||
|
||||
if (section === undefined) return <></>;
|
||||
|
||||
return (
|
||||
<>
|
||||
<Modal isOpen={pickerOpen} onClose={() => setPickerOpen(false)} title="Exercise Wizard"
|
||||
titleClassName={clsx(
|
||||
"text-2xl font-semibold text-center py-4",
|
||||
`bg-ielts-${module} text-white`,
|
||||
"shadow-sm",
|
||||
"-mx-6 -mt-6",
|
||||
"mb-6"
|
||||
)}
|
||||
>
|
||||
<ExerciseWizard
|
||||
module={module as Module}
|
||||
selectedExercises={localSelectedExercises}
|
||||
sectionId={sectionId}
|
||||
exercises={moduleExercises}
|
||||
onSubmit={onModuleSpecific}
|
||||
onDiscard={() => setPickerOpen(false)}
|
||||
extraArgs={extraArgs}
|
||||
/>
|
||||
</Modal>
|
||||
<div className="flex flex-col gap-4 px-4" key={sectionId}>
|
||||
<div className="space-y-2">
|
||||
{moduleExercises.map((exercise) => {
|
||||
const fullType = getFullExerciseType(exercise);
|
||||
return (
|
||||
<label
|
||||
key={fullType}
|
||||
className={`flex items-center space-x-3 text-white font-semibold cursor-pointer p-2 hover:bg-ielts-${exercise.module}/70 rounded bg-ielts-${exercise.module}/90`}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
name="exercise"
|
||||
value={fullType}
|
||||
checked={localSelectedExercises.includes(fullType)}
|
||||
onChange={() => handleChange(exercise)}
|
||||
className="h-5 w-5"
|
||||
/>
|
||||
<div className="flex items-center space-x-2">
|
||||
<exercise.icon className="h-5 w-5 text-white" />
|
||||
<span>{exercise.label}</span>
|
||||
</div>
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div className="flex flex-row justify-center">
|
||||
<button
|
||||
className={
|
||||
clsx("flex items-center justify-center px-4 py-2 text-white rounded-xl transition-colors duration-300 disabled:cursor-not-allowed",
|
||||
`bg-ielts-${module}/70 border border-ielts-${module} hover:bg-ielts-${module} disabled:bg-ielts-${module}/40 `,
|
||||
)
|
||||
}
|
||||
onClick={() => setPickerOpen(true)}
|
||||
disabled={localSelectedExercises.length === 0}
|
||||
>
|
||||
{section.generating === "exercises" ? (
|
||||
<div key={`section-${sectionId}`} className="flex items-center justify-center">
|
||||
<BsArrowRepeat className="text-white animate-spin" size={25} />
|
||||
</div>
|
||||
) : (
|
||||
<>{["speaking", "writing"].includes(module) ? "Add Exercises" : "Set Up Exercises"} ({localSelectedExercises.length}) </>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default ExercisePicker;
|
||||
247
src/components/ExamEditor/Exercises/Blanks/BlanksReducer.tsx
Normal file
@@ -0,0 +1,247 @@
|
||||
import { toast } from "react-toastify";
|
||||
|
||||
export type TextToken = {
|
||||
type: 'text';
|
||||
content: string;
|
||||
isWhitespace: boolean;
|
||||
isLineBreak?: boolean;
|
||||
};
|
||||
|
||||
export type BlankToken = {
|
||||
type: 'blank';
|
||||
id: number;
|
||||
};
|
||||
|
||||
type Token = TextToken | BlankToken;
|
||||
|
||||
export type BlankState = {
|
||||
id: number;
|
||||
position: number;
|
||||
};
|
||||
|
||||
|
||||
export const getTextSegments = (text: string): Token[] => {
|
||||
const tokens: Token[] = [];
|
||||
let lastIndex = 0;
|
||||
const regex = /{{(\d+)}}/g;
|
||||
let match;
|
||||
|
||||
const addTextTokens = (text: string) => {
|
||||
// Split by newlines first
|
||||
const lines = text.replaceAll("\\n",'\n').split(/(\n)/);
|
||||
|
||||
lines.forEach((line, i) => {
|
||||
if (line === '\n') {
|
||||
tokens.push({
|
||||
type: 'text',
|
||||
content: '<br>',
|
||||
isWhitespace: false,
|
||||
isLineBreak: true
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const normalizedText = line.replace(/\s+/g, ' ');
|
||||
if (normalizedText) {
|
||||
const parts = normalizedText.split(/(\s)/);
|
||||
parts.forEach(part => {
|
||||
if (part) {
|
||||
tokens.push({
|
||||
type: 'text',
|
||||
content: part,
|
||||
isWhitespace: /^\s+$/.test(part)
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
while ((match = regex.exec(text)) !== null) {
|
||||
if (match.index > lastIndex) {
|
||||
addTextTokens(text.slice(lastIndex, match.index));
|
||||
}
|
||||
tokens.push({
|
||||
type: 'blank',
|
||||
id: parseInt(match[1])
|
||||
});
|
||||
lastIndex = regex.lastIndex;
|
||||
}
|
||||
|
||||
if (lastIndex < text.length) {
|
||||
addTextTokens(text.slice(lastIndex));
|
||||
}
|
||||
|
||||
return tokens;
|
||||
}
|
||||
|
||||
export const reconstructTextFromTokens = (tokens: Token[]): string => {
|
||||
return tokens.map(token => {
|
||||
if (token.type === 'blank') {
|
||||
return `{{${token.id}}}`;
|
||||
}
|
||||
if (token.type === 'text' && token.isLineBreak) {
|
||||
return '\n';
|
||||
}
|
||||
return token.content;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
|
||||
export type BlanksState = {
|
||||
text: string;
|
||||
blanks: BlankState[];
|
||||
selectedBlankId: number | null;
|
||||
draggedItemId: string | null;
|
||||
textMode: boolean;
|
||||
setEditing: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
};
|
||||
|
||||
export type BlanksAction =
|
||||
| { type: "SET_TEXT"; payload: string }
|
||||
| { type: "SET_BLANKS"; payload: BlankState[] }
|
||||
| { type: "ADD_BLANK" }
|
||||
| { type: "REMOVE_BLANK"; payload: number }
|
||||
| { type: "SELECT_BLANK"; payload: number | null }
|
||||
| { type: "SET_DRAGGED_ITEM"; payload: string | null }
|
||||
| { type: "MOVE_BLANK"; payload: { blankId: number; newPosition: number } }
|
||||
| { type: "TOGGLE_EDIT_MODE" }
|
||||
| { type: "RESET", payload: { text: string } };
|
||||
|
||||
|
||||
export const blanksReducer = (state: BlanksState, action: BlanksAction): BlanksState => {
|
||||
switch (action.type) {
|
||||
case "SET_TEXT": {
|
||||
return {
|
||||
...state,
|
||||
text: action.payload,
|
||||
};
|
||||
}
|
||||
case "SET_BLANKS": {
|
||||
return {
|
||||
...state,
|
||||
blanks: action.payload,
|
||||
};
|
||||
}
|
||||
case "ADD_BLANK":
|
||||
state.setEditing(true);
|
||||
const newBlankId = Math.max(...state.blanks.map(b => b.id), 0) + 1;
|
||||
const newBlanks = [
|
||||
...state.blanks,
|
||||
{ id: newBlankId, position: state.blanks.length }
|
||||
];
|
||||
const newText = state.text + ` {{${newBlankId}}}`;
|
||||
|
||||
return {
|
||||
...state,
|
||||
blanks: newBlanks,
|
||||
text: newText
|
||||
};
|
||||
|
||||
case "REMOVE_BLANK": {
|
||||
if (state.blanks.length === 1) {
|
||||
toast.error("There needs to be at least 1 blank!");
|
||||
break;
|
||||
}
|
||||
state.setEditing(true);
|
||||
const blanksToKeep = state.blanks.filter(b => b.id !== action.payload);
|
||||
const updatedBlanks = blanksToKeep.map((blank, index) => ({
|
||||
...blank,
|
||||
position: index
|
||||
}));
|
||||
|
||||
const tokens = getTextSegments(state.text).filter(
|
||||
token => !(token.type === 'blank' && token.id === action.payload)
|
||||
);
|
||||
|
||||
const newText = reconstructTextFromTokens(tokens);
|
||||
|
||||
return {
|
||||
...state,
|
||||
blanks: updatedBlanks,
|
||||
text: newText,
|
||||
selectedBlankId: state.selectedBlankId === action.payload ? null : state.selectedBlankId
|
||||
};
|
||||
}
|
||||
|
||||
case "MOVE_BLANK": {
|
||||
state.setEditing(true);
|
||||
const { blankId, newPosition } = action.payload;
|
||||
const tokens = getTextSegments(state.text);
|
||||
|
||||
// Find the current position of the blank
|
||||
const currentPosition = tokens.findIndex(
|
||||
token => token.type === 'blank' && token.id === blankId
|
||||
);
|
||||
|
||||
if (currentPosition === -1) return state;
|
||||
|
||||
// Remove the blank and its surrounding whitespace
|
||||
const blankToken = tokens[currentPosition];
|
||||
tokens.splice(currentPosition, 1);
|
||||
|
||||
// When inserting at new position, ensure there's whitespace around the blank
|
||||
let insertPosition = newPosition;
|
||||
const prevToken = tokens[insertPosition - 1];
|
||||
const nextToken = tokens[insertPosition];
|
||||
|
||||
// Insert space before if needed
|
||||
if (!prevToken || (prevToken.type === 'text' && !prevToken.isWhitespace)) {
|
||||
tokens.splice(insertPosition, 0, {
|
||||
type: 'text',
|
||||
content: ' ',
|
||||
isWhitespace: true
|
||||
});
|
||||
insertPosition++;
|
||||
}
|
||||
|
||||
// Insert the blank
|
||||
tokens.splice(insertPosition, 0, blankToken);
|
||||
insertPosition++;
|
||||
|
||||
// Insert space after if needed
|
||||
if (!nextToken || (nextToken.type === 'text' && !nextToken.isWhitespace)) {
|
||||
tokens.splice(insertPosition, 0, {
|
||||
type: 'text',
|
||||
content: ' ',
|
||||
isWhitespace: true
|
||||
});
|
||||
}
|
||||
|
||||
// Reconstruct the text
|
||||
const newText = reconstructTextFromTokens(tokens);
|
||||
|
||||
// Update blank positions
|
||||
const updatedBlanks = tokens.reduce((acc, token, idx) => {
|
||||
if (token.type === 'blank') {
|
||||
acc.push({ id: token.id, position: idx });
|
||||
}
|
||||
return acc;
|
||||
}, [] as BlankState[]);
|
||||
|
||||
return {
|
||||
...state,
|
||||
text: newText,
|
||||
blanks: updatedBlanks
|
||||
};
|
||||
}
|
||||
case "SELECT_BLANK":
|
||||
return { ...state, selectedBlankId: action.payload };
|
||||
case "SET_DRAGGED_ITEM":
|
||||
state.setEditing(true);
|
||||
return { ...state, draggedItemId: action.payload };
|
||||
case "TOGGLE_EDIT_MODE":
|
||||
return { ...state, textMode: !state.textMode };
|
||||
|
||||
case "RESET":
|
||||
return {
|
||||
text: action.payload.text || "",
|
||||
blanks: [],
|
||||
selectedBlankId: null,
|
||||
draggedItemId: null,
|
||||
textMode: false,
|
||||
setEditing: state.setEditing
|
||||
};
|
||||
}
|
||||
return state;
|
||||
};
|
||||
129
src/components/ExamEditor/Exercises/Blanks/DragNDrop.tsx
Normal file
@@ -0,0 +1,129 @@
|
||||
import { useDraggable, useDroppable } from "@dnd-kit/core";
|
||||
import clsx from "clsx";
|
||||
import { MdClose, MdDelete, MdDragIndicator } from "react-icons/md";
|
||||
import { CSS } from "@dnd-kit/utilities";
|
||||
import { useEffect, useState } from "react";
|
||||
import ConfirmDeleteBtn from "../../Shared/ConfirmDeleteBtn";
|
||||
|
||||
interface BlankProps {
|
||||
id: number;
|
||||
module: string;
|
||||
variant: "text" | "bank";
|
||||
isSelected?: boolean;
|
||||
isDragging?: boolean;
|
||||
onSelect?: (id: number) => void;
|
||||
onRemove?: (id: number) => void;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export const Blank: React.FC<BlankProps> = ({
|
||||
id,
|
||||
module,
|
||||
variant,
|
||||
isSelected,
|
||||
isDragging,
|
||||
onSelect,
|
||||
onRemove,
|
||||
disabled,
|
||||
}) => {
|
||||
const { attributes, listeners, setNodeRef, transform } = useDraggable({
|
||||
id: `${variant}-blank-${id}`,
|
||||
disabled: disabled || variant !== "text",
|
||||
});
|
||||
|
||||
const style = transform ? {
|
||||
transform: CSS.Translate.toString(transform),
|
||||
transition: 'none',
|
||||
zIndex: 999,
|
||||
position: 'relative' as const,
|
||||
touchAction: 'none',
|
||||
} : {
|
||||
transition: 'transform 0.2s cubic-bezier(0.25, 1, 0.5, 1)',
|
||||
touchAction: 'none',
|
||||
position: 'relative' as const,
|
||||
};
|
||||
|
||||
const handleClick = (e: React.MouseEvent) => {
|
||||
if (variant === "bank" && !disabled && onSelect) {
|
||||
onSelect(id);
|
||||
}
|
||||
};
|
||||
|
||||
const dragProps = variant === "text" ? {
|
||||
...attributes,
|
||||
...listeners,
|
||||
} : {};
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={setNodeRef}
|
||||
style={style}
|
||||
className={clsx(
|
||||
"group relative inline-flex items-center gap-2 px-2 py-1.5 rounded-lg select-none",
|
||||
"transform-gpu transition-colors duration-150",
|
||||
"hover:ring-2 hover:ring-offset-1 shadow-sm",
|
||||
(
|
||||
isSelected ? (
|
||||
isDragging ?
|
||||
`bg-ielts-${module}/20 bg-ielts-${module} hover:ring-ielts-${module}/50` :
|
||||
`bg-ielts-${module}/20 bg-ielts-${module}/80 hover:ring-ielts-${module}/40`
|
||||
)
|
||||
: `bg-ielts-${module}/20 bg-ielts-${module} hover:ring-ielts-${module}/50`
|
||||
),
|
||||
!disabled && (variant === "text" ? "cursor-grab active:cursor-grabbing" : "cursor-pointer"),
|
||||
disabled && "cursor-default",
|
||||
variant === "bank" && "w-12"
|
||||
)}
|
||||
onClick={variant === "bank" ? handleClick : undefined}
|
||||
{...dragProps}
|
||||
role="button"
|
||||
>
|
||||
{variant === "text" && (
|
||||
<span
|
||||
className={clsx(
|
||||
"text-xl p-1.5 -ml-1 rounded-md",
|
||||
"transition-colors duration-150"
|
||||
)}
|
||||
title="Drag to reorder"
|
||||
>
|
||||
{isSelected ?
|
||||
<MdDragIndicator className="transform scale-125" color="white" /> :
|
||||
<MdDragIndicator className="transform scale-125" color="#898492" />
|
||||
}
|
||||
</span>
|
||||
)}
|
||||
<span className={clsx(
|
||||
"font-semibold px-1 text-mti-gray-taupe",
|
||||
isSelected && !isDragging && "text-white"
|
||||
)}>
|
||||
{id}
|
||||
</span>
|
||||
|
||||
{onRemove && !isDragging && (
|
||||
<ConfirmDeleteBtn
|
||||
onDelete={() => onRemove(id)}
|
||||
size="md"
|
||||
position="top-right"
|
||||
className="-translate-y-2 translate-x-1.5"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const DropZone: React.FC<{ index: number, module: string; }> = ({ index, module }) => {
|
||||
const { setNodeRef, isOver } = useDroppable({
|
||||
id: `drop-${index}`,
|
||||
});
|
||||
|
||||
return (
|
||||
<span
|
||||
ref={setNodeRef}
|
||||
className={clsx(
|
||||
"inline-block h-6 w-4 mx-px transition-all duration-200 select-none",
|
||||
isOver ? `bg-ielts-${module}/20 w-4.5` : `bg-transparent hover:bg-ielts-${module}/20`
|
||||
)}
|
||||
role="presentation"
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,58 @@
|
||||
import { MdDelete } from "react-icons/md";
|
||||
|
||||
interface Props {
|
||||
letter: string;
|
||||
word: string;
|
||||
isSelected: boolean;
|
||||
onClick: () => void;
|
||||
onRemove?: () => void;
|
||||
onEdit?: (newWord: string) => void;
|
||||
isEditMode?: boolean;
|
||||
}
|
||||
|
||||
const FillBlanksWord: React.FC<Props> = ({
|
||||
letter,
|
||||
word,
|
||||
isSelected,
|
||||
onClick,
|
||||
onRemove,
|
||||
onEdit,
|
||||
isEditMode
|
||||
}) => {
|
||||
return (
|
||||
<div className="w-full flex items-center gap-2">
|
||||
{isEditMode ? (
|
||||
<div className="min-w-0 flex-1 flex items-center gap-2 p-2 rounded-md border border-gray-200">
|
||||
<span className="font-medium min-w-[24px] text-center shrink-0">{letter}</span>
|
||||
<input
|
||||
type="text"
|
||||
value={word}
|
||||
onChange={(e) => onEdit?.(e.target.value)}
|
||||
className="w-full min-w-0 focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
onClick={onClick}
|
||||
className={`
|
||||
min-w-0 flex-1 flex items-center gap-2 p-2 rounded-md border text-left transition-colors
|
||||
${isSelected ? 'border-blue-500 bg-blue-100' : 'border-gray-200'}
|
||||
`}
|
||||
>
|
||||
<span className="font-medium min-w-[24px] text-center shrink-0">{letter}</span>
|
||||
<span className="truncate">{word}</span>
|
||||
</button>
|
||||
)}
|
||||
{isEditMode && onRemove && (
|
||||
<button
|
||||
onClick={onRemove}
|
||||
className="p-1 rounded text-red-500 hover:bg-gray-100 shrink-0"
|
||||
aria-label="Remove word"
|
||||
>
|
||||
<MdDelete className="h-4 w-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
export default FillBlanksWord;
|
||||
353
src/components/ExamEditor/Exercises/Blanks/Letters/index.tsx
Normal file
@@ -0,0 +1,353 @@
|
||||
import { Difficulty, FillBlanksExercise, ReadingPart } from "@/interfaces/exam";
|
||||
import { useCallback, useEffect, useReducer, useState } from "react";
|
||||
import BlanksEditor from "..";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { MdEdit, MdEditOff } from "react-icons/md";
|
||||
import FillBlanksWord from "./FillBlanksWord";
|
||||
import { FaPlus } from "react-icons/fa";
|
||||
import useExamEditorStore from "@/stores/examEditor";
|
||||
import { blanksReducer, BlankState, getTextSegments } from "../BlanksReducer";
|
||||
import useSectionEdit from "@/components/ExamEditor/Hooks/useSectionEdit";
|
||||
import { AlertItem } from "../../Shared/Alert";
|
||||
import validateBlanks from "../validateBlanks";
|
||||
import { toast } from "react-toastify";
|
||||
import setEditingAlert from "../../Shared/setEditingAlert";
|
||||
import PromptEdit from "../../Shared/PromptEdit";
|
||||
import { uuidv4 } from "@firebase/util";
|
||||
|
||||
interface Word {
|
||||
letter: string;
|
||||
word: string;
|
||||
}
|
||||
|
||||
const FillBlanksLetters: React.FC<{ exercise: FillBlanksExercise; sectionId: number }> = ({ exercise, sectionId }) => {
|
||||
const { currentModule, dispatch } = useExamEditorStore();
|
||||
const difficulty = useExamEditorStore((state) => state.modules[currentModule].difficulty);
|
||||
const { state } = useExamEditorStore(
|
||||
(state) => state.modules[currentModule].sections.find((section) => section.sectionId === sectionId)!
|
||||
);
|
||||
|
||||
const section = state as ReadingPart;
|
||||
|
||||
const [alerts, setAlerts] = useState<AlertItem[]>([]);
|
||||
|
||||
const [local, setLocal] = useState(exercise);
|
||||
const [selectedBlankId, setSelectedBlankId] = useState<string | null>(null);
|
||||
const [answers, setAnswers] = useState<Map<string, string>>(
|
||||
new Map(exercise.solutions.map(({ id, solution }) => [id, solution]))
|
||||
);
|
||||
const [isEditMode, setIsEditMode] = useState(false);
|
||||
const [newWord, setNewWord] = useState('');
|
||||
|
||||
const [editing, setEditing] = useState(false);
|
||||
|
||||
const updateLocal = (exercise: FillBlanksExercise) => {
|
||||
setLocal(exercise);
|
||||
setEditingAlert(true, setAlerts);
|
||||
setEditing(true);
|
||||
};
|
||||
|
||||
const [blanksState, blanksDispatcher] = useReducer(blanksReducer, {
|
||||
text: exercise.text || "",
|
||||
blanks: [],
|
||||
selectedBlankId: null,
|
||||
draggedItemId: null,
|
||||
textMode: false,
|
||||
setEditing,
|
||||
});
|
||||
|
||||
const { handleSave, handleDiscard, handleDelete, handlePractice } = useSectionEdit({
|
||||
sectionId,
|
||||
editing,
|
||||
setEditing,
|
||||
onSave: () => {
|
||||
if (!validateBlanks(blanksState.blanks, answers, alerts, setAlerts)) {
|
||||
toast.error("Please fix the errors before saving!");
|
||||
return;
|
||||
}
|
||||
|
||||
setEditing(false);
|
||||
setAlerts([]);
|
||||
|
||||
const updatedExercise = {
|
||||
...local,
|
||||
text: blanksState.text,
|
||||
solutions: Array.from(answers.entries()).map(([id, solution]) => ({
|
||||
uuid: local.solutions.find(sol => sol.id === id)?.uuid || uuidv4(),
|
||||
id,
|
||||
solution
|
||||
}))
|
||||
};
|
||||
|
||||
const newState = { ...section };
|
||||
newState.exercises = newState.exercises.map((ex) =>
|
||||
ex.id === exercise.id ? updatedExercise : ex
|
||||
);
|
||||
|
||||
dispatch({ type: 'UPDATE_SECTION_STATE', payload: { sectionId, update: newState, module: currentModule } });
|
||||
},
|
||||
onDiscard: () => {
|
||||
setSelectedBlankId(null);
|
||||
setAnswers(new Map(exercise.solutions.map(({ id, solution }) => [id, solution])));
|
||||
setIsEditMode(false);
|
||||
setNewWord('');
|
||||
setLocal(exercise);
|
||||
|
||||
blanksDispatcher({ type: "RESET", payload: { text: exercise.text } });
|
||||
blanksDispatcher({ type: "SET_TEXT", payload: exercise.text || "" });
|
||||
|
||||
const tokens = getTextSegments(exercise.text || "");
|
||||
const initialBlanks = tokens.reduce((acc, token, idx) => {
|
||||
if (token.type === 'blank') {
|
||||
acc.push({ id: token.id, position: idx });
|
||||
}
|
||||
return acc;
|
||||
}, [] as BlankState[]);
|
||||
blanksDispatcher({ type: "SET_BLANKS", payload: initialBlanks });
|
||||
|
||||
},
|
||||
onDelete: () => {
|
||||
const newSection = {
|
||||
...section,
|
||||
exercises: section.exercises.filter((ex) => ex.id !== local.id)
|
||||
};
|
||||
dispatch({ type: "UPDATE_SECTION_STATE", payload: { sectionId, update: newSection, module: currentModule } });
|
||||
},
|
||||
onPractice: () => {
|
||||
const updatedExercise = {
|
||||
...local,
|
||||
isPractice: !local.isPractice,
|
||||
};
|
||||
const newState = { ...section };
|
||||
newState.exercises = newState.exercises.map((ex) =>
|
||||
ex.id === exercise.id ? updatedExercise : ex
|
||||
);
|
||||
setLocal((prev) => ({...prev, isPractice: !local.isPractice}))
|
||||
dispatch({ type: 'UPDATE_SECTION_STATE', payload: { sectionId, update: newState, module: currentModule } });
|
||||
}
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!editing) {
|
||||
setLocal(exercise);
|
||||
setAnswers(new Map(exercise.solutions.map(({ id, solution }) => [id, solution])));
|
||||
}
|
||||
}, [exercise, editing]);
|
||||
|
||||
const handleWordSelect = (word: string) => {
|
||||
if (!selectedBlankId) return;
|
||||
|
||||
if (!editing) setEditing(true);
|
||||
|
||||
const newAnswers = new Map(answers);
|
||||
newAnswers.set(selectedBlankId, word);
|
||||
|
||||
setAnswers(newAnswers);
|
||||
|
||||
setLocal(prev => ({
|
||||
...prev,
|
||||
solutions: Array.from(newAnswers.entries()).map(([id, solution]) => ({
|
||||
uuid: prev.solutions.find(sol => sol.id === id)?.uuid || uuidv4(),
|
||||
id,
|
||||
solution
|
||||
}))
|
||||
}));
|
||||
};
|
||||
|
||||
const handleAddWord = () => {
|
||||
const word = newWord.trim();
|
||||
if (!word) return;
|
||||
|
||||
setLocal(prev => {
|
||||
const nextLetter = String.fromCharCode(65 + prev.words.length);
|
||||
return {
|
||||
...prev,
|
||||
words: [...prev.words, { letter: nextLetter, word }]
|
||||
};
|
||||
});
|
||||
setNewWord('');
|
||||
};
|
||||
|
||||
const handleRemoveWord = (index: number) => {
|
||||
if (!editing) setEditing(true);
|
||||
|
||||
if (answers.size === 1) {
|
||||
toast.error("There needs to be at least 1 word!");
|
||||
return;
|
||||
}
|
||||
|
||||
setLocal(prev => {
|
||||
const newWords = prev.words.filter((_, i) => i !== index) as Word[];
|
||||
const removedWord = prev.words[index] as Word;
|
||||
|
||||
const newAnswers = new Map(answers);
|
||||
for (const [blankId, answer] of newAnswers.entries()) {
|
||||
if (answer === removedWord.word) {
|
||||
newAnswers.delete(blankId);
|
||||
}
|
||||
}
|
||||
setAnswers(newAnswers);
|
||||
|
||||
return {
|
||||
...prev,
|
||||
words: newWords,
|
||||
solutions: Array.from(newAnswers.entries()).map(([id, solution]) => ({
|
||||
uuid: prev.solutions.find(sol => sol.id === id)?.uuid || uuidv4(),
|
||||
id,
|
||||
solution
|
||||
}))
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
const handleEditWord = (index: number, newWord: string) => {
|
||||
if (!editing) setEditing(true);
|
||||
|
||||
|
||||
setLocal(prev => {
|
||||
const newWords = [...prev.words] as Word[];
|
||||
const oldWord = newWords[index].word;
|
||||
newWords[index] = { ...newWords[index], word: newWord };
|
||||
|
||||
const newAnswers = new Map(answers);
|
||||
for (const [blankId, answer] of newAnswers.entries()) {
|
||||
if (answer === oldWord) {
|
||||
newAnswers.set(blankId, newWord);
|
||||
}
|
||||
}
|
||||
setAnswers(newAnswers);
|
||||
|
||||
return {
|
||||
...prev,
|
||||
words: newWords,
|
||||
solutions: Array.from(newAnswers.entries()).map(([id, solution]) => ({
|
||||
uuid: prev.solutions.find(sol => sol.id === id)?.uuid || uuidv4(),
|
||||
id,
|
||||
solution
|
||||
}))
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
const handleBlankRemove = (blankId: number) => {
|
||||
if (!editing) setEditing(true);
|
||||
|
||||
const newAnswers = new Map(answers);
|
||||
newAnswers.delete(blankId.toString());
|
||||
setAnswers(newAnswers);
|
||||
|
||||
setLocal(prev => ({
|
||||
...prev,
|
||||
solutions: Array.from(newAnswers.entries()).map(([id, solution]) => ({
|
||||
uuid: prev.solutions.find(sol => sol.id === id)?.uuid || uuidv4(),
|
||||
id,
|
||||
solution
|
||||
}))
|
||||
}));
|
||||
blanksDispatcher({ type: "REMOVE_BLANK", payload: blankId });
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
validateBlanks(blanksState.blanks, answers, alerts, setAlerts);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [answers, blanksState.blanks, blanksState.textMode])
|
||||
|
||||
useEffect(()=> {
|
||||
setEditingAlert(editing, setAlerts);
|
||||
}, [editing])
|
||||
|
||||
const saveDifficulty = useCallback((diff: Difficulty) => {
|
||||
if (!difficulty.includes(diff)) {
|
||||
dispatch({ type: 'UPDATE_MODULE', payload: { updates: { difficulty: [...difficulty, diff]} } });
|
||||
}
|
||||
const updatedExercise = { ...exercise, difficulty: diff };
|
||||
const newState = { ...section };
|
||||
newState.exercises = newState.exercises.map((ex) => ex.id === exercise.id ? updatedExercise : ex );
|
||||
dispatch({ type: 'UPDATE_SECTION_STATE', payload: { sectionId, update: newState, module: currentModule } });
|
||||
}, [currentModule, difficulty, dispatch, exercise, section, sectionId]);
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<BlanksEditor
|
||||
alerts={alerts}
|
||||
editing={editing}
|
||||
state={blanksState}
|
||||
difficulty={exercise.difficulty}
|
||||
saveDifficulty={saveDifficulty}
|
||||
blanksDispatcher={blanksDispatcher}
|
||||
description="Place blanks and assign words from the word bank"
|
||||
initialText={local.text}
|
||||
module={currentModule}
|
||||
showBlankBank={true}
|
||||
onBlankSelect={(blankId) => setSelectedBlankId(blankId?.toString() || null)}
|
||||
onBlankRemove={handleBlankRemove}
|
||||
onSave={handleSave}
|
||||
onDiscard={handleDiscard}
|
||||
onDelete={handleDelete}
|
||||
setEditing={setEditing}
|
||||
onPractice={handlePractice}
|
||||
isEvaluationEnabled={!local.isPractice}
|
||||
prompt={local.prompt}
|
||||
updatePrompt={(prompt: string) => updateLocal({...local, prompt})}
|
||||
>
|
||||
<>
|
||||
{!blanksState.textMode && <Card className="p-4">
|
||||
<CardContent>
|
||||
<div className="flex justify-between items-center mb-4">
|
||||
<div className="text-lg font-semibold">Word Bank</div>
|
||||
<button
|
||||
onClick={() => setIsEditMode(!isEditMode)}
|
||||
className="p-2 hover:bg-gray-100 rounded-lg transition-colors"
|
||||
>
|
||||
{isEditMode ?
|
||||
<MdEditOff size={20} className="text-gray-500" /> :
|
||||
<MdEdit size={20} className="text-gray-500" />
|
||||
}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 gap-2">
|
||||
{(local.words as Word[]).map((wordItem, index) => (
|
||||
<FillBlanksWord
|
||||
key={wordItem.letter}
|
||||
letter={wordItem.letter}
|
||||
word={wordItem.word}
|
||||
isSelected={answers.get(selectedBlankId || '') === wordItem.word}
|
||||
onClick={() => handleWordSelect(wordItem.word)}
|
||||
onRemove={isEditMode ? () => handleRemoveWord(index) : undefined}
|
||||
onEdit={isEditMode ? (newWord) => handleEditWord(index, newWord) : undefined}
|
||||
isEditMode={isEditMode}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{isEditMode && (
|
||||
<div className="flex flex-row mt-8">
|
||||
<input
|
||||
type="text"
|
||||
value={newWord}
|
||||
onChange={(e) => setNewWord(e.target.value)}
|
||||
placeholder="Enter new word"
|
||||
className="flex-1 px-3 py-2 border border-r-0 rounded-l-md focus:outline-none"
|
||||
name=""
|
||||
/>
|
||||
<button
|
||||
onClick={handleAddWord}
|
||||
disabled={!isEditMode || newWord === ""}
|
||||
className="px-4 bg-blue-500 text-white rounded-r-md border border-blue-500 hover:bg-blue-600 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
<FaPlus className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
}
|
||||
</>
|
||||
</BlanksEditor>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default FillBlanksLetters;
|
||||
@@ -0,0 +1,67 @@
|
||||
import clsx from "clsx";
|
||||
|
||||
interface MCOptionProps {
|
||||
id: string;
|
||||
options: {
|
||||
A: string;
|
||||
B: string;
|
||||
C: string;
|
||||
D: string;
|
||||
};
|
||||
selectedOption?: string;
|
||||
onSelect: (option: string) => void;
|
||||
isEditMode?: boolean;
|
||||
onEdit?: (key: 'A' | 'B' | 'C' | 'D', value: string) => void;
|
||||
onRemove?: () => void;
|
||||
}
|
||||
|
||||
const MCOption: React.FC<MCOptionProps> = ({
|
||||
id,
|
||||
options,
|
||||
selectedOption,
|
||||
onSelect,
|
||||
isEditMode,
|
||||
onEdit,
|
||||
}) => {
|
||||
const optionKeys = ['A', 'B', 'C', 'D'] as const;
|
||||
|
||||
return (
|
||||
<div className="w-full">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="font-medium">Question {id}</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{optionKeys.map((key) => (
|
||||
<div key={key} className="flex gap-2">
|
||||
{isEditMode ? (
|
||||
<div className="flex-1 flex items-center gap-2 p-2 rounded-md border border-gray-200">
|
||||
<span className="font-medium min-w-[24px] text-center">{key}</span>
|
||||
<input
|
||||
type="text"
|
||||
value={options[key]}
|
||||
onChange={(e) => onEdit?.(key, e.target.value)}
|
||||
className="w-full focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => onSelect(key)}
|
||||
className={clsx(
|
||||
"flex-1 flex items-center gap-2 p-2 rounded-md border transition-colors text-left",
|
||||
selectedOption === key
|
||||
? "border-blue-500 bg-blue-100"
|
||||
: "border-gray-200 hover:bg-blue-50"
|
||||
)}
|
||||
>
|
||||
<span className="font-medium min-w-[24px] text-center">{key}</span>
|
||||
<span>{options[key]}</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default MCOption;
|
||||
@@ -0,0 +1,345 @@
|
||||
import { Difficulty, FillBlanksExercise, FillBlanksMCOption, ReadingPart } from "@/interfaces/exam";
|
||||
import { useCallback, useEffect, useReducer, useState } from "react";
|
||||
import BlanksEditor from "..";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import useExamEditorStore from "@/stores/examEditor";
|
||||
import { blanksReducer, BlankState, getTextSegments } from "../BlanksReducer";
|
||||
import useSectionEdit from "@/components/ExamEditor/Hooks/useSectionEdit";
|
||||
import { AlertItem } from "../../Shared/Alert";
|
||||
import validateBlanks from "../validateBlanks";
|
||||
import { toast } from "react-toastify";
|
||||
import setEditingAlert from "../../Shared/setEditingAlert";
|
||||
import { MdEdit, MdEditOff } from "react-icons/md";
|
||||
import MCOption from "./MCOption";
|
||||
import { uuidv4 } from "@firebase/util";
|
||||
|
||||
|
||||
const FillBlanksMC: React.FC<{ exercise: FillBlanksExercise; sectionId: number }> = ({ exercise, sectionId }) => {
|
||||
const { currentModule, dispatch } = useExamEditorStore();
|
||||
const difficulty = useExamEditorStore((state) => state.modules[currentModule].difficulty);
|
||||
const { state } = useExamEditorStore(
|
||||
(state) => state.modules[currentModule].sections.find((section) => section.sectionId === sectionId)!
|
||||
);
|
||||
|
||||
const section = state as ReadingPart;
|
||||
const [alerts, setAlerts] = useState<AlertItem[]>([]);
|
||||
const [local, setLocal] = useState(exercise);
|
||||
const [selectedBlankId, setSelectedBlankId] = useState<string | null>(null);
|
||||
|
||||
const [answers, setAnswers] = useState<Map<string, string>>(() => {
|
||||
return new Map(
|
||||
exercise.solutions.map(({ id, solution }) => [
|
||||
id.toString(),
|
||||
solution
|
||||
])
|
||||
);
|
||||
});
|
||||
|
||||
const [isEditMode, setIsEditMode] = useState(false);
|
||||
const [editing, setEditing] = useState(false);
|
||||
|
||||
const updateLocal = (exercise: FillBlanksExercise) => {
|
||||
setLocal(exercise);
|
||||
setEditingAlert(true, setAlerts);
|
||||
setEditing(true);
|
||||
};
|
||||
|
||||
const [blanksState, blanksDispatcher] = useReducer(blanksReducer, {
|
||||
text: exercise.text || "",
|
||||
blanks: [],
|
||||
selectedBlankId: null,
|
||||
draggedItemId: null,
|
||||
textMode: false,
|
||||
setEditing,
|
||||
});
|
||||
|
||||
const { handleSave, handleDiscard, handleDelete, handlePractice } = useSectionEdit({
|
||||
sectionId,
|
||||
editing,
|
||||
setEditing,
|
||||
onSave: () => {
|
||||
if (!validateBlanks(blanksState.blanks, answers, alerts, setAlerts)) {
|
||||
toast.error("Please fix the errors before saving!");
|
||||
return;
|
||||
}
|
||||
|
||||
setEditing(false);
|
||||
setAlerts([]);
|
||||
|
||||
const updatedExercise = {
|
||||
...local,
|
||||
text: blanksState.text,
|
||||
solutions: Array.from(answers.entries()).map(([id, solution]) => ({
|
||||
uuid: local.solutions.find(sol => sol.id === id)?.uuid || uuidv4(),
|
||||
id,
|
||||
solution
|
||||
}))
|
||||
};
|
||||
|
||||
const newState = { ...section };
|
||||
newState.exercises = newState.exercises.map((ex) =>
|
||||
ex.id === exercise.id ? updatedExercise : ex
|
||||
);
|
||||
|
||||
dispatch({ type: 'UPDATE_SECTION_STATE', payload: { sectionId, update: newState, module: currentModule } });
|
||||
},
|
||||
onDiscard: () => {
|
||||
setSelectedBlankId(null);
|
||||
setAnswers(new Map(exercise.solutions.map(({ id, solution }) => [id, solution])));
|
||||
setIsEditMode(false);
|
||||
setLocal(exercise);
|
||||
|
||||
blanksDispatcher({ type: "RESET", payload: { text: exercise.text } });
|
||||
blanksDispatcher({ type: "SET_TEXT", payload: exercise.text || "" });
|
||||
|
||||
const tokens = getTextSegments(exercise.text || "");
|
||||
const initialBlanks = tokens.reduce((acc, token, idx) => {
|
||||
if (token.type === 'blank') {
|
||||
acc.push({ id: token.id, position: idx });
|
||||
}
|
||||
return acc;
|
||||
}, [] as BlankState[]);
|
||||
blanksDispatcher({ type: "SET_BLANKS", payload: initialBlanks });
|
||||
},
|
||||
onDelete: () => {
|
||||
const newSection = {
|
||||
...section,
|
||||
exercises: section.exercises.filter((ex) => ex.id !== local.id)
|
||||
};
|
||||
dispatch({ type: "UPDATE_SECTION_STATE", payload: { sectionId, update: newSection, module: currentModule } });
|
||||
},
|
||||
onPractice: () => {
|
||||
const updatedExercise = {
|
||||
...local,
|
||||
isPractice: !local.isPractice
|
||||
};
|
||||
const newState = { ...section };
|
||||
newState.exercises = newState.exercises.map((ex) =>
|
||||
ex.id === exercise.id ? updatedExercise : ex
|
||||
);
|
||||
setLocal((prev) => ({ ...prev, isPractice: !local.isPractice }))
|
||||
dispatch({ type: 'UPDATE_SECTION_STATE', payload: { sectionId, update: newState, module: currentModule } });
|
||||
}
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!editing) {
|
||||
setLocal(exercise);
|
||||
setAnswers(new Map(exercise.solutions.map(({ id, solution }) => [id, solution])));
|
||||
}
|
||||
}, [exercise, editing]);
|
||||
|
||||
const handleOptionSelect = (option: string) => {
|
||||
if (!selectedBlankId) return;
|
||||
if (!editing) setEditing(true);
|
||||
|
||||
const newAnswers = new Map(answers);
|
||||
newAnswers.set(selectedBlankId, option);
|
||||
|
||||
setAnswers(newAnswers);
|
||||
|
||||
setLocal(prev => ({
|
||||
...prev,
|
||||
solutions: Array.from(newAnswers.entries()).map(([id, solution]) => ({
|
||||
uuid: prev.solutions.find(sol => sol.id === id)?.uuid || uuidv4(),
|
||||
id,
|
||||
solution
|
||||
}))
|
||||
}));
|
||||
};
|
||||
|
||||
const handleEditOption = (mcOptionIndex: number, key: keyof FillBlanksMCOption['options'], value: string) => {
|
||||
if (!editing) setEditing(true);
|
||||
|
||||
setLocal(prev => {
|
||||
const newWords = [...prev.words] as FillBlanksMCOption[];
|
||||
const mcOption = newWords[mcOptionIndex] as FillBlanksMCOption;
|
||||
|
||||
const newOptions = { ...mcOption.options, [key]: value };
|
||||
newWords[mcOptionIndex] = { ...mcOption, options: newOptions };
|
||||
|
||||
const oldValue = (mcOption.options as any)[key];
|
||||
const newAnswers = new Map(answers);
|
||||
for (const [blankId, answer] of newAnswers.entries()) {
|
||||
if (answer === oldValue) {
|
||||
newAnswers.set(blankId, value);
|
||||
}
|
||||
}
|
||||
setAnswers(newAnswers);
|
||||
|
||||
return {
|
||||
...prev,
|
||||
words: newWords,
|
||||
solutions: Array.from(newAnswers.entries()).map(([id, solution]) => ({
|
||||
uuid: prev.solutions.find(sol => sol.id === id)?.uuid || uuidv4(),
|
||||
id,
|
||||
solution
|
||||
}))
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
validateBlanks(blanksState.blanks, answers, alerts, setAlerts);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [answers, blanksState.blanks, blanksState.textMode]);
|
||||
|
||||
useEffect(() => {
|
||||
setEditingAlert(editing, setAlerts);
|
||||
}, [editing]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!editing) {
|
||||
setLocal(exercise);
|
||||
setAnswers(new Map(
|
||||
exercise.solutions.map(({ id, solution }) => [
|
||||
id.toString(),
|
||||
solution
|
||||
])
|
||||
));
|
||||
}
|
||||
}, [exercise, editing]);
|
||||
|
||||
useEffect(() => {
|
||||
setAnswers(new Map(
|
||||
exercise.solutions.map(({ id, solution }) => [
|
||||
id.toString(),
|
||||
solution
|
||||
])
|
||||
));
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
const handleBlankRemove = (blankId: number) => {
|
||||
if (!editing) setEditing(true);
|
||||
|
||||
const newAnswers = new Map(answers);
|
||||
newAnswers.delete(blankId.toString());
|
||||
setAnswers(newAnswers);
|
||||
|
||||
setLocal(prev => ({
|
||||
...prev,
|
||||
words: (prev.words as FillBlanksMCOption[]).filter(w => w.id !== blankId.toString()),
|
||||
solutions: Array.from(newAnswers.entries()).map(([id, solution]) => ({
|
||||
uuid: prev.solutions.find(sol => sol.id === id)?.uuid || uuidv4(),
|
||||
id,
|
||||
solution
|
||||
}))
|
||||
}));
|
||||
|
||||
blanksDispatcher({ type: "REMOVE_BLANK", payload: blankId });
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const existingWordIds = new Set((local.words as FillBlanksMCOption[]).map(word => word.id));
|
||||
const blanksMissingWords = blanksState.blanks.filter(blank => !existingWordIds.has(blank.id.toString()));
|
||||
if (blanksMissingWords.length > 0) {
|
||||
setLocal(prev => {
|
||||
const newWords = [...prev.words] as FillBlanksMCOption[];
|
||||
|
||||
blanksMissingWords.forEach(blank => {
|
||||
const newMCOption: FillBlanksMCOption = {
|
||||
uuid: uuidv4(),
|
||||
id: blank.id.toString(),
|
||||
options: {
|
||||
A: 'Option A',
|
||||
B: 'Option B',
|
||||
C: 'Option C',
|
||||
D: 'Option D'
|
||||
}
|
||||
};
|
||||
newWords.push(newMCOption);
|
||||
});
|
||||
|
||||
return {
|
||||
...prev,
|
||||
words: newWords,
|
||||
solutions: Array.from(answers.entries()).map(([id, solution]) => ({
|
||||
uuid: prev.solutions.find(sol => sol.id === id)?.uuid || uuidv4(),
|
||||
id,
|
||||
solution
|
||||
}))
|
||||
};
|
||||
});
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [blanksState.blanks]);
|
||||
|
||||
const saveDifficulty = useCallback((diff: Difficulty) => {
|
||||
if (!difficulty.includes(diff)) {
|
||||
dispatch({ type: 'UPDATE_MODULE', payload: { updates: { difficulty: [...difficulty, diff]} } });
|
||||
}
|
||||
const updatedExercise = { ...exercise, difficulty: diff };
|
||||
const newState = { ...section };
|
||||
newState.exercises = newState.exercises.map((ex) => ex.id === exercise.id ? updatedExercise : ex );
|
||||
dispatch({ type: 'UPDATE_SECTION_STATE', payload: { sectionId, update: newState, module: currentModule } });
|
||||
}, [currentModule, difficulty, dispatch, exercise, section, sectionId]);
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<BlanksEditor
|
||||
alerts={alerts}
|
||||
editing={editing}
|
||||
state={blanksState}
|
||||
difficulty={exercise.difficulty}
|
||||
saveDifficulty={saveDifficulty}
|
||||
blanksDispatcher={blanksDispatcher}
|
||||
description="Place blanks and select the correct answer from multiple choice options"
|
||||
initialText={local.text}
|
||||
module={currentModule}
|
||||
showBlankBank={true}
|
||||
onBlankSelect={(blankId) => setSelectedBlankId(blankId?.toString() || null)}
|
||||
onSave={handleSave}
|
||||
onDiscard={handleDiscard}
|
||||
onDelete={handleDelete}
|
||||
onPractice={handlePractice}
|
||||
setEditing={setEditing}
|
||||
onBlankRemove={handleBlankRemove}
|
||||
isEvaluationEnabled={!local.isPractice}
|
||||
prompt={local.prompt}
|
||||
updatePrompt={(prompt: string) => updateLocal({ ...local, prompt })}
|
||||
>
|
||||
{!blanksState.textMode && selectedBlankId && (
|
||||
<Card className="p-4">
|
||||
<CardContent>
|
||||
<div className="flex justify-between items-center mb-4">
|
||||
<div className="text-lg font-semibold">Multiple Choice Options</div>
|
||||
<button
|
||||
onClick={() => setIsEditMode(!isEditMode)}
|
||||
className="p-2 hover:bg-gray-100 rounded-lg transition-colors"
|
||||
>
|
||||
{isEditMode ?
|
||||
<MdEditOff size={20} className="text-gray-500" /> :
|
||||
<MdEdit size={20} className="text-gray-500" />
|
||||
}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{(local.words as FillBlanksMCOption[]).map((mcOption) => {
|
||||
if (mcOption.id.toString() !== selectedBlankId) return null;
|
||||
|
||||
return (
|
||||
<MCOption
|
||||
key={mcOption.id}
|
||||
id={mcOption.id}
|
||||
options={mcOption.options}
|
||||
selectedOption={answers.get(selectedBlankId)}
|
||||
onSelect={(option) => handleOptionSelect(option)}
|
||||
isEditMode={isEditMode}
|
||||
onEdit={(key, value) => handleEditOption(
|
||||
(local.words as FillBlanksMCOption[]).findIndex(w => w.id === mcOption.id),
|
||||
key as "A" | "B" | "C" | "D",
|
||||
value
|
||||
)}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</BlanksEditor>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default FillBlanksMC;
|
||||
@@ -0,0 +1,47 @@
|
||||
import { MdDelete, MdAdd } from "react-icons/md";
|
||||
|
||||
interface AlternativeSolutionProps {
|
||||
solutions: string[];
|
||||
onAdd: () => void;
|
||||
onRemove: (index: number) => void;
|
||||
onEdit: (index: number, value: string) => void;
|
||||
}
|
||||
|
||||
const AlternativeSolutions: React.FC<AlternativeSolutionProps> = ({
|
||||
solutions,
|
||||
onAdd,
|
||||
onRemove,
|
||||
onEdit,
|
||||
}) => {
|
||||
return (
|
||||
<div className="space-y-2 mt-4">
|
||||
{solutions.map((solution, index) => (
|
||||
<div key={index} className="flex items-center gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={solution}
|
||||
onChange={(e) => onEdit(index, e.target.value)}
|
||||
className="flex-1 p-2 border rounded-lg focus:ring-2 focus:ring-blue-500 focus:outline-none"
|
||||
placeholder={`Solution ${index + 1}`}
|
||||
/>
|
||||
<button
|
||||
onClick={() => onRemove(index)}
|
||||
className="p-2 text-gray-500 hover:text-red-500 hover:bg-red-50 rounded-lg transition-colors"
|
||||
title="Delete solution"
|
||||
>
|
||||
<MdDelete size={20} />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
<button
|
||||
onClick={onAdd}
|
||||
className="w-full mt-2 p-2 border-2 border-dashed border-gray-300 rounded-lg hover:border-blue-500 hover:bg-blue-50 transition-colors flex items-center justify-center gap-2 text-gray-600 hover:text-blue-600"
|
||||
>
|
||||
<MdAdd size={18} />
|
||||
Add Alternative Solution
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AlternativeSolutions;
|
||||
@@ -0,0 +1,234 @@
|
||||
import useSectionEdit from "@/components/ExamEditor/Hooks/useSectionEdit";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { WriteBlanksExercise, ReadingPart, Difficulty } from "@/interfaces/exam";
|
||||
import useExamEditorStore from "@/stores/examEditor";
|
||||
import { useState, useReducer, useEffect, useCallback } from "react";
|
||||
import { toast } from "react-toastify";
|
||||
import BlanksEditor from "..";
|
||||
import { AlertItem } from "../../Shared/Alert";
|
||||
import setEditingAlert from "../../Shared/setEditingAlert";
|
||||
import { blanksReducer } from "../BlanksReducer";
|
||||
import { validateWriteBlanks } from "./validation";
|
||||
import AlternativeSolutions from "./AlternativeSolutions";
|
||||
|
||||
const WriteBlanksFill: React.FC<{ exercise: WriteBlanksExercise; sectionId: number }> = ({ exercise, sectionId }) => {
|
||||
const { currentModule, dispatch } = useExamEditorStore();
|
||||
const difficulty = useExamEditorStore((state) => state.modules[currentModule].difficulty);
|
||||
const { state } = useExamEditorStore(
|
||||
(state) => state.modules[currentModule].sections.find((section) => section.sectionId === sectionId)!
|
||||
);
|
||||
|
||||
const section = state as ReadingPart;
|
||||
|
||||
const [alerts, setAlerts] = useState<AlertItem[]>([]);
|
||||
const [local, setLocal] = useState(exercise);
|
||||
const [selectedBlankId, setSelectedBlankId] = useState<string | null>(null);
|
||||
const [editing, setEditing] = useState(false);
|
||||
|
||||
const updateLocal = (exercise: WriteBlanksExercise) => {
|
||||
setLocal(exercise);
|
||||
setEditingAlert(true, setAlerts);
|
||||
setEditing(true);
|
||||
};
|
||||
|
||||
const [blanksState, blanksDispatcher] = useReducer(blanksReducer, {
|
||||
text: exercise.text || "",
|
||||
blanks: [],
|
||||
selectedBlankId: null,
|
||||
draggedItemId: null,
|
||||
textMode: false,
|
||||
setEditing,
|
||||
});
|
||||
|
||||
const { handleSave, handleDiscard, handleDelete, handlePractice } = useSectionEdit({
|
||||
sectionId,
|
||||
editing,
|
||||
setEditing,
|
||||
onSave: () => {
|
||||
if (!validateWriteBlanks(local.solutions, local.maxWords, setAlerts)) {
|
||||
toast.error("Please fix the errors before saving!");
|
||||
return;
|
||||
}
|
||||
|
||||
setEditing(false);
|
||||
setAlerts([]);
|
||||
|
||||
const updatedExercise = {
|
||||
...local,
|
||||
text: blanksState.text,
|
||||
};
|
||||
|
||||
const newState = { ...section };
|
||||
newState.exercises = newState.exercises.map((ex) =>
|
||||
ex.id === exercise.id ? updatedExercise : ex
|
||||
);
|
||||
|
||||
dispatch({ type: 'UPDATE_SECTION_STATE', payload: { sectionId, update: newState, module: currentModule } });
|
||||
},
|
||||
onDiscard: () => {
|
||||
setSelectedBlankId(null);
|
||||
setLocal(exercise);
|
||||
blanksDispatcher({ type: "RESET", payload: { text: exercise.text } });
|
||||
},
|
||||
onDelete: () => {
|
||||
const newSection = {
|
||||
...section,
|
||||
exercises: section.exercises.filter((ex) => ex.id !== local.id)
|
||||
};
|
||||
dispatch({ type: "UPDATE_SECTION_STATE", payload: { sectionId, update: newSection, module: currentModule } });
|
||||
},
|
||||
onPractice: () => {
|
||||
const updatedExercise = {
|
||||
...local,
|
||||
isPractice: !local.isPractice
|
||||
};
|
||||
const newState = { ...section };
|
||||
newState.exercises = newState.exercises.map((ex) =>
|
||||
ex.id === exercise.id ? updatedExercise : ex
|
||||
);
|
||||
setLocal((prev) => ({ ...prev, isPractice: !local.isPractice }))
|
||||
dispatch({ type: 'UPDATE_SECTION_STATE', payload: { sectionId, update: newState, module: currentModule } });
|
||||
}
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!editing) {
|
||||
setLocal(exercise);
|
||||
}
|
||||
}, [exercise, editing]);
|
||||
|
||||
const handleAddSolution = (blankId: string) => {
|
||||
if (!editing) setEditing(true);
|
||||
setLocal(prev => ({
|
||||
...prev,
|
||||
solutions: prev.solutions.map(s =>
|
||||
s.id === blankId
|
||||
? { ...s, solution: [...s.solution, ""] }
|
||||
: s
|
||||
)
|
||||
}));
|
||||
};
|
||||
|
||||
const handleRemoveSolution = (blankId: string, index: number) => {
|
||||
if (!editing) setEditing(true);
|
||||
|
||||
const solutions = local.solutions.find(s => s.id === blankId);
|
||||
if (solutions && solutions.solution.length <= 1) {
|
||||
toast.error("Each blank must have at least one solution!");
|
||||
return;
|
||||
}
|
||||
|
||||
setLocal(prev => ({
|
||||
...prev,
|
||||
solutions: prev.solutions.map(s =>
|
||||
s.id === blankId
|
||||
? { ...s, solution: s.solution.filter((_, i) => i !== index) }
|
||||
: s
|
||||
)
|
||||
}));
|
||||
};
|
||||
|
||||
const handleEditSolution = (blankId: string, index: number, value: string) => {
|
||||
if (!editing) setEditing(true);
|
||||
|
||||
setLocal(prev => ({
|
||||
...prev,
|
||||
solutions: prev.solutions.map(s =>
|
||||
s.id === blankId
|
||||
? {
|
||||
...s,
|
||||
solution: s.solution.map((sol, i) => i === index ? value : sol)
|
||||
}
|
||||
: s
|
||||
)
|
||||
}));
|
||||
};
|
||||
|
||||
const handleBlankRemove = (blankId: number) => {
|
||||
if (!editing) setEditing(true);
|
||||
setLocal(prev => ({
|
||||
...prev,
|
||||
solutions: prev.solutions.filter(s => s.id !== blankId.toString())
|
||||
}));
|
||||
|
||||
blanksDispatcher({ type: "REMOVE_BLANK", payload: blankId });
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
validateWriteBlanks(local.solutions, local.maxWords, setAlerts);
|
||||
}, [local.solutions, local.maxWords]);
|
||||
|
||||
useEffect(() => {
|
||||
setEditingAlert(editing, setAlerts);
|
||||
}, [editing]);
|
||||
|
||||
const saveDifficulty = useCallback((diff: Difficulty) => {
|
||||
if (!difficulty.includes(diff)) {
|
||||
dispatch({ type: 'UPDATE_MODULE', payload: { updates: { difficulty: [...difficulty, diff]} } });
|
||||
}
|
||||
const updatedExercise = { ...exercise, difficulty: diff };
|
||||
const newState = { ...section };
|
||||
newState.exercises = newState.exercises.map((ex) => ex.id === exercise.id ? updatedExercise : ex );
|
||||
dispatch({ type: 'UPDATE_SECTION_STATE', payload: { sectionId, update: newState, module: currentModule } });
|
||||
}, [currentModule, difficulty, dispatch, exercise, section, sectionId]);
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<BlanksEditor
|
||||
title="Write Blanks: Fill"
|
||||
alerts={alerts}
|
||||
editing={editing}
|
||||
state={blanksState}
|
||||
blanksDispatcher={blanksDispatcher}
|
||||
description={local.prompt}
|
||||
initialText={local.text}
|
||||
difficulty={exercise.difficulty}
|
||||
saveDifficulty={saveDifficulty}
|
||||
module={currentModule}
|
||||
showBlankBank={true}
|
||||
onBlankSelect={(blankId) => setSelectedBlankId(blankId?.toString() || null)}
|
||||
onBlankRemove={handleBlankRemove}
|
||||
onSave={handleSave}
|
||||
onDiscard={handleDiscard}
|
||||
onDelete={handleDelete}
|
||||
onPractice={handlePractice}
|
||||
setEditing={setEditing}
|
||||
isEvaluationEnabled={!local.isPractice}
|
||||
prompt={local.prompt}
|
||||
updatePrompt={(prompt: string) => updateLocal({ ...local, prompt })}
|
||||
>
|
||||
{!blanksState.textMode && (
|
||||
<Card>
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<span className="text-lg font-semibold">
|
||||
{selectedBlankId
|
||||
? `Solutions for Blank ${selectedBlankId}`
|
||||
: "Click a blank to edit its solutions"}
|
||||
</span>
|
||||
{selectedBlankId && (
|
||||
<span className="text-sm text-gray-500">
|
||||
Max words per solution: {local.maxWords}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-4">
|
||||
{selectedBlankId && (
|
||||
<AlternativeSolutions
|
||||
solutions={local.solutions.find(s => s.id === selectedBlankId)?.solution || []}
|
||||
onAdd={() => handleAddSolution(selectedBlankId)}
|
||||
onRemove={(index: number) => handleRemoveSolution(selectedBlankId, index)}
|
||||
onEdit={(index: number, value: string) => handleEditSolution(selectedBlankId, index, value)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</BlanksEditor>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default WriteBlanksFill;
|
||||
@@ -0,0 +1,58 @@
|
||||
import { AlertItem } from "../../Shared/Alert";
|
||||
|
||||
|
||||
export const validateWriteBlanks = (
|
||||
solutions: { id: string; solution: string[] }[],
|
||||
maxWords: number,
|
||||
setAlerts: React.Dispatch<React.SetStateAction<AlertItem[]>>
|
||||
): boolean => {
|
||||
let isValid = true;
|
||||
|
||||
const emptySolutions = solutions.flatMap(s =>
|
||||
s.solution.map((sol, index) => ({
|
||||
blankId: s.id,
|
||||
solutionIndex: index,
|
||||
isEmpty: !sol.trim()
|
||||
}))
|
||||
).filter(({ isEmpty }) => isEmpty);
|
||||
|
||||
if (emptySolutions.length > 0) {
|
||||
isValid = false;
|
||||
setAlerts(prev => {
|
||||
const filtered = prev.filter(a => !a.tag?.startsWith('empty-solution'));
|
||||
return [...filtered, ...emptySolutions.map(({ blankId, solutionIndex }) => ({
|
||||
variant: "error" as const,
|
||||
tag: `empty-solution-${blankId}-${solutionIndex}`,
|
||||
description: `Solution ${solutionIndex + 1} for blank ${blankId} cannot be empty`
|
||||
}))];
|
||||
});
|
||||
} else {
|
||||
setAlerts(prev => prev.filter(a => !a.tag?.startsWith('empty-solution')));
|
||||
}
|
||||
|
||||
if (maxWords > 0) {
|
||||
const invalidWordCount = solutions.flatMap(s =>
|
||||
s.solution.map((sol, index) => ({
|
||||
blankId: s.id,
|
||||
solutionIndex: index,
|
||||
wordCount: sol.trim().split(/\s+/).length
|
||||
}))
|
||||
).filter(({ wordCount }) => wordCount > maxWords);
|
||||
|
||||
if (invalidWordCount.length > 0) {
|
||||
isValid = false;
|
||||
setAlerts(prev => {
|
||||
const filtered = prev.filter(a => !a.tag?.startsWith('word-count'));
|
||||
return [...filtered, ...invalidWordCount.map(({ blankId, solutionIndex, wordCount }) => ({
|
||||
variant: "error" as const,
|
||||
tag: `word-count-${blankId}-${solutionIndex}`,
|
||||
description: `Solution ${solutionIndex + 1} for blank ${blankId} exceeds maximum of ${maxWords} words (current: ${wordCount} words)`
|
||||
}))];
|
||||
});
|
||||
} else {
|
||||
setAlerts(prev => prev.filter(a => !a.tag?.startsWith('word-count')));
|
||||
}
|
||||
}
|
||||
|
||||
return isValid;
|
||||
};
|
||||
283
src/components/ExamEditor/Exercises/Blanks/index.tsx
Normal file
@@ -0,0 +1,283 @@
|
||||
import React, { useCallback, useMemo, useReducer, useEffect, ReactNode } from "react";
|
||||
import {
|
||||
DndContext,
|
||||
DragEndEvent,
|
||||
DragStartEvent,
|
||||
MeasuringStrategy,
|
||||
PointerSensor,
|
||||
useSensor,
|
||||
useSensors,
|
||||
} from "@dnd-kit/core";
|
||||
import {
|
||||
restrictToWindowEdges,
|
||||
snapCenterToCursor,
|
||||
} from "@dnd-kit/modifiers";
|
||||
import AutoExpandingTextArea from "@/components/Low/AutoExpandingTextarea";
|
||||
import Header from "../../Shared/Header";
|
||||
import Alert, { AlertItem } from "../Shared/Alert";
|
||||
import clsx from "clsx";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Blank, DropZone } from "./DragNDrop";
|
||||
import { getTextSegments, BlankState, BlanksState, BlanksAction, BlankToken } from "./BlanksReducer";
|
||||
import PromptEdit from "../Shared/PromptEdit";
|
||||
import { Difficulty } from "@/interfaces/exam";
|
||||
|
||||
|
||||
interface Props {
|
||||
title?: string;
|
||||
initialText: string;
|
||||
description: string;
|
||||
difficulty?: Difficulty;
|
||||
saveDifficulty: (difficulty: Difficulty) => void;
|
||||
state: BlanksState;
|
||||
module: string;
|
||||
editing: boolean;
|
||||
showBlankBank: boolean;
|
||||
alerts: AlertItem[];
|
||||
prompt: string;
|
||||
updatePrompt: (prompt: string) => void;
|
||||
setEditing: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
blanksDispatcher: React.Dispatch<BlanksAction>
|
||||
onBlankSelect?: (blankId: number | null) => void;
|
||||
onBlankRemove: (blankId: number) => void;
|
||||
onSave: () => void;
|
||||
onDiscard: () => void;
|
||||
onDelete: () => void;
|
||||
onPractice: () => void;
|
||||
isEvaluationEnabled?: boolean;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
const BlanksEditor: React.FC<Props> = ({
|
||||
title = "Fill Blanks",
|
||||
initialText,
|
||||
description,
|
||||
difficulty,
|
||||
saveDifficulty,
|
||||
state,
|
||||
editing,
|
||||
module,
|
||||
children,
|
||||
showBlankBank = true,
|
||||
alerts,
|
||||
blanksDispatcher,
|
||||
onBlankSelect,
|
||||
onBlankRemove,
|
||||
onSave,
|
||||
onDiscard,
|
||||
onDelete,
|
||||
onPractice,
|
||||
isEvaluationEnabled,
|
||||
setEditing,
|
||||
prompt,
|
||||
updatePrompt
|
||||
}) => {
|
||||
|
||||
useEffect(() => {
|
||||
const tokens = getTextSegments(initialText);
|
||||
const initialBlanks = tokens.reduce((acc, token, idx) => {
|
||||
if (token.type === 'blank') {
|
||||
acc.push({ id: token.id, position: idx });
|
||||
}
|
||||
return acc;
|
||||
}, [] as BlankState[]);
|
||||
|
||||
blanksDispatcher({ type: "SET_TEXT", payload: initialText });
|
||||
blanksDispatcher({ type: "SET_BLANKS", payload: initialBlanks });
|
||||
}, [initialText, blanksDispatcher]);
|
||||
|
||||
const tokens = useMemo(() => {
|
||||
return getTextSegments(state.text || "");
|
||||
}, [state.text]);
|
||||
|
||||
const handleDragStart = useCallback((event: DragStartEvent) => {
|
||||
blanksDispatcher({ type: "SET_DRAGGED_ITEM", payload: event.active.id.toString() });
|
||||
}, [blanksDispatcher]);
|
||||
|
||||
const handleDragEnd = useCallback(
|
||||
(event: DragEndEvent) => {
|
||||
const { active, over } = event;
|
||||
if (!over) return;
|
||||
|
||||
const blankId = parseInt(active.id.toString().split("-").pop() || "");
|
||||
const dropIndex = parseInt(over.id.toString().split("-")[1]);
|
||||
|
||||
blanksDispatcher({
|
||||
type: "MOVE_BLANK",
|
||||
payload: { blankId, newPosition: dropIndex },
|
||||
});
|
||||
|
||||
blanksDispatcher({ type: "SET_DRAGGED_ITEM", payload: null });
|
||||
},
|
||||
[blanksDispatcher]
|
||||
);
|
||||
|
||||
const handleTextChange = useCallback(
|
||||
(newText: string) => {
|
||||
const processedText = newText.replace(/\[(\d+)\]/g, "{{$1}}");
|
||||
|
||||
const existingBlankIds = getTextSegments(state.text)
|
||||
.filter(token => token.type === 'blank')
|
||||
.map(token => (token as BlankToken).id);
|
||||
|
||||
const newBlankIds = getTextSegments(processedText)
|
||||
.filter(token => token.type === 'blank')
|
||||
.map(token => (token as BlankToken).id);
|
||||
|
||||
const removedBlankIds = existingBlankIds.filter(id => !newBlankIds.includes(id));
|
||||
|
||||
removedBlankIds.forEach(id => {
|
||||
onBlankRemove(id);
|
||||
});
|
||||
|
||||
blanksDispatcher({ type: "SET_TEXT", payload: processedText });
|
||||
},
|
||||
[blanksDispatcher, state.text, onBlankRemove]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (onBlankSelect !== undefined) onBlankSelect(state.selectedBlankId);
|
||||
}, [state.selectedBlankId, onBlankSelect]);
|
||||
|
||||
const handleBlankSelect = (blankId: number) => {
|
||||
blanksDispatcher({
|
||||
type: "SELECT_BLANK",
|
||||
payload: blankId === state.selectedBlankId ? null : blankId,
|
||||
});
|
||||
};
|
||||
|
||||
const handleBlankRemove = useCallback((blankId: number) => {
|
||||
onBlankRemove(blankId);
|
||||
blanksDispatcher({ type: "REMOVE_BLANK", payload: blankId });
|
||||
}, [blanksDispatcher, onBlankRemove]);
|
||||
|
||||
const sensors = useSensors(
|
||||
useSensor(PointerSensor, {
|
||||
activationConstraint: {
|
||||
distance: 4,
|
||||
tolerance: 5,
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
const modifiers = [snapCenterToCursor, restrictToWindowEdges];
|
||||
|
||||
const measuring = {
|
||||
droppable: {
|
||||
strategy: MeasuringStrategy.Always,
|
||||
},
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4 p-4">
|
||||
<Header
|
||||
title={title}
|
||||
description={description}
|
||||
editing={editing}
|
||||
difficulty={difficulty}
|
||||
saveDifficulty={saveDifficulty}
|
||||
handleSave={onSave}
|
||||
handleDelete={onDelete}
|
||||
handleDiscard={onDiscard}
|
||||
handlePractice={onPractice}
|
||||
isEvaluationEnabled={isEvaluationEnabled}
|
||||
/>
|
||||
{alerts.length > 0 && <Alert alerts={alerts} />}
|
||||
<PromptEdit value={prompt} onChange={(text: string) => updatePrompt(text)} />
|
||||
<Card>
|
||||
<CardContent className="p-4 text-white font-semibold flex gap-2">
|
||||
<button
|
||||
onClick={() => blanksDispatcher({ type: "ADD_BLANK" })}
|
||||
className={`px-3 py-1.5 bg-ielts-${module} rounded-md hover:bg-ielts-${module}/50 transition-colors`}
|
||||
>
|
||||
Add Blank
|
||||
</button>
|
||||
<button
|
||||
onClick={() => blanksDispatcher({ type: "TOGGLE_EDIT_MODE" })}
|
||||
className={clsx(
|
||||
"px-3 py-1.5 rounded-md transition-colors",
|
||||
`bg-ielts-${module} text-white hover:bg-ielts-${module}/50`
|
||||
)}
|
||||
>
|
||||
{state.textMode ? "Drag Mode" : "Text Mode"}
|
||||
</button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
onDragStart={handleDragStart}
|
||||
onDragEnd={handleDragEnd}
|
||||
modifiers={modifiers}
|
||||
measuring={measuring}
|
||||
>
|
||||
<Card>
|
||||
<CardContent className="p-4">
|
||||
{state.textMode ? (
|
||||
<AutoExpandingTextArea
|
||||
value={state.text.replace(/{{(\d+)}}/g, "[$1]")}
|
||||
onChange={(text) => { handleTextChange(text); if (!editing) setEditing(true) }}
|
||||
className="w-full h-full min-h-[200px] p-2 bg-white border rounded-md"
|
||||
placeholder="Enter text here. Use [1], [2], etc. for blanks..."
|
||||
/>
|
||||
) : (
|
||||
<div className="leading-relaxed p-4">
|
||||
{tokens.map((token, index) => {
|
||||
const isWordToken = token.type === 'text' && !token.isWhitespace;
|
||||
const showDropZone = isWordToken || token.type === 'blank';
|
||||
|
||||
return (
|
||||
<React.Fragment key={index}>
|
||||
{showDropZone && <DropZone index={index} module={module} />}
|
||||
{token.type === 'blank' ? (
|
||||
<Blank
|
||||
id={token.id}
|
||||
module={module}
|
||||
variant="text"
|
||||
isSelected={token.id === state.selectedBlankId}
|
||||
isDragging={state.draggedItemId === `text-blank-${token.id}`}
|
||||
/>
|
||||
) : token.isLineBreak ? (
|
||||
<br />
|
||||
) : (
|
||||
<span className="select-none">{token.content}</span>
|
||||
)}
|
||||
</React.Fragment>
|
||||
);
|
||||
})}
|
||||
{tokens.length > 0 &&
|
||||
tokens[tokens.length - 1].type === 'text' && (
|
||||
<DropZone index={tokens.length} module={module} />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
|
||||
{(!state.textMode && showBlankBank) && (
|
||||
<Card>
|
||||
<CardContent className="flex flex-wrap gap-2 p-4">
|
||||
{state.blanks.map(blank => (
|
||||
<Blank
|
||||
key={blank.id}
|
||||
id={blank.id}
|
||||
module={module}
|
||||
variant="bank"
|
||||
isSelected={blank.id === state.selectedBlankId}
|
||||
isDragging={state.draggedItemId === `bank-blank-${blank.id}`}
|
||||
onSelect={handleBlankSelect}
|
||||
onRemove={handleBlankRemove}
|
||||
disabled={state.textMode}
|
||||
/>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
{children}
|
||||
</DndContext>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default BlanksEditor;
|
||||
38
src/components/ExamEditor/Exercises/Blanks/validateBlanks.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import { AlertItem } from "../Shared/Alert";
|
||||
import { BlankState } from "./BlanksReducer";
|
||||
|
||||
|
||||
const validateBlanks = (
|
||||
blanks: BlankState[],
|
||||
answers: Map<string, string>,
|
||||
alerts: AlertItem[],
|
||||
setAlerts: React.Dispatch<React.SetStateAction<AlertItem[]>>,
|
||||
save: boolean = false,
|
||||
): boolean => {
|
||||
const unfilledBlanks = blanks.filter(blank => !answers.has(blank.id.toString()));
|
||||
const filteredAlerts = alerts.filter(alert => alert.tag !== "unfilled-blanks");
|
||||
|
||||
if (unfilledBlanks.length > 0) {
|
||||
if (!save && !filteredAlerts.some(alert => alert.tag === "editing")) {
|
||||
filteredAlerts.push({
|
||||
variant: "info",
|
||||
description: "You have unsaved changes. Don't forget to save your work!",
|
||||
tag: "editing"
|
||||
});
|
||||
}
|
||||
setAlerts([
|
||||
...filteredAlerts,
|
||||
{
|
||||
variant: "error",
|
||||
tag: "unfilled-blanks",
|
||||
description: `${unfilledBlanks.length} blank${unfilledBlanks.length > 1 ? 's' : ''} ${unfilledBlanks.length > 1 ? 'are' : 'is'} missing a word (blanks: ${unfilledBlanks.map(blank => blank.id).join(", ")})`
|
||||
}
|
||||
]);
|
||||
return false;
|
||||
} else if (filteredAlerts.length !== alerts.length) {
|
||||
setAlerts(filteredAlerts);
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
export default validateBlanks;
|
||||
@@ -0,0 +1,45 @@
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { MatchSentenceExerciseOption } from "@/interfaces/exam";
|
||||
import { MdVisibilityOff } from "react-icons/md";
|
||||
|
||||
interface Props {
|
||||
showReference: boolean;
|
||||
selectedReference: string | null;
|
||||
options: MatchSentenceExerciseOption[];
|
||||
headings: boolean;
|
||||
setShowReference: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
}
|
||||
|
||||
const ReferenceViewer: React.FC<Props> = ({ showReference, selectedReference, options, setShowReference, headings = true}) => (
|
||||
<div
|
||||
className={`fixed inset-y-0 right-0 w-96 bg-white shadow-lg transform transition-transform duration-300 z-50 ease-in-out ${showReference ? 'translate-x-0' : 'translate-x-full'}`}
|
||||
>
|
||||
<div className="h-full flex flex-col">
|
||||
<div className="p-4 border-b bg-gray-50 flex justify-between items-center">
|
||||
<h3 className="font-semibold text-gray-800">{headings ? "Reference Paragraphs" : "Authors"}</h3>
|
||||
<button
|
||||
onClick={() => setShowReference(false)}
|
||||
className="p-2 hover:bg-gray-200 rounded-full"
|
||||
>
|
||||
<MdVisibilityOff size={20} />
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto p-4">
|
||||
<div className="space-y-4">
|
||||
{options.map((option) => (
|
||||
<Card key={option.id} className={`bg-gray-50 transition-all duration-200 ${selectedReference === option.id ? 'ring-2 ring-blue-500' : ''}`}>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-md text-black">{headings ? "Paragraph" : "Author" } {option.id}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-sm text-gray-600">{option.sentence}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
export default ReferenceViewer;
|
||||
263
src/components/ExamEditor/Exercises/MatchSentences/index.tsx
Normal file
@@ -0,0 +1,263 @@
|
||||
import React, { useState, useMemo, useEffect, useCallback } from 'react';
|
||||
import {
|
||||
MdAdd,
|
||||
MdVisibility,
|
||||
MdVisibilityOff
|
||||
} from 'react-icons/md';
|
||||
import { Difficulty, MatchSentencesExercise, ReadingPart } from '@/interfaces/exam';
|
||||
import Alert, { AlertItem } from '../Shared/Alert';
|
||||
import ReferenceViewer from './ParagraphViewer';
|
||||
import Header from '../../Shared/Header';
|
||||
import SortableQuestion from '../Shared/SortableQuestion';
|
||||
import QuestionsList from '../Shared/QuestionsList';
|
||||
import useExamEditorStore from '@/stores/examEditor';
|
||||
import useSectionEdit from '../../Hooks/useSectionEdit';
|
||||
import validateMatchSentences from './validation';
|
||||
import setEditingAlert from '../Shared/setEditingAlert';
|
||||
import { toast } from 'react-toastify';
|
||||
import { DragEndEvent } from '@dnd-kit/core';
|
||||
import { handleMatchSentencesReorder } from '@/stores/examEditor/reorder/local';
|
||||
import PromptEdit from '../Shared/PromptEdit';
|
||||
import { uuidv4 } from '@firebase/util';
|
||||
|
||||
const MatchSentences: React.FC<{ exercise: MatchSentencesExercise, sectionId: number }> = ({ exercise, sectionId }) => {
|
||||
const { currentModule, dispatch } = useExamEditorStore();
|
||||
const difficulty = useExamEditorStore((state) => state.modules[currentModule].difficulty);
|
||||
const { state } = useExamEditorStore(
|
||||
(state) => state.modules[currentModule].sections.find((section) => section.sectionId === sectionId)!
|
||||
);
|
||||
|
||||
const section = state as ReadingPart;
|
||||
|
||||
const [local, setLocal] = useState(exercise);
|
||||
const [selectedParagraph, setSelectedParagraph] = useState<string | null>(null);
|
||||
const [showReference, setShowReference] = useState(false);
|
||||
const [alerts, setAlerts] = useState<AlertItem[]>([]);
|
||||
|
||||
const updateLocal = (exercise: MatchSentencesExercise) => {
|
||||
setLocal(exercise);
|
||||
setEditing(true);
|
||||
};
|
||||
|
||||
const { editing, setEditing, handleSave, handleDiscard, handleDelete, handlePractice } = useSectionEdit({
|
||||
sectionId,
|
||||
onSave: () => {
|
||||
|
||||
const isValid = validateMatchSentences(local.sentences, setAlerts);
|
||||
|
||||
if (!isValid) {
|
||||
toast.error("Please fix the errors before saving!");
|
||||
return;
|
||||
}
|
||||
|
||||
setEditing(false);
|
||||
setAlerts([]);
|
||||
|
||||
const newState = { ...section };
|
||||
newState.exercises = newState.exercises.map((ex) => ex.id === exercise.id ? local : ex);
|
||||
dispatch({ type: 'UPDATE_SECTION_STATE', payload: { sectionId, update: newState, module: currentModule } });
|
||||
},
|
||||
onDiscard: () => {
|
||||
setLocal(exercise);
|
||||
setSelectedParagraph(null);
|
||||
setShowReference(false);
|
||||
},
|
||||
onDelete: () => {
|
||||
const newSection = {
|
||||
...section,
|
||||
exercises: section.exercises.filter((ex) => ex.id !== local.id)
|
||||
};
|
||||
dispatch({ type: "UPDATE_SECTION_STATE", payload: { sectionId, update: newSection, module: currentModule } });
|
||||
},
|
||||
onPractice: () => {
|
||||
const updatedExercise = {
|
||||
...local,
|
||||
isPractice: !local.isPractice
|
||||
};
|
||||
const newState = { ...section };
|
||||
newState.exercises = newState.exercises.map((ex) =>
|
||||
ex.id === exercise.id ? updatedExercise : ex
|
||||
);
|
||||
setLocal((prev) => ({...prev, isPractice: !local.isPractice}))
|
||||
dispatch({ type: 'UPDATE_SECTION_STATE', payload: { sectionId, update: newState, module: currentModule } });
|
||||
}
|
||||
});
|
||||
|
||||
const usedOptions = useMemo(() => {
|
||||
return local.sentences.reduce((acc, sentence) => {
|
||||
if (sentence.solution) {
|
||||
acc.add(sentence.solution);
|
||||
}
|
||||
return acc;
|
||||
}, new Set<string>());
|
||||
}, [local.sentences]);
|
||||
|
||||
const addHeading = () => {
|
||||
const newId = (parseInt(local.sentences[local.sentences.length - 1].id) + 1).toString();
|
||||
updateLocal({
|
||||
...local,
|
||||
sentences: [
|
||||
...local.sentences,
|
||||
{
|
||||
uuid: uuidv4(),
|
||||
id: newId,
|
||||
sentence: "",
|
||||
solution: ""
|
||||
}
|
||||
]
|
||||
});
|
||||
};
|
||||
|
||||
const updateHeading = (index: number, field: string, value: string) => {
|
||||
const newSentences = [...local.sentences];
|
||||
|
||||
if (field === 'solution') {
|
||||
const oldSolution = newSentences[index].solution;
|
||||
if (oldSolution) {
|
||||
usedOptions.delete(oldSolution);
|
||||
}
|
||||
}
|
||||
|
||||
newSentences[index] = { ...newSentences[index], [field]: value };
|
||||
updateLocal({ ...local, sentences: newSentences });
|
||||
};
|
||||
|
||||
const deleteHeading = (index: number) => {
|
||||
if (local.sentences.length <= 1) {
|
||||
toast.error(`There needs to be at least one ${exercise.variant && exercise.variant == "ideaMatch" ? "idea/opinion" : "heading"}!`);
|
||||
return;
|
||||
}
|
||||
|
||||
const deletedSolution = local.sentences[index].solution;
|
||||
if (deletedSolution) {
|
||||
usedOptions.delete(deletedSolution);
|
||||
}
|
||||
|
||||
const newSentences = local.sentences.filter((_, i) => i !== index);
|
||||
updateLocal({ ...local, sentences: newSentences });
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
validateMatchSentences(local.sentences, setAlerts);
|
||||
}, [local.sentences]);
|
||||
|
||||
useEffect(() => {
|
||||
setEditingAlert(editing, setAlerts);
|
||||
}, [editing]);
|
||||
|
||||
|
||||
const handleDragEnd = (event: DragEndEvent) => {
|
||||
updateLocal(handleMatchSentencesReorder(event, local));
|
||||
}
|
||||
|
||||
const saveDifficulty = useCallback((diff: Difficulty) => {
|
||||
if (!difficulty.includes(diff)) {
|
||||
dispatch({ type: 'UPDATE_MODULE', payload: { updates: { difficulty: [...difficulty, diff]} } });
|
||||
}
|
||||
const updatedExercise = { ...exercise, difficulty: diff };
|
||||
const newState = { ...section };
|
||||
newState.exercises = newState.exercises.map((ex) => ex.id === exercise.id ? updatedExercise : ex );
|
||||
dispatch({ type: 'UPDATE_SECTION_STATE', payload: { sectionId, update: newState, module: currentModule } });
|
||||
}, [currentModule, difficulty, dispatch, exercise, section, sectionId]);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col mx-auto p-2">
|
||||
<Header
|
||||
title={exercise.variant && exercise.variant == "ideaMatch" ? "Idea Match" : "Paragraph Match"}
|
||||
description={`Edit ${exercise.variant && exercise.variant == "ideaMatch" ? "ideas/opinions" : "headings"} and their matches`}
|
||||
editing={editing}
|
||||
difficulty={exercise.difficulty}
|
||||
saveDifficulty={saveDifficulty}
|
||||
handleSave={handleSave}
|
||||
handleDelete={handleDelete}
|
||||
handleDiscard={handleDiscard}
|
||||
handlePractice={handlePractice}
|
||||
isEvaluationEnabled={!local.isPractice}
|
||||
>
|
||||
<button
|
||||
onClick={() => setShowReference(!showReference)}
|
||||
className="px-4 py-2 bg-gray-100 hover:bg-gray-200 text-gray-700 rounded-lg transition-colors flex items-center gap-2"
|
||||
>
|
||||
{showReference ? <MdVisibilityOff size={18} /> : <MdVisibility size={18} />}
|
||||
{showReference ? 'Hide Reference' : 'Show Reference'}
|
||||
</button>
|
||||
</Header>
|
||||
|
||||
<div className="space-y-4">
|
||||
{alerts.length > 0 && <Alert alerts={alerts} />}
|
||||
<PromptEdit
|
||||
value={local.prompt}
|
||||
onChange={(text) => updateLocal({ ...local, prompt: text })}
|
||||
/>
|
||||
<QuestionsList
|
||||
ids={local.sentences.map(s => s.id)}
|
||||
handleDragEnd={handleDragEnd}
|
||||
>
|
||||
{local.sentences.map((sentence, index) => (
|
||||
<SortableQuestion
|
||||
key={sentence.id}
|
||||
id={sentence.id}
|
||||
index={index}
|
||||
deleteQuestion={() => deleteHeading(index)}
|
||||
onFocus={() => setSelectedParagraph(sentence.solution)}
|
||||
>
|
||||
<>
|
||||
<input
|
||||
type="text"
|
||||
value={sentence.sentence}
|
||||
onChange={(e) => updateHeading(index, 'sentence', e.target.value)}
|
||||
className="w-full p-3 border rounded-lg focus:ring-2 focus:ring-blue-500 focus:outline-none text-mti-gray-dim"
|
||||
placeholder={`Enter ${exercise.variant && exercise.variant == "ideaMatch" ? "idea/opinion" : "heading"} ...`}
|
||||
/>
|
||||
<div className="flex items-center gap-3">
|
||||
<select
|
||||
value={sentence.solution}
|
||||
onChange={(e) => {
|
||||
updateHeading(index, 'solution', e.target.value);
|
||||
setSelectedParagraph(e.target.value);
|
||||
}}
|
||||
className="w-full p-3 border rounded-lg focus:ring-2 focus:ring-blue-500 focus:outline-none bg-white text-mti-gray-dim"
|
||||
>
|
||||
<option value="">Select matching {exercise.variant == "ideaMatch" ? "author" : "paragraph"}...</option>
|
||||
{local.options.map((option) => {
|
||||
const isUsed = usedOptions.has(option.id);
|
||||
const isCurrentSelection = sentence.solution === option.id;
|
||||
|
||||
return (
|
||||
<option
|
||||
key={option.id}
|
||||
value={option.id}
|
||||
disabled={isUsed && !isCurrentSelection}
|
||||
>
|
||||
{exercise.variant == "ideaMatch" ? "Author" : "Paragraph"} {option.id}
|
||||
</option>
|
||||
);
|
||||
})}
|
||||
</select>
|
||||
</div>
|
||||
</>
|
||||
</SortableQuestion>
|
||||
))}
|
||||
</QuestionsList>
|
||||
{(section.text !== undefined && section.text.content.split("\n\n").length - 1) === local.sentences.length && (
|
||||
<button
|
||||
onClick={addHeading}
|
||||
className="w-full p-4 border-2 border-dashed border-gray-300 rounded-lg hover:border-blue-500 hover:bg-blue-50 transition-colors flex items-center justify-center gap-2 text-gray-600 hover:text-blue-600"
|
||||
>
|
||||
<MdAdd size={18} />
|
||||
Add New Match
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<ReferenceViewer
|
||||
headings={exercise.variant !== "ideaMatch"}
|
||||
showReference={showReference}
|
||||
selectedReference={selectedParagraph}
|
||||
options={local.options}
|
||||
setShowReference={setShowReference}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default MatchSentences;
|
||||
@@ -0,0 +1,42 @@
|
||||
import { AlertItem } from "../Shared/Alert";
|
||||
|
||||
const validateMatchSentences = (
|
||||
sentences: {id: string; sentence: string; solution: string;}[],
|
||||
setAlerts: React.Dispatch<React.SetStateAction<AlertItem[]>>
|
||||
): boolean => {
|
||||
let hasErrors = false;
|
||||
|
||||
const emptySentences = sentences.filter(s => !s.sentence.trim());
|
||||
if (emptySentences.length > 0) {
|
||||
hasErrors = true;
|
||||
setAlerts(prev => {
|
||||
const filteredAlerts = prev.filter(alert => !alert.tag?.startsWith('empty-sentence'));
|
||||
return [...filteredAlerts, ...emptySentences.map(s => ({
|
||||
variant: "error" as const,
|
||||
tag: `empty-sentence-${s.id}`,
|
||||
description: `Heading ${s.id} is empty`
|
||||
}))];
|
||||
});
|
||||
} else {
|
||||
setAlerts(prev => prev.filter(alert => !alert.tag?.startsWith('empty-sentence')));
|
||||
}
|
||||
|
||||
const unmatchedSentences = sentences.filter(s => !s.solution);
|
||||
if (unmatchedSentences.length > 0) {
|
||||
hasErrors = true;
|
||||
setAlerts(prev => {
|
||||
const filteredAlerts = prev.filter(alert => !alert.tag?.startsWith('unmatched-sentence'));
|
||||
return [...filteredAlerts, ...unmatchedSentences.map(s => ({
|
||||
variant: "error" as const,
|
||||
tag: `unmatched-sentence-${s.id}`,
|
||||
description: `Heading ${s.id} has no paragraph selected`
|
||||
}))];
|
||||
});
|
||||
} else {
|
||||
setAlerts(prev => prev.filter(alert => !alert.tag?.startsWith('unmatched-sentence')));
|
||||
}
|
||||
|
||||
return !hasErrors;
|
||||
};
|
||||
|
||||
export default validateMatchSentences;
|
||||
@@ -0,0 +1,191 @@
|
||||
import { MultipleChoiceQuestion } from "@/interfaces/exam";
|
||||
import clsx from "clsx";
|
||||
import { useEffect, useState } from "react";
|
||||
import { MdEdit, MdEditOff } from "react-icons/md";
|
||||
|
||||
interface UnderlineQuestionProps {
|
||||
question: MultipleChoiceQuestion;
|
||||
onQuestionChange: (updatedQuestion: MultipleChoiceQuestion) => void;
|
||||
onValidationChange?: (isValid: boolean) => void;
|
||||
}
|
||||
|
||||
interface Option {
|
||||
id: string;
|
||||
text?: string;
|
||||
src?: string;
|
||||
}
|
||||
|
||||
export const UnderlineQuestion: React.FC<UnderlineQuestionProps> = ({
|
||||
question,
|
||||
onQuestionChange,
|
||||
onValidationChange,
|
||||
}) => {
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
const [validationErrors, setValidationErrors] = useState<string[]>([]);
|
||||
|
||||
const stripUnderlineTags = (text: string = '') => text.replace(/<\/?u>/g, '');
|
||||
|
||||
const addUnderlineTags = (text: string, options: Option[]) => {
|
||||
let result = text;
|
||||
|
||||
// Sort options by length (longest first) to handle overlapping matches
|
||||
const sortedOptions = [...options]
|
||||
.filter(opt => opt.text?.trim() && opt.text.trim().length > 1)
|
||||
.sort((a, b) => ((b.text?.length || 0) - (a.text?.length || 0)));
|
||||
|
||||
for (const option of sortedOptions) {
|
||||
if (!option.text?.trim()) continue;
|
||||
|
||||
const optionText = stripUnderlineTags(option.text).trim();
|
||||
const textLower = result.toLowerCase();
|
||||
const optionLower = optionText.toLowerCase();
|
||||
|
||||
let startIndex = textLower.indexOf(optionLower);
|
||||
while (startIndex !== -1) {
|
||||
// Check if this portion is already underlined
|
||||
const beforeTag = result.slice(Math.max(0, startIndex - 3), startIndex);
|
||||
const afterTag = result.slice(startIndex + optionText.length, startIndex + optionText.length + 4);
|
||||
|
||||
if (!beforeTag.includes('<u>') && !afterTag.includes('</u>')) {
|
||||
const before = result.substring(0, startIndex);
|
||||
const match = result.substring(startIndex, startIndex + optionText.length);
|
||||
const after = result.substring(startIndex + optionText.length);
|
||||
result = `${before}<u>${match}</u>${after}`;
|
||||
}
|
||||
|
||||
// Find next occurrence
|
||||
startIndex = textLower.indexOf(optionLower, startIndex + 1);
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
};
|
||||
|
||||
const validateQuestion = (q: MultipleChoiceQuestion) => {
|
||||
const errors: string[] = [];
|
||||
const rawPrompt = stripUnderlineTags(q.prompt).toLowerCase();
|
||||
|
||||
q.options.forEach((option) => {
|
||||
if (option.text?.trim() && !rawPrompt.includes(stripUnderlineTags(option.text).trim().toLowerCase())) {
|
||||
errors.push(`Option ${option.id} text not found in prompt`);
|
||||
}
|
||||
});
|
||||
|
||||
setValidationErrors(errors);
|
||||
onValidationChange?.(errors.length === 0);
|
||||
return errors.length === 0;
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
validateQuestion(question);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [question]);
|
||||
|
||||
const handlePromptChange = (value: string) => {
|
||||
const newPrompt = addUnderlineTags(value, question.options);
|
||||
onQuestionChange({
|
||||
...question,
|
||||
prompt: newPrompt
|
||||
});
|
||||
};
|
||||
|
||||
const handleOptionChange = (optionIndex: number, value: string) => {
|
||||
const updatedOptions = question.options.map((opt, idx) =>
|
||||
idx === optionIndex ? { ...opt, text: value } : opt
|
||||
);
|
||||
|
||||
const strippedPrompt = stripUnderlineTags(question.prompt);
|
||||
const newPrompt = addUnderlineTags(strippedPrompt, updatedOptions);
|
||||
|
||||
onQuestionChange({
|
||||
...question,
|
||||
prompt: newPrompt,
|
||||
options: updatedOptions
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex gap-2 items-center">
|
||||
{isEditing ? (
|
||||
<input
|
||||
value={stripUnderlineTags(question.prompt)}
|
||||
onChange={(e) => handlePromptChange(e.target.value)}
|
||||
className="flex-1 p-3 border rounded-lg focus:outline-none"
|
||||
placeholder="Enter text for underlining..."
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
className="flex-1 p-3 border rounded-lg min-h-[50px]"
|
||||
dangerouslySetInnerHTML={{ __html: question.prompt || '' }}
|
||||
/>
|
||||
)}
|
||||
<button
|
||||
onClick={() => setIsEditing(!isEditing)}
|
||||
className="p-2 hover:bg-gray-100 rounded-lg transition-colors"
|
||||
>
|
||||
{isEditing ?
|
||||
<MdEditOff size={24} className="text-gray-500" /> :
|
||||
<MdEdit size={24} className="text-gray-500" />
|
||||
}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{validationErrors.length > 0 && (
|
||||
<div className="text-red-500 text-sm">
|
||||
{validationErrors.map((error, index) => (
|
||||
<div key={index}>{error}</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-2">
|
||||
{question.options.map((option, optionIndex) => {
|
||||
const isInvalidOption = option.text?.trim() &&
|
||||
!stripUnderlineTags(question.prompt || '').toLowerCase()
|
||||
.includes(stripUnderlineTags(option.text).trim().toLowerCase());
|
||||
|
||||
return (
|
||||
<div key={option.id} className="flex gap-2">
|
||||
<label
|
||||
className={clsx(
|
||||
"flex-none w-12 p-3 text-center rounded-lg border-2 transition-all cursor-pointer",
|
||||
question.solution === option.id
|
||||
? 'border-blue-500 bg-blue-50 text-blue-700'
|
||||
: 'border-gray-200 hover:border-gray-300'
|
||||
)}
|
||||
>
|
||||
<input
|
||||
type="radio"
|
||||
name={`solution-${question.id}`}
|
||||
value={option.id}
|
||||
checked={question.solution === option.id}
|
||||
onChange={(e) => onQuestionChange({
|
||||
...question,
|
||||
solution: e.target.value
|
||||
})}
|
||||
className="sr-only"
|
||||
/>
|
||||
{option.id}
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={stripUnderlineTags(option.text || '')}
|
||||
onChange={(e) => handleOptionChange(optionIndex, e.target.value)}
|
||||
className={clsx(
|
||||
"flex-1 p-3 border rounded-lg focus:ring-2 focus:outline-none",
|
||||
isInvalidOption
|
||||
? "border-red-500 focus:ring-red-500 bg-red-50"
|
||||
: "focus:ring-blue-500"
|
||||
)}
|
||||
placeholder={`Option ${option.id}...`}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default UnderlineQuestion;
|
||||
@@ -0,0 +1,179 @@
|
||||
import Header from "@/components/ExamEditor/Shared/Header";
|
||||
import QuestionsList from "../../Shared/QuestionsList";
|
||||
import SortableQuestion from "../../Shared/SortableQuestion";
|
||||
import UnderlineQuestion from "./UnderlineQuestion";
|
||||
import useSectionEdit from "@/components/ExamEditor/Hooks/useSectionEdit";
|
||||
import { toast } from "react-toastify";
|
||||
import setEditingAlert from "../../Shared/setEditingAlert";
|
||||
import { Difficulty, LevelPart, ListeningPart, MultipleChoiceExercise, MultipleChoiceQuestion, ReadingPart } from "@/interfaces/exam";
|
||||
import useExamEditorStore from "@/stores/examEditor";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { MdAdd } from "react-icons/md";
|
||||
import Alert, { AlertItem } from "../../Shared/Alert";
|
||||
import PromptEdit from "../../Shared/PromptEdit";
|
||||
import { uuidv4 } from "@firebase/util";
|
||||
|
||||
|
||||
const UnderlineMultipleChoice: React.FC<{exercise: MultipleChoiceExercise, sectionId: number}> = ({
|
||||
exercise,
|
||||
sectionId,
|
||||
}) => {
|
||||
const { currentModule, dispatch } = useExamEditorStore();
|
||||
const difficulty = useExamEditorStore((state) => state.modules[currentModule].difficulty);
|
||||
const { state } = useExamEditorStore(
|
||||
(state) => state.modules[currentModule].sections.find((section) => section.sectionId === sectionId)!
|
||||
);
|
||||
const section = state as ReadingPart | ListeningPart | LevelPart;
|
||||
|
||||
const [local, setLocal] = useState(exercise);
|
||||
const [alerts, setAlerts] = useState<AlertItem[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
setLocal(exercise);
|
||||
}, [exercise]);
|
||||
|
||||
const updateLocal = (exercise: MultipleChoiceExercise) => {
|
||||
setLocal(exercise);
|
||||
setEditingAlert(true, setAlerts);
|
||||
setEditing(true);
|
||||
};
|
||||
|
||||
const handleQuestionChange = (questionIndex: number, updatedQuestion: MultipleChoiceQuestion) => {
|
||||
const newQuestions = [...local.questions];
|
||||
newQuestions[questionIndex] = updatedQuestion;
|
||||
updateLocal({ ...local, questions: newQuestions });
|
||||
};
|
||||
|
||||
const addQuestion = () => {
|
||||
const newId = (parseInt(local.questions[local.questions.length - 1].id) + 1).toString();
|
||||
const options = Array.from({ length: 4 }, (_, i) => ({
|
||||
id: String.fromCharCode(65 + i),
|
||||
text: ''
|
||||
}));
|
||||
|
||||
updateLocal({
|
||||
...local,
|
||||
questions: [
|
||||
...local.questions,
|
||||
{
|
||||
prompt: "",
|
||||
solution: "",
|
||||
uuid: uuidv4(),
|
||||
id: newId,
|
||||
options,
|
||||
variant: "text"
|
||||
},
|
||||
]
|
||||
});
|
||||
};
|
||||
|
||||
const deleteQuestion = (index: number) => {
|
||||
if (local.questions.length === 1) {
|
||||
toast.error("There needs to be at least one question!");
|
||||
return;
|
||||
}
|
||||
|
||||
const newQuestions = local.questions.filter((_, i) => i !== index);
|
||||
updateLocal({ ...local, questions: newQuestions });
|
||||
};
|
||||
|
||||
const { editing, handleSave, handleDiscard, handleDelete, handlePractice, setEditing } = useSectionEdit({
|
||||
sectionId,
|
||||
onSave: () => {
|
||||
setEditing(false);
|
||||
setAlerts([]);
|
||||
const newSection = {
|
||||
...section,
|
||||
exercises: section.exercises.map((ex) =>
|
||||
ex.id === local.id ? local : ex
|
||||
)
|
||||
};
|
||||
dispatch({ type: "UPDATE_SECTION_STATE", payload: { sectionId, update: newSection, module: currentModule } });
|
||||
},
|
||||
onDiscard: () => {
|
||||
setAlerts([]);
|
||||
setLocal(exercise);
|
||||
setEditing(false);
|
||||
},
|
||||
onDelete: () => {
|
||||
const newSection = {
|
||||
...section,
|
||||
exercises: section.exercises.filter((ex) => ex.id !== local.id)
|
||||
};
|
||||
dispatch({ type: "UPDATE_SECTION_STATE", payload: { sectionId, update: newSection, module: currentModule } });
|
||||
},
|
||||
onPractice: () => {
|
||||
const updatedExercise = {
|
||||
...local,
|
||||
isPractice: !local.isPractice
|
||||
};
|
||||
const newState = { ...section };
|
||||
newState.exercises = newState.exercises.map((ex) =>
|
||||
ex.id === exercise.id ? updatedExercise : ex
|
||||
);
|
||||
setLocal((prev) => ({...prev, isPractice: !local.isPractice}))
|
||||
dispatch({ type: 'UPDATE_SECTION_STATE', payload: { sectionId, update: newState, module: currentModule } });
|
||||
}
|
||||
});
|
||||
|
||||
const saveDifficulty = useCallback((diff: Difficulty) => {
|
||||
if (!difficulty.includes(diff)) {
|
||||
dispatch({ type: 'UPDATE_MODULE', payload: { updates: { difficulty: [...difficulty, diff]} } });
|
||||
}
|
||||
const updatedExercise = { ...exercise, difficulty: diff };
|
||||
const newState = { ...section };
|
||||
newState.exercises = newState.exercises.map((ex) => ex.id === exercise.id ? updatedExercise : ex );
|
||||
dispatch({ type: 'UPDATE_SECTION_STATE', payload: { sectionId, update: newState, module: currentModule } });
|
||||
}, [currentModule, difficulty, dispatch, exercise, section, sectionId]);
|
||||
|
||||
return (
|
||||
<div className="p-4">
|
||||
<Header
|
||||
title='Underline Multiple Choice Exercise'
|
||||
description="Edit questions with 4 underline options each"
|
||||
editing={editing}
|
||||
difficulty={exercise.difficulty}
|
||||
saveDifficulty={saveDifficulty}
|
||||
handleSave={handleSave}
|
||||
handleDelete={handleDelete}
|
||||
handlePractice={handlePractice}
|
||||
handleDiscard={handleDiscard}
|
||||
isEvaluationEnabled={!local.isPractice}
|
||||
/>
|
||||
{alerts.length > 0 && <Alert className="mb-6" alerts={alerts} />}
|
||||
<PromptEdit value={local.prompt} onChange={(prompt: string) => updateLocal({...local, prompt})} />
|
||||
<div className="space-y-4">
|
||||
<QuestionsList
|
||||
ids={local.questions.map(q => q.id)}
|
||||
handleDragEnd={()=> {}}
|
||||
>
|
||||
{local.questions.map((question, questionIndex) => (
|
||||
<SortableQuestion
|
||||
key={question.id}
|
||||
id={question.id}
|
||||
index={questionIndex}
|
||||
deleteQuestion={() => deleteQuestion(questionIndex)}
|
||||
>
|
||||
<UnderlineQuestion
|
||||
question={question}
|
||||
onQuestionChange={(updatedQuestion) =>
|
||||
handleQuestionChange(questionIndex, updatedQuestion)
|
||||
}
|
||||
/>
|
||||
</SortableQuestion>
|
||||
))}
|
||||
</QuestionsList>
|
||||
|
||||
<button
|
||||
onClick={addQuestion}
|
||||
className="w-full p-4 border-2 border-dashed border-gray-300 rounded-lg hover:border-blue-500 hover:bg-blue-50 transition-colors flex items-center justify-center gap-2 text-gray-600 hover:text-blue-600"
|
||||
>
|
||||
<MdAdd size={18} />
|
||||
Add New Question
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default UnderlineMultipleChoice;
|
||||
@@ -0,0 +1,302 @@
|
||||
import React, { useCallback, useEffect, useState } from 'react';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import {
|
||||
MdAdd,
|
||||
MdEdit,
|
||||
MdEditOff,
|
||||
} from 'react-icons/md';
|
||||
import { ReadingPart, MultipleChoiceExercise, MultipleChoiceQuestion, LevelPart, ListeningPart, Difficulty } from '@/interfaces/exam';
|
||||
import clsx from 'clsx';
|
||||
import useExamEditorStore from '@/stores/examEditor';
|
||||
import { toast } from 'react-toastify';
|
||||
import { DragEndEvent } from '@dnd-kit/core';
|
||||
import useSectionEdit from '@/components/ExamEditor/Hooks/useSectionEdit';
|
||||
import Header from '@/components/ExamEditor/Shared/Header';
|
||||
import Alert, { AlertItem } from '../../Shared/Alert';
|
||||
import QuestionsList from '../../Shared/QuestionsList';
|
||||
import SortableQuestion from '../../Shared/SortableQuestion';
|
||||
import setEditingAlert from '../../Shared/setEditingAlert';
|
||||
import { handleMultipleChoiceReorder } from '@/stores/examEditor/reorder/local';
|
||||
import PromptEdit from '../../Shared/PromptEdit';
|
||||
import { uuidv4 } from '@firebase/util';
|
||||
|
||||
interface MultipleChoiceProps {
|
||||
exercise: MultipleChoiceExercise;
|
||||
sectionId: number;
|
||||
optionsQuantity: number;
|
||||
}
|
||||
|
||||
const validateMultipleChoiceQuestions = (
|
||||
questions: MultipleChoiceQuestion[],
|
||||
optionsQuantity: number,
|
||||
setAlerts: React.Dispatch<React.SetStateAction<AlertItem[]>>
|
||||
) => {
|
||||
const validationAlerts: AlertItem[] = [];
|
||||
|
||||
questions.forEach((question, index) => {
|
||||
if (!question.prompt.trim()) {
|
||||
validationAlerts.push({
|
||||
variant: 'error',
|
||||
tag: `missing-prompt-${index}`,
|
||||
description: `Question ${index + 1} is missing a prompt`
|
||||
});
|
||||
}
|
||||
if (!question.solution) {
|
||||
validationAlerts.push({
|
||||
variant: 'error',
|
||||
tag: `missing-solution-${index}`,
|
||||
description: `Question ${index + 1} is missing a solution`
|
||||
});
|
||||
}
|
||||
if (question.options.length !== optionsQuantity) {
|
||||
validationAlerts.push({
|
||||
variant: 'error',
|
||||
tag: `invalid-options-${index}`,
|
||||
description: `Question ${index + 1} must have exactly ${optionsQuantity} options`
|
||||
});
|
||||
}
|
||||
question.options.forEach((option, optionIndex) => {
|
||||
if (option.text && option.text.trim() === "") {
|
||||
validationAlerts.push({
|
||||
variant: 'error',
|
||||
tag: `empty-option-${index}-${optionIndex}`,
|
||||
description: `Question ${index + 1} has an empty option`
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
setAlerts(prev => {
|
||||
const editingAlert = prev.find(alert => alert.tag === 'editing');
|
||||
return [...validationAlerts, ...(editingAlert ? [editingAlert] : [])];
|
||||
});
|
||||
|
||||
return validationAlerts.length === 0;
|
||||
};
|
||||
|
||||
const MultipleChoice: React.FC<MultipleChoiceProps> = ({ exercise, sectionId, optionsQuantity }) => {
|
||||
const { currentModule, dispatch } = useExamEditorStore();
|
||||
const difficulty = useExamEditorStore((state) => state.modules[currentModule].difficulty);
|
||||
const { state } = useExamEditorStore(
|
||||
(state) => state.modules[currentModule].sections.find((section) => section.sectionId === sectionId)!
|
||||
);
|
||||
|
||||
const section = state as ReadingPart | ListeningPart| LevelPart;
|
||||
|
||||
const [local, setLocal] = useState(exercise);
|
||||
const [editingPrompt, setEditingPrompt] = useState(false);
|
||||
const [alerts, setAlerts] = useState<AlertItem[]>([]);
|
||||
|
||||
const updateLocal = (exercise: MultipleChoiceExercise) => {
|
||||
setLocal(exercise);
|
||||
setEditingAlert(true, setAlerts);
|
||||
setEditing(true);
|
||||
};
|
||||
|
||||
const updateQuestion = (index: number, field: string, value: string) => {
|
||||
const newQuestions = [...local.questions];
|
||||
newQuestions[index] = { ...newQuestions[index], [field]: value };
|
||||
updateLocal({ ...local, questions: newQuestions });
|
||||
};
|
||||
|
||||
const updateOption = (questionIndex: number, optionIndex: number, value: string) => {
|
||||
const newQuestions = [...local.questions];
|
||||
const newOptions = [...newQuestions[questionIndex].options];
|
||||
newOptions[optionIndex] = { ...newOptions[optionIndex], text: value };
|
||||
newQuestions[questionIndex] = { ...newQuestions[questionIndex], options: newOptions };
|
||||
updateLocal({ ...local, questions: newQuestions });
|
||||
};
|
||||
|
||||
const addQuestion = () => {
|
||||
const newId = (parseInt(local.questions[local.questions.length - 1].id) + 1).toString();
|
||||
const options = Array.from({ length: optionsQuantity }, (_, i) => ({
|
||||
id: String.fromCharCode(65 + i),
|
||||
text: ''
|
||||
}));
|
||||
|
||||
updateLocal({
|
||||
...local,
|
||||
questions: [
|
||||
...local.questions,
|
||||
{
|
||||
prompt: "",
|
||||
solution: "",
|
||||
uuid: uuidv4(),
|
||||
id: newId,
|
||||
options,
|
||||
variant: "text"
|
||||
},
|
||||
]
|
||||
});
|
||||
};
|
||||
|
||||
const deleteQuestion = (index: number) => {
|
||||
if (local.questions.length === 1) {
|
||||
toast.error("There needs to be at least one question!");
|
||||
return;
|
||||
}
|
||||
|
||||
const newQuestions = local.questions.filter((_, i) => i !== index);
|
||||
const minId = Math.min(...newQuestions.map(q => parseInt(q.id)));
|
||||
|
||||
const updatedQuestions = newQuestions.map((question, i) => ({
|
||||
...question,
|
||||
id: String(minId + i)
|
||||
}));
|
||||
|
||||
updateLocal({ ...local, questions: updatedQuestions });
|
||||
};
|
||||
|
||||
const { editing, handleSave, handleDiscard, handleDelete, handlePractice, setEditing } = useSectionEdit({
|
||||
sectionId,
|
||||
onSave: () => {
|
||||
const isValid = validateMultipleChoiceQuestions(
|
||||
local.questions,
|
||||
optionsQuantity,
|
||||
setAlerts
|
||||
);
|
||||
|
||||
if (!isValid) {
|
||||
toast.error("Please fix the errors before saving!");
|
||||
return;
|
||||
}
|
||||
|
||||
setEditing(false);
|
||||
setAlerts([]);
|
||||
const newSection = {
|
||||
...section,
|
||||
exercises: section.exercises.map((ex) => ex.id === local.id ? local : ex)
|
||||
};
|
||||
dispatch({ type: "UPDATE_SECTION_STATE", payload: { sectionId, update: newSection, module: currentModule } });
|
||||
},
|
||||
onDiscard: () => {
|
||||
setEditing(false);
|
||||
setAlerts([]);
|
||||
setLocal(exercise);
|
||||
},
|
||||
onDelete: () => {
|
||||
const newSection = {
|
||||
...section,
|
||||
exercises: section.exercises.filter((ex) => ex.id !== local.id)
|
||||
};
|
||||
dispatch({ type: "UPDATE_SECTION_STATE", payload: { sectionId, update: newSection, module: currentModule } });
|
||||
},
|
||||
onPractice: () => {
|
||||
const updatedExercise = {
|
||||
...local,
|
||||
isPractice: !local.isPractice
|
||||
};
|
||||
const newState = { ...section };
|
||||
newState.exercises = newState.exercises.map((ex) =>
|
||||
ex.id === exercise.id ? updatedExercise : ex
|
||||
);
|
||||
setLocal((prev) => ({...prev, isPractice: !local.isPractice}))
|
||||
dispatch({ type: 'UPDATE_SECTION_STATE', payload: { sectionId, update: newState, module: currentModule } });
|
||||
}
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
validateMultipleChoiceQuestions(local.questions, optionsQuantity, setAlerts);
|
||||
}, [local.questions, optionsQuantity]);
|
||||
|
||||
const handleDragEnd = (event: DragEndEvent) => {
|
||||
setEditingAlert(true, setAlerts);
|
||||
setEditing(true);
|
||||
setLocal(handleMultipleChoiceReorder(event, local));
|
||||
};
|
||||
|
||||
const saveDifficulty = useCallback((diff: Difficulty) => {
|
||||
if (!difficulty.includes(diff)) {
|
||||
dispatch({ type: 'UPDATE_MODULE', payload: { updates: { difficulty: [...difficulty, diff]} } });
|
||||
}
|
||||
const updatedExercise = { ...exercise, difficulty: diff };
|
||||
const newState = { ...section };
|
||||
newState.exercises = newState.exercises.map((ex) => ex.id === exercise.id ? updatedExercise : ex );
|
||||
dispatch({ type: 'UPDATE_SECTION_STATE', payload: { sectionId, update: newState, module: currentModule } });
|
||||
}, [currentModule, difficulty, dispatch, exercise, section, sectionId]);
|
||||
|
||||
return (
|
||||
<div className="p-4">
|
||||
<Header
|
||||
title='Multiple Choice Exercise'
|
||||
description={`Edit questions with ${optionsQuantity} options each`}
|
||||
editing={editing}
|
||||
difficulty={exercise.difficulty}
|
||||
saveDifficulty={saveDifficulty}
|
||||
handleSave={handleSave}
|
||||
handleDelete={handleDelete}
|
||||
handleDiscard={handleDiscard}
|
||||
handlePractice={handlePractice}
|
||||
isEvaluationEnabled={!local.isPractice}
|
||||
/>
|
||||
{alerts.length > 0 && <Alert className="mb-6" alerts={alerts} />}
|
||||
<PromptEdit value={local.prompt} onChange={(prompt: string) => updateLocal({...local, prompt})} />
|
||||
<div className="space-y-4">
|
||||
<QuestionsList
|
||||
ids={local.questions.map(q => q.id)}
|
||||
handleDragEnd={handleDragEnd}
|
||||
>
|
||||
{local.questions.map((question, questionIndex) => (
|
||||
<SortableQuestion
|
||||
key={question.id}
|
||||
id={question.id}
|
||||
index={questionIndex}
|
||||
deleteQuestion={deleteQuestion}
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<input
|
||||
type="text"
|
||||
value={question.prompt}
|
||||
onChange={(e) => updateQuestion(questionIndex, 'prompt', e.target.value)}
|
||||
className="w-full p-3 border rounded-lg focus:ring-2 focus:ring-blue-500 focus:outline-none"
|
||||
placeholder="Enter question..."
|
||||
/>
|
||||
<div className="space-y-2">
|
||||
{question.options.map((option, optionIndex) => (
|
||||
<div key={option.id} className="flex gap-2">
|
||||
<label
|
||||
className={clsx(
|
||||
"flex-none w-12 p-3 text-center rounded-lg border-2 transition-all cursor-pointer",
|
||||
question.solution === option.id
|
||||
? 'border-blue-500 bg-blue-50 text-blue-700'
|
||||
: 'border-gray-200 hover:border-gray-300'
|
||||
)}
|
||||
>
|
||||
<input
|
||||
type="radio"
|
||||
name={`solution-${question.id}`}
|
||||
value={option.id}
|
||||
checked={question.solution === option.id}
|
||||
onChange={(e) => updateQuestion(questionIndex, 'solution', e.target.value)}
|
||||
className="sr-only"
|
||||
/>
|
||||
{option.id}
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={option.text}
|
||||
onChange={(e) => updateOption(questionIndex, optionIndex, e.target.value)}
|
||||
className="flex-1 p-3 border rounded-lg focus:ring-2 focus:ring-blue-500 focus:outline-none"
|
||||
placeholder={`Option ${option.id}...`}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</SortableQuestion>
|
||||
))}
|
||||
</QuestionsList>
|
||||
|
||||
<button
|
||||
onClick={addQuestion}
|
||||
className="w-full p-4 border-2 border-dashed border-gray-300 rounded-lg hover:border-blue-500 hover:bg-blue-50 transition-colors flex items-center justify-center gap-2 text-gray-600 hover:text-blue-600"
|
||||
>
|
||||
<MdAdd size={18} />
|
||||
Add New Question
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default MultipleChoice;
|
||||
17
src/components/ExamEditor/Exercises/MultipleChoice/index.tsx
Normal file
@@ -0,0 +1,17 @@
|
||||
import { MultipleChoiceExercise } from "@/interfaces/exam";
|
||||
import Vanilla from "./Vanilla";
|
||||
import MultipleChoiceUnderline from "./Underline";
|
||||
|
||||
const MultipleChoice: React.FC<{sectionId: number; exercise: MultipleChoiceExercise}> = (props) => {
|
||||
const {exercise} = props;
|
||||
|
||||
const length = exercise.questions[0].options.length;
|
||||
|
||||
if (exercise.questions[0].prompt.includes('<u>')) {
|
||||
return <MultipleChoiceUnderline {...props} />
|
||||
}
|
||||
|
||||
return (<Vanilla {...props} optionsQuantity={length}/>);
|
||||
}
|
||||
|
||||
export default MultipleChoice;
|
||||
86
src/components/ExamEditor/Exercises/Script/Message.tsx
Normal file
@@ -0,0 +1,86 @@
|
||||
import AutoExpandingTextArea from "@/components/Low/AutoExpandingTextarea";
|
||||
import { useState } from "react";
|
||||
import { FaEdit, FaFemale, FaMale } from "react-icons/fa";
|
||||
import { FaTrash } from "react-icons/fa6";
|
||||
import { ScriptLine } from ".";
|
||||
|
||||
interface MessageProps {
|
||||
message: ScriptLine & { position: 'left' | 'right' };
|
||||
color: string;
|
||||
editing: boolean;
|
||||
onEdit?: (text: string) => void;
|
||||
onDelete?: () => void;
|
||||
}
|
||||
|
||||
const Message: React.FC<MessageProps> = ({ message, color, editing, onEdit, onDelete }) => {
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
const [editText, setEditText] = useState(message.text);
|
||||
|
||||
return (
|
||||
<div className={`flex items-start gap-2 ${message.position === 'left' ? 'justify-start' : 'justify-end'}`}>
|
||||
<div className="flex flex-col w-[50%]">
|
||||
<div className={`flex items-center gap-2 ${message.position === 'right' && 'self-end'}`}>
|
||||
{message.gender === 'male' ? (
|
||||
<FaMale className="w-5 h-5 text-blue-500" />
|
||||
) : (
|
||||
<FaFemale className="w-5 h-5 text-pink-500" />
|
||||
)}
|
||||
<span className="text-sm font-medium">{message.name}</span>
|
||||
</div>
|
||||
<div className={`rounded-lg p-3 bg-${color}-100 relative group mt-1`}>
|
||||
{isEditing ? (
|
||||
<div className="flex flex-col gap-2">
|
||||
<AutoExpandingTextArea
|
||||
value={editText}
|
||||
onChange={setEditText}
|
||||
placeholder="Edit message..."
|
||||
className="w-full min-h-[96px] px-4 py-2 border border-gray-200 rounded-lg focus:ring-1 focus:ring-blue-500 focus:border-blue-500 text-base resize-none"
|
||||
/>
|
||||
<div className="flex justify-between">
|
||||
<button
|
||||
className="px-3 py-1 bg-blue-500 text-white rounded-md hover:bg-blue-600 transition-colors text-sm font-medium"
|
||||
onClick={() => {
|
||||
onEdit?.(editText);
|
||||
setIsEditing(false);
|
||||
}}
|
||||
>
|
||||
Save
|
||||
</button>
|
||||
<button
|
||||
className="px-3 py-1 bg-red-500 rounded-md hover:bg-gray-100 transition-colors text-sm font-medium text-white"
|
||||
onClick={() => setIsEditing(false)}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<p className="text-gray-700 whitespace-pre-wrap flex-grow">{message.text}</p>
|
||||
{editing && (
|
||||
<div className="flex-shrink-0 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<div className="flex gap-1">
|
||||
<button
|
||||
onClick={() => setIsEditing(true)}
|
||||
className="p-1 rounded hover:bg-gray-200 transition-colors"
|
||||
>
|
||||
<FaEdit className="w-3.5 h-3.5 text-gray-600" />
|
||||
</button>
|
||||
<button
|
||||
onClick={onDelete}
|
||||
className="p-1 rounded hover:bg-gray-200 transition-colors"
|
||||
>
|
||||
<FaTrash className="w-3.5 h-3.5 text-red-500" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Message;
|
||||
360
src/components/ExamEditor/Exercises/Script/index.tsx
Normal file
@@ -0,0 +1,360 @@
|
||||
import React, { useState, useMemo } from 'react';
|
||||
import { Script } from "@/interfaces/exam";
|
||||
import Message from './Message';
|
||||
import AutoExpandingTextArea from '@/components/Low/AutoExpandingTextarea';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import Input from '@/components/Low/Input';
|
||||
import { FaFemale, FaMale, FaPlus } from 'react-icons/fa';
|
||||
import clsx from 'clsx';
|
||||
import { toast } from 'react-toastify';
|
||||
|
||||
export interface Speaker {
|
||||
id: number;
|
||||
name: string;
|
||||
gender: 'male' | 'female';
|
||||
color: string;
|
||||
position: 'left' | 'right';
|
||||
}
|
||||
|
||||
type Gender = 'male' | 'female';
|
||||
|
||||
export interface ScriptLine {
|
||||
name: string;
|
||||
gender: Gender;
|
||||
text: string;
|
||||
voice?: string;
|
||||
}
|
||||
|
||||
interface MessageWithPosition extends ScriptLine {
|
||||
position: 'left' | 'right';
|
||||
}
|
||||
|
||||
interface Props {
|
||||
section: number;
|
||||
editing?: boolean;
|
||||
local?: Script;
|
||||
setLocal: (script: Script) => void;
|
||||
}
|
||||
|
||||
const colorOptions = [
|
||||
'red', 'blue', 'green', 'purple', 'pink', 'indigo', 'teal', 'orange',
|
||||
'cyan', 'emerald', 'sky', 'violet', 'fuchsia', 'rose', 'lime', 'slate'
|
||||
];
|
||||
|
||||
const ScriptEditor: React.FC<Props> = ({ section, editing = false, local, setLocal }) => {
|
||||
const isConversation = [1, 3].includes(section);
|
||||
const speakerCount = section === 1 ? 2 : 4;
|
||||
|
||||
if (local === undefined) {
|
||||
if (isConversation) {
|
||||
setLocal([]);
|
||||
} else {
|
||||
setLocal('');
|
||||
}
|
||||
}
|
||||
|
||||
const [selectedSpeaker, setSelectedSpeaker] = useState<string>('');
|
||||
const [newMessage, setNewMessage] = useState('');
|
||||
|
||||
const [speakers, setSpeakers] = useState<Speaker[]>(() => {
|
||||
if (local === undefined) {
|
||||
return Array.from({ length: speakerCount }, (_, index) => ({
|
||||
id: index,
|
||||
name: '',
|
||||
gender: 'male',
|
||||
color: colorOptions[index],
|
||||
position: index % 2 === 0 ? 'left' : 'right'
|
||||
}));
|
||||
}
|
||||
|
||||
const existingScript = local as ScriptLine[];
|
||||
const existingSpeakers = new Set<string>();
|
||||
const speakerGenders = new Map<string, 'male' | 'female'>();
|
||||
|
||||
if (Array.isArray(existingScript)) {
|
||||
existingScript.forEach(line => {
|
||||
existingSpeakers.add(line.name);
|
||||
speakerGenders.set(line.name, line.gender.toLowerCase() === 'female' ? 'female' : 'male' as 'male' | 'female');
|
||||
});
|
||||
}
|
||||
|
||||
const speakerArray = Array.from(existingSpeakers);
|
||||
const totalNeeded = Math.max(speakerCount, speakerArray.length);
|
||||
|
||||
return Array.from({ length: totalNeeded }, (_, index) => {
|
||||
if (index < speakerArray.length) {
|
||||
return {
|
||||
id: index,
|
||||
name: speakerArray[index],
|
||||
gender: speakerGenders.get(speakerArray[index]) || 'male',
|
||||
color: colorOptions[index],
|
||||
position: index % 2 === 0 ? 'left' : 'right'
|
||||
};
|
||||
}
|
||||
return {
|
||||
id: index,
|
||||
name: '',
|
||||
gender: 'male',
|
||||
color: colorOptions[index],
|
||||
position: index % 2 === 0 ? 'left' : 'right'
|
||||
};
|
||||
});
|
||||
});
|
||||
|
||||
const speakerProperties = useMemo(() => {
|
||||
return speakers.reduce((acc, speaker) => {
|
||||
if (speaker.name) {
|
||||
acc[speaker.name] = {
|
||||
color: speaker.color,
|
||||
gender: speaker.gender
|
||||
};
|
||||
}
|
||||
return acc;
|
||||
}, {} as Record<string, { color: string; gender: 'male' | 'female' }>);
|
||||
}, [speakers]);
|
||||
|
||||
const allSpeakersConfigured = useMemo(() => {
|
||||
return speakers.every(speaker => speaker.name.trim() !== '');
|
||||
}, [speakers]);
|
||||
|
||||
const updateSpeaker = (index: number, updates: Partial<Speaker>) => {
|
||||
const updatedSpeakers = speakers.map((speaker, i) => {
|
||||
if (i === index) {
|
||||
return { ...speaker, ...updates };
|
||||
}
|
||||
return speaker;
|
||||
});
|
||||
setSpeakers(updatedSpeakers);
|
||||
|
||||
if (Array.isArray(local)) {
|
||||
if ('name' in updates && speakers[index].name) {
|
||||
const oldName = speakers[index].name;
|
||||
const newName = updates.name || '';
|
||||
const updatedScript = local.map(line => {
|
||||
if (line.name === oldName) {
|
||||
return { ...line, name: newName };
|
||||
}
|
||||
return line;
|
||||
});
|
||||
setLocal(updatedScript);
|
||||
}
|
||||
|
||||
if ('gender' in updates && speakers[index].name && updates.gender) {
|
||||
const name = speakers[index].name;
|
||||
const newGender = updates.gender;
|
||||
const updatedScript = local.map(line => {
|
||||
if (line.name === name) {
|
||||
return { ...line, gender: newGender };
|
||||
}
|
||||
return line;
|
||||
});
|
||||
setLocal(updatedScript);
|
||||
}
|
||||
}
|
||||
|
||||
if ('name' in updates && speakers[index].name === selectedSpeaker) {
|
||||
setSelectedSpeaker(updates.name || '');
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
const addMessage = () => {
|
||||
if (!isConversation || !selectedSpeaker || !newMessage.trim()) return;
|
||||
if (!Array.isArray(local)) return;
|
||||
|
||||
const speaker = speakers.find(s => s.name === selectedSpeaker);
|
||||
if (!speaker) return;
|
||||
|
||||
const newLine: ScriptLine = {
|
||||
name: selectedSpeaker,
|
||||
gender: speaker.gender,
|
||||
text: newMessage.trim()
|
||||
};
|
||||
|
||||
const updatedScript = [...local, newLine];
|
||||
setLocal(updatedScript);
|
||||
setNewMessage('');
|
||||
};
|
||||
|
||||
const updateMessage = (index: number, newText: string) => {
|
||||
if (!Array.isArray(local)) return;
|
||||
|
||||
const updatedScript = [...local];
|
||||
updatedScript[index] = { ...updatedScript[index], text: newText };
|
||||
setLocal(updatedScript);
|
||||
};
|
||||
|
||||
const deleteMessage = (index: number) => {
|
||||
if (!Array.isArray(local)) return;
|
||||
|
||||
const updatedScript = local.filter((_, i) => i !== index);
|
||||
setLocal(updatedScript);
|
||||
};
|
||||
|
||||
const updateMonologue = (text: string) => {
|
||||
setLocal(text);
|
||||
};
|
||||
|
||||
const messages = useMemo(() => {
|
||||
if (typeof local === 'string' || !Array.isArray(local)) return [];
|
||||
|
||||
return local.reduce<MessageWithPosition[]>((acc, line, index) => {
|
||||
const normalizedLine = {
|
||||
...line,
|
||||
gender: line.gender.toLowerCase() === 'female' ? 'female' : 'male'
|
||||
} as ScriptLine;
|
||||
|
||||
if (index === 0) {
|
||||
acc.push({ ...normalizedLine, position: 'left' });
|
||||
} else {
|
||||
const prevMsg = acc[index - 1];
|
||||
const position = line.name === prevMsg.name
|
||||
? prevMsg.position
|
||||
: (prevMsg.position === 'left' ? 'right' : 'left');
|
||||
acc.push({ ...normalizedLine, position });
|
||||
}
|
||||
return acc;
|
||||
}, []);
|
||||
}, [local]);
|
||||
|
||||
if (!isConversation) {
|
||||
if (typeof local !== 'string') {
|
||||
toast.error(`Section ${section} is monologue based, but the import contained a conversation!`);
|
||||
setLocal('');
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="py-10">
|
||||
<div className="w-full">
|
||||
{editing ? (
|
||||
<AutoExpandingTextArea
|
||||
value={local as string}
|
||||
onChange={updateMonologue}
|
||||
placeholder='Write the monologue here...'
|
||||
/>
|
||||
) : (
|
||||
<div className="p-8 shadow-inner border border-gray-200 bg-gray-50 rounded-xl">
|
||||
<span>{(local as string) || "Edit, generate or import your own audio."}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
if (typeof local === 'string') {
|
||||
toast.error(`Section ${section} is conversation based, but the import contained a monologue!`);
|
||||
setLocal([]);
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="py-10">
|
||||
<div className="space-y-6">
|
||||
{editing && (
|
||||
<div className="bg-white rounded-2xl p-6 shadow-inner border mb-8">
|
||||
<h3 className="text-lg font-medium text-gray-700 mb-6">Edit Conversation</h3>
|
||||
<div className="space-y-4 mb-6">
|
||||
{speakers.map((speaker, index) => (
|
||||
<div key={index} className="flex items-center gap-4">
|
||||
<div className="flex-1">
|
||||
<Input
|
||||
type="text"
|
||||
name=""
|
||||
value={speaker.name}
|
||||
onChange={(text) => updateSpeaker(index, { name: text })}
|
||||
placeholder="Speaker name"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="w-[140px] relative">
|
||||
<select
|
||||
value={speaker.gender}
|
||||
onChange={(e) => updateSpeaker(index, { gender: e.target.value as 'male' | 'female' })}
|
||||
className="w-full appearance-none px-4 py-2 border border-gray-200 rounded-full text-base bg-white focus:ring-1 focus:ring-blue-500 focus:outline-none"
|
||||
>
|
||||
<option value="female">Female</option>
|
||||
<option value="male">Male</option>
|
||||
</select>
|
||||
<div className="absolute right-3 top-2.5 pointer-events-none">
|
||||
{speaker.gender === 'male' ? (
|
||||
<FaMale className="w-5 h-5 text-blue-500" />
|
||||
) : (
|
||||
<FaFemale className="w-5 h-5 text-pink-500" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex gap-4">
|
||||
<div className="w-[240px] flex flex-col gap-2">
|
||||
<select
|
||||
value={selectedSpeaker}
|
||||
onChange={(e) => setSelectedSpeaker(e.target.value)}
|
||||
disabled={!allSpeakersConfigured}
|
||||
className="w-full h-[42px] px-4 appearance-none border border-gray-200 rounded-full focus:ring-1 focus:ring-blue-500 focus:outline-none bg-white text-gray-700 text-base disabled:bg-gray-100"
|
||||
>
|
||||
<option value="">Select Speaker ...</option>
|
||||
{speakers.filter(s => s.name).map((speaker) => (
|
||||
<option key={speaker.id} value={speaker.name}>
|
||||
{speaker.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<button
|
||||
onClick={addMessage}
|
||||
disabled={!selectedSpeaker || !newMessage.trim() || !allSpeakersConfigured}
|
||||
className={clsx(
|
||||
"w-full h-[42px] rounded-lg flex items-center justify-center gap-2 transition-colors font-medium",
|
||||
!selectedSpeaker || !newMessage.trim() || !allSpeakersConfigured
|
||||
? 'bg-gray-100 text-gray-500 cursor-not-allowed'
|
||||
: 'bg-blue-500 text-white hover:bg-blue-600'
|
||||
)}
|
||||
>
|
||||
<FaPlus className="w-4 h-4" />
|
||||
Add
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex-1">
|
||||
<AutoExpandingTextArea
|
||||
value={newMessage}
|
||||
onChange={setNewMessage}
|
||||
placeholder={allSpeakersConfigured ? "Type your message..." : "Configure all speakers first"}
|
||||
disabled={!allSpeakersConfigured}
|
||||
className="w-full min-h-[96px] px-4 py-2 border border-gray-200 rounded-lg focus:ring-1 focus:ring-blue-500 focus:border-blue-500 text-base resize-none disabled:bg-gray-100"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-4">
|
||||
{messages.map((message, index) => {
|
||||
const properties = speakerProperties[message.name];
|
||||
if (!properties) return null;
|
||||
|
||||
return (
|
||||
<Message
|
||||
key={index}
|
||||
message={message}
|
||||
color={properties.color}
|
||||
editing={editing}
|
||||
onEdit={(text: string) => updateMessage(index, text)}
|
||||
onDelete={() => deleteMessage(index)}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default ScriptEditor;
|
||||
61
src/components/ExamEditor/Exercises/Shared/Alert.tsx
Normal file
@@ -0,0 +1,61 @@
|
||||
import clsx from "clsx";
|
||||
import { BiErrorCircle } from "react-icons/bi";
|
||||
import { IoInformationCircle } from "react-icons/io5";
|
||||
|
||||
export interface AlertItem {
|
||||
variant: "info" | "error";
|
||||
description: string;
|
||||
tag?: string;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
alerts: AlertItem[];
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const Alert: React.FC<Props> = ({ alerts, className }) => {
|
||||
const hasError = alerts.some(alert => alert.variant === "error");
|
||||
const alertsToShow = hasError ? alerts.filter(alert => alert.variant === "error") : alerts;
|
||||
|
||||
if (alertsToShow.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className={clsx("space-y-2", className)}>
|
||||
{alertsToShow.map((alert, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className={clsx(
|
||||
"border rounded-xl flex items-center gap-2 py-2 px-4",
|
||||
{
|
||||
'bg-amber-50': alert.variant === 'info',
|
||||
'bg-red-50': alert.variant === 'error'
|
||||
}
|
||||
)}
|
||||
>
|
||||
{alert.variant === 'info' ? (
|
||||
<IoInformationCircle
|
||||
className="h-5 w-5 text-amber-700"
|
||||
/>
|
||||
) : (
|
||||
<BiErrorCircle
|
||||
className="h-5 w-5 text-red-700"
|
||||
/>
|
||||
)}
|
||||
<p
|
||||
className={clsx(
|
||||
"font-medium py-0.5",
|
||||
{
|
||||
'text-amber-700': alert.variant === 'info',
|
||||
'text-red-700': alert.variant === 'error'
|
||||
}
|
||||
)}
|
||||
>
|
||||
{alert.description}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Alert;
|
||||
14
src/components/ExamEditor/Exercises/Shared/GenLoader.tsx
Normal file
@@ -0,0 +1,14 @@
|
||||
import clsx from "clsx";
|
||||
|
||||
const GenLoader: React.FC<{module: string, custom?: string, className?: string}> = ({module, custom, className}) => {
|
||||
return (
|
||||
<div className={clsx("w-full cursor-text px-7 py-8 border-2 border-mti-gray-platinum rounded-3xl", className)}>
|
||||
<div className="flex flex-col items-center justify-center animate-pulse">
|
||||
<span className={`loading loading-infinity w-32 bg-ielts-${module}`} />
|
||||
<span className={`font-bold text-2xl text-ielts-${module}`}>{`${custom ? custom : "Generating..."}`}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default GenLoader;
|
||||
70
src/components/ExamEditor/Exercises/Shared/PromptEdit.tsx
Normal file
@@ -0,0 +1,70 @@
|
||||
import AutoExpandingTextArea from "@/components/Low/AutoExpandingTextarea";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { useState } from "react";
|
||||
import { MdEdit, MdEditOff } from "react-icons/md";
|
||||
|
||||
interface Props {
|
||||
value: string;
|
||||
onChange: (text: string) => void;
|
||||
wrapperCard?: boolean;
|
||||
}
|
||||
|
||||
const PromptEdit: React.FC<Props> = ({ value, onChange, wrapperCard = true }) => {
|
||||
const [editing, setEditing] = useState(false);
|
||||
|
||||
const renderTextWithLineBreaks = (text: string) => {
|
||||
const unescapedText = text.replace(/\\n/g, '\n');
|
||||
return unescapedText.split('\n').map((line, index, array) => (
|
||||
<span key={index}>
|
||||
{line}
|
||||
{index < array.length - 1 && <br />}
|
||||
</span>
|
||||
));
|
||||
};
|
||||
|
||||
const promptEditTsx = (
|
||||
<div className="flex justify-between items-start gap-4">
|
||||
{editing ? (
|
||||
<AutoExpandingTextArea
|
||||
className="flex-1 p-3 border rounded-lg focus:ring-2 focus:ring-blue-500 focus:outline-none min-h-[100px]"
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
onBlur={() => setEditing(false)}
|
||||
/>
|
||||
) : (
|
||||
<div className="flex-1">
|
||||
<h3 className="font-medium text-gray-800 mb-2">
|
||||
Question/Instructions displayed to the student:
|
||||
</h3>
|
||||
<p className="text-gray-600">
|
||||
{renderTextWithLineBreaks(value)}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
<button
|
||||
onClick={() => setEditing(!editing)}
|
||||
className="p-2 hover:bg-gray-100 rounded-lg transition-colors"
|
||||
>
|
||||
{editing ? (
|
||||
<MdEditOff size={20} className="text-gray-500" />
|
||||
) : (
|
||||
<MdEdit size={20} className="text-gray-500" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
|
||||
if (!wrapperCard) {
|
||||
return promptEditTsx;
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className="mb-6">
|
||||
<CardContent className="p-4">
|
||||
{promptEditTsx}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default PromptEdit;
|
||||
34
src/components/ExamEditor/Exercises/Shared/QuestionsList.tsx
Normal file
@@ -0,0 +1,34 @@
|
||||
import { closestCenter, DndContext, PointerSensor, useSensor, useSensors } from "@dnd-kit/core";
|
||||
import { SortableContext, verticalListSortingStrategy } from "@dnd-kit/sortable";
|
||||
import { ReactNode } from "react";
|
||||
|
||||
interface Props {
|
||||
ids: string[];
|
||||
handleDragEnd: (event: any) => void;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
const QuestionsList: React.FC<Props> = ({ ids, handleDragEnd, children }) => {
|
||||
const sensors = useSensors(
|
||||
useSensor(PointerSensor),
|
||||
);
|
||||
|
||||
return (
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
collisionDetection={closestCenter}
|
||||
onDragEnd={handleDragEnd}
|
||||
>
|
||||
<SortableContext
|
||||
items={ids}
|
||||
strategy={verticalListSortingStrategy}
|
||||
>
|
||||
<div className="space-y-4">
|
||||
{children}
|
||||
</div>
|
||||
</SortableContext>
|
||||
</DndContext>
|
||||
);
|
||||
};
|
||||
|
||||
export default QuestionsList;
|
||||
155
src/components/ExamEditor/Exercises/Shared/SortableQuestion.tsx
Normal file
@@ -0,0 +1,155 @@
|
||||
import React, { ReactNode, useState } from 'react';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { MdDragIndicator, MdDelete, MdEdit, MdEditOff } from 'react-icons/md';
|
||||
import { useSortable } from '@dnd-kit/sortable';
|
||||
import { CSS } from '@dnd-kit/utilities';
|
||||
import clsx from 'clsx';
|
||||
|
||||
interface Props {
|
||||
id: string;
|
||||
index: number;
|
||||
deleteQuestion: (index: any) => void;
|
||||
onFocus?: () => void;
|
||||
extra?: ReactNode;
|
||||
children: ReactNode;
|
||||
variant?: 'default' | 'writeBlanks' | 'del-up';
|
||||
title?: string;
|
||||
onQuestionChange?: (value: string) => void;
|
||||
questionText?: string;
|
||||
}
|
||||
|
||||
const SortableQuestion: React.FC<Props> = ({
|
||||
id,
|
||||
index,
|
||||
deleteQuestion,
|
||||
children,
|
||||
extra,
|
||||
onFocus,
|
||||
variant = 'default',
|
||||
questionText = "",
|
||||
onQuestionChange
|
||||
}) => {
|
||||
const [isEditingQuestion, setIsEditingQuestion] = useState(false);
|
||||
|
||||
const {
|
||||
attributes,
|
||||
listeners,
|
||||
setNodeRef,
|
||||
transform,
|
||||
transition,
|
||||
isDragging,
|
||||
} = useSortable({ id });
|
||||
|
||||
const style = {
|
||||
transform: CSS.Transform.toString(transform),
|
||||
transition,
|
||||
opacity: isDragging ? 0.5 : 1,
|
||||
};
|
||||
|
||||
if (variant === 'writeBlanks') {
|
||||
return (
|
||||
<Card ref={setNodeRef} style={style} onFocus={onFocus}>
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-stretch gap-4">
|
||||
<div className='flex flex-col flex-none w-12'>
|
||||
<div className="flex-none">
|
||||
<span className="text-sm font-medium text-gray-500">ID: {id}</span>
|
||||
</div>
|
||||
<div
|
||||
className='flex-1 flex items-center justify-center group'
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
>
|
||||
<div className="p-2 rounded-lg group-hover:bg-gray-100 cursor-grab active:cursor-grabbing transition-colors">
|
||||
<MdDragIndicator size={24} className="text-gray-400" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
{isEditingQuestion ? (
|
||||
<input
|
||||
type="text"
|
||||
value={questionText}
|
||||
onChange={(e) => onQuestionChange?.(e.target.value)}
|
||||
className="flex-1 p-2 border rounded-lg focus:ring-2 focus:ring-blue-500 focus:outline-none"
|
||||
autoFocus
|
||||
onBlur={() => setIsEditingQuestion(false)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
setIsEditingQuestion(false);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<span className="flex-1 font-bold text-gray-800">{questionText}</span>
|
||||
)}
|
||||
<div className="flex items-center gap-2 flex-none">
|
||||
<button
|
||||
onClick={() => setIsEditingQuestion(!isEditingQuestion)}
|
||||
className="p-2 hover:bg-gray-100 rounded-lg transition-colors"
|
||||
>
|
||||
{isEditingQuestion ?
|
||||
<MdEditOff size={20} className="text-gray-500" /> :
|
||||
<MdEdit size={20} className="text-gray-500" />
|
||||
}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => deleteQuestion(index)}
|
||||
className="p-2 text-red-500 hover:bg-red-50 rounded-lg transition-colors"
|
||||
title="Delete question"
|
||||
>
|
||||
<MdDelete size={20} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-4 space-y-3">
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{extra && <div className="mt-4">{extra}</div>}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Card ref={setNodeRef} style={style} onFocus={onFocus}>
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-stretch gap-4">
|
||||
<div className='flex flex-col flex-none w-12'>
|
||||
<div className="flex-none">
|
||||
<span className="text-sm font-medium text-gray-500">ID: {id}</span>
|
||||
</div>
|
||||
<div className='flex-1 flex items-center justify-center group'>
|
||||
<div
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
className="p-2 rounded-lg group-hover:bg-gray-100 cursor-grab active:cursor-grabbing transition-colors"
|
||||
>
|
||||
<MdDragIndicator size={24} className="text-gray-400" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-1 space-y-3">
|
||||
{children}
|
||||
</div>
|
||||
<div className={clsx('flex flex-col gap-4', variant !== "del-up" ? "justify-center": "mt-1.5")}>
|
||||
<button
|
||||
onClick={() => deleteQuestion(index)}
|
||||
className="p-2 text-red-500 hover:bg-red-50 rounded-lg transition-colors"
|
||||
title="Delete question"
|
||||
>
|
||||
<MdDelete size={variant !== "del-up" ? 20 : 24} />
|
||||
</button>
|
||||
{extra}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default SortableQuestion;
|
||||
@@ -0,0 +1,21 @@
|
||||
import { AlertItem } from "./Alert";
|
||||
|
||||
|
||||
const setEditingAlert = (editing: boolean, setAlerts: React.Dispatch<React.SetStateAction<AlertItem[]>>) => {
|
||||
if (editing) {
|
||||
setAlerts(prev => {
|
||||
if (!prev.some(alert => alert.variant === "info")) {
|
||||
return [...prev, {
|
||||
variant: "info",
|
||||
description: "You have unsaved changes. Don't forget to save your work!",
|
||||
tag: "editing"
|
||||
}];
|
||||
}
|
||||
return prev;
|
||||
});
|
||||
} else {
|
||||
setAlerts([]);
|
||||
}
|
||||
}
|
||||
|
||||
export default setEditingAlert;
|
||||
@@ -0,0 +1,480 @@
|
||||
import AutoExpandingTextArea from "@/components/Low/AutoExpandingTextarea";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { BiQuestionMark } from 'react-icons/bi';
|
||||
import { AiOutlineUnorderedList, AiOutlinePlus, AiOutlineDelete } from 'react-icons/ai';
|
||||
import { Tooltip } from "react-tooltip";
|
||||
import Header from "../../Shared/Header";
|
||||
import GenLoader from "../Shared/GenLoader";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import useSectionEdit from "../../Hooks/useSectionEdit";
|
||||
import useExamEditorStore from "@/stores/examEditor";
|
||||
import { Difficulty, InteractiveSpeakingExercise, LevelPart } from "@/interfaces/exam";
|
||||
import { BsFileText } from "react-icons/bs";
|
||||
import { FaChevronLeft, FaChevronRight } from "react-icons/fa6";
|
||||
import { RiVideoLine } from "react-icons/ri";
|
||||
import { Module } from "@/interfaces";
|
||||
|
||||
interface Props {
|
||||
sectionId: number;
|
||||
exercise: InteractiveSpeakingExercise;
|
||||
module?: Module;
|
||||
}
|
||||
|
||||
const InteractiveSpeaking: React.FC<Props> = ({ sectionId, exercise, module = "speaking" }) => {
|
||||
const { currentModule, dispatch } = useExamEditorStore();
|
||||
const difficulty = useExamEditorStore((state) => state.modules[currentModule].difficulty);
|
||||
const [local, setLocal] = useState(exercise);
|
||||
const [currentVideoIndex, setCurrentVideoIndex] = useState(0);
|
||||
|
||||
const { generating, genResult, state, levelGenResults, levelGenerating } = useExamEditorStore(
|
||||
(state) => state.modules[module].sections.find((section) => section.sectionId === sectionId)!
|
||||
);
|
||||
|
||||
const { editing, setEditing, handleSave, handleDiscard, handleEdit, handlePractice } = useSectionEdit({
|
||||
sectionId,
|
||||
onSave: () => {
|
||||
setEditing(false);
|
||||
|
||||
if (module === "level") {
|
||||
const updatedState = {
|
||||
...state,
|
||||
exercises: (state as LevelPart).exercises.map((ex) =>
|
||||
ex.id === local.id ? local : ex
|
||||
)
|
||||
};
|
||||
dispatch({
|
||||
type: "UPDATE_SECTION_STATE",
|
||||
payload: { sectionId, update: updatedState, module }
|
||||
});
|
||||
} else {
|
||||
dispatch({
|
||||
type: "UPDATE_SECTION_STATE",
|
||||
payload: { sectionId, update: local, module }
|
||||
});
|
||||
}
|
||||
|
||||
if (genResult) {
|
||||
dispatch({
|
||||
type: "UPDATE_SECTION_SINGLE_FIELD",
|
||||
payload: {
|
||||
sectionId,
|
||||
module,
|
||||
field: "genResult",
|
||||
value: undefined
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const speakingScript = levelGenResults?.find((res) => res.generating === `${local.id}-speakingScript`);
|
||||
if (module === "level" && speakingScript) {
|
||||
dispatch({
|
||||
type: "UPDATE_SECTION_SINGLE_FIELD", payload: {
|
||||
sectionId,
|
||||
field: "levelGenResults",
|
||||
value: levelGenResults.filter((res) => res.generating !== `${local.id}-speakingScript`),
|
||||
module
|
||||
}
|
||||
});
|
||||
}
|
||||
},
|
||||
onDiscard: () => {
|
||||
setLocal(exercise);
|
||||
},
|
||||
onPractice: () => {
|
||||
const updatedLocal = { ...local, isPractice: !local.isPractice };
|
||||
setLocal(updatedLocal);
|
||||
|
||||
if (module === "level") {
|
||||
const updatedState = {
|
||||
...state,
|
||||
exercises: (state as LevelPart).exercises.map((ex) =>
|
||||
ex.id === local.id ? updatedLocal : ex
|
||||
)
|
||||
};
|
||||
dispatch({
|
||||
type: "UPDATE_SECTION_STATE",
|
||||
payload: { sectionId, update: updatedState, module }
|
||||
});
|
||||
} else {
|
||||
dispatch({
|
||||
type: "UPDATE_SECTION_STATE",
|
||||
payload: { sectionId, update: updatedLocal, module }
|
||||
});
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (genResult && generating === "speakingScript") {
|
||||
if (!difficulty.includes(genResult.result[0].difficulty)) {
|
||||
dispatch({ type: 'UPDATE_MODULE', payload: { updates: { difficulty: [...difficulty, genResult.result[0].difficulty]} } });
|
||||
}
|
||||
const updatedLocal = {
|
||||
...local,
|
||||
title: genResult.result[0].title,
|
||||
prompts: genResult.result[0].prompts.map((item: any) => ({
|
||||
text: item || "",
|
||||
video_url: ""
|
||||
})),
|
||||
difficulty: genResult.result[0].difficulty
|
||||
};
|
||||
setEditing(true);
|
||||
setLocal(updatedLocal);
|
||||
|
||||
dispatch({
|
||||
type: "UPDATE_SECTION_SINGLE_FIELD",
|
||||
payload: {
|
||||
sectionId,
|
||||
module,
|
||||
field: "generating",
|
||||
value: undefined
|
||||
}
|
||||
});
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [genResult, generating]);
|
||||
|
||||
useEffect(() => {
|
||||
if (genResult && generating === "video") {
|
||||
const updatedLocal = { ...local, prompts: genResult.result[0].prompts };
|
||||
setLocal(updatedLocal);
|
||||
|
||||
dispatch({
|
||||
type: "UPDATE_SECTION_STATE",
|
||||
payload: { sectionId, update: updatedLocal, module }
|
||||
});
|
||||
|
||||
|
||||
dispatch({
|
||||
type: "UPDATE_SECTION_SINGLE_FIELD",
|
||||
payload: {
|
||||
sectionId,
|
||||
module,
|
||||
field: "generating",
|
||||
value: undefined
|
||||
}
|
||||
});
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [genResult, generating]);
|
||||
|
||||
useEffect(() => {
|
||||
const speakingScript = levelGenResults?.find((res) => res.generating === `${local.id}-speakingScript`);
|
||||
const isGenerating = levelGenerating?.includes(`${local.id}-speakingScript`);
|
||||
|
||||
if (speakingScript && isGenerating) {
|
||||
if (!difficulty.includes(speakingScript.result[0].difficulty)) {
|
||||
dispatch({ type: 'UPDATE_MODULE', payload: { updates: { difficulty: [...difficulty, speakingScript.result[0].difficulty]} } });
|
||||
}
|
||||
const updatedLocal = {
|
||||
...local,
|
||||
title: speakingScript.result[0].title,
|
||||
prompts: speakingScript.result[0].prompts.map((item: any) => ({
|
||||
text: item || "",
|
||||
video_url: ""
|
||||
})),
|
||||
difficulty: speakingScript.result[0].difficulty
|
||||
};
|
||||
setEditing(true);
|
||||
setLocal(updatedLocal);
|
||||
|
||||
dispatch({
|
||||
type: "UPDATE_SECTION_SINGLE_FIELD", payload: {
|
||||
sectionId,
|
||||
field: "levelGenerating",
|
||||
value: levelGenerating.filter((g) => g !== `${local.id}-speakingScript`),
|
||||
module
|
||||
}
|
||||
});
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [levelGenResults, levelGenerating]);
|
||||
|
||||
useEffect(() => {
|
||||
const speakingVideo = levelGenResults?.find((res) => res.generating === `${local.id}-video`);
|
||||
const isGenerating = levelGenerating?.includes(`${local.id}-video`);
|
||||
|
||||
if (speakingVideo && isGenerating) {
|
||||
const updatedLocal = { ...local, prompts: speakingVideo.result[0].prompts };
|
||||
setLocal(updatedLocal);
|
||||
|
||||
const updatedState = {
|
||||
...state,
|
||||
exercises: (state as LevelPart).exercises.map((ex) =>
|
||||
ex.id === local.id ? updatedLocal : ex
|
||||
)
|
||||
};
|
||||
|
||||
dispatch({
|
||||
type: "UPDATE_SECTION_STATE",
|
||||
payload: { sectionId, update: updatedState, module }
|
||||
});
|
||||
|
||||
dispatch({
|
||||
type: "UPDATE_SECTION_SINGLE_FIELD", payload: {
|
||||
sectionId,
|
||||
field: "levelGenerating",
|
||||
value: levelGenerating.filter((g) => g !== `${local.id}-video`),
|
||||
module
|
||||
}
|
||||
});
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [levelGenResults, levelGenerating]);
|
||||
|
||||
const addPrompt = () => {
|
||||
setLocal(prev => ({
|
||||
...prev,
|
||||
prompts: [...prev.prompts, { text: "", video_url: "" }]
|
||||
}));
|
||||
};
|
||||
|
||||
const removePrompt = (index: number) => {
|
||||
setLocal(prev => ({
|
||||
...prev,
|
||||
prompts: prev.prompts.filter((_, i) => i !== index)
|
||||
}));
|
||||
};
|
||||
|
||||
const updatePrompt = (index: number, text: string) => {
|
||||
setLocal(prev => {
|
||||
const newPrompts = [...prev.prompts];
|
||||
newPrompts[index] = { ...newPrompts[index], text };
|
||||
return { ...prev, prompts: newPrompts };
|
||||
});
|
||||
};
|
||||
|
||||
const isUnedited = local.prompts.length === 0;
|
||||
|
||||
useEffect(() => {
|
||||
if (genResult && generating === "video") {
|
||||
setLocal({ ...local, prompts: genResult.result[0].prompts });
|
||||
dispatch({ type: "UPDATE_SECTION_STATE", payload: { sectionId, update: { ...local, prompts: genResult.result[0].prompts }, module: module } });
|
||||
dispatch({
|
||||
type: "UPDATE_SECTION_SINGLE_FIELD",
|
||||
payload: {
|
||||
sectionId,
|
||||
module: module,
|
||||
field: "generating",
|
||||
value: undefined
|
||||
}
|
||||
});
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [genResult, generating]);
|
||||
|
||||
const handlePrevVideo = () => {
|
||||
setCurrentVideoIndex((prev) => (prev > 0 ? prev - 1 : prev));
|
||||
};
|
||||
|
||||
const handleNextVideo = () => {
|
||||
setCurrentVideoIndex((prev) =>
|
||||
(prev < local.prompts.length - 1 ? prev + 1 : prev)
|
||||
);
|
||||
};
|
||||
|
||||
const saveDifficulty = useCallback((diff: Difficulty)=> {
|
||||
if (!difficulty.includes(diff)) {
|
||||
dispatch({ type: 'UPDATE_MODULE', payload: { updates: { difficulty: [...difficulty, diff]} } });
|
||||
}
|
||||
if (module !== "level") {
|
||||
const updatedExercise = { ...exercise, difficulty: diff };
|
||||
dispatch({ type: 'UPDATE_SECTION_STATE', payload: { sectionId, update: updatedExercise, module: currentModule } });
|
||||
} else {
|
||||
const updatedExercise = { ...exercise, difficulty: diff };
|
||||
const newState = { ...state as LevelPart };
|
||||
newState.exercises = (newState as LevelPart).exercises.map((ex) => ex.id === exercise.id ? updatedExercise : ex );
|
||||
dispatch({ type: 'UPDATE_SECTION_STATE', payload: { sectionId, update: newState, module: currentModule } });
|
||||
}
|
||||
}, [currentModule, difficulty, dispatch, exercise, module, sectionId, state]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className='relative pb-4'>
|
||||
<Header
|
||||
title={`Interactive Speaking Script`}
|
||||
description='Generate or write the scripts for the videos.'
|
||||
editing={editing}
|
||||
difficulty={exercise.difficulty}
|
||||
saveDifficulty={saveDifficulty}
|
||||
handleSave={handleSave}
|
||||
handleEdit={handleEdit}
|
||||
handleDiscard={handleDiscard}
|
||||
handlePractice={handlePractice}
|
||||
isEvaluationEnabled={!local.isPractice}
|
||||
module="speaking"
|
||||
/>
|
||||
</div>
|
||||
{(generating && generating === "speakingScript") || (levelGenerating.find((g) => g === `${local.id}-speakingScript`)) ? (
|
||||
<GenLoader module={module} />
|
||||
) : (
|
||||
<>
|
||||
{editing ? (
|
||||
<>
|
||||
{local.prompts.every((p) => p.video_url !== "") && (
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<div className="flex flex-col items-start gap-3">
|
||||
<div className="flex flex-row mb-3 gap-4 w-full justify-between items-center">
|
||||
<div className="flex flex-row gap-4">
|
||||
<RiVideoLine className="h-5 w-5 text-amber-500 mt-1" />
|
||||
<h3 className="font-semibold text-xl">Videos</h3>
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
<button
|
||||
onClick={handlePrevVideo}
|
||||
disabled={currentVideoIndex === 0}
|
||||
className={`p-2 rounded-full ${currentVideoIndex === 0
|
||||
? 'text-gray-400 cursor-not-allowed'
|
||||
: 'text-gray-600 hover:bg-gray-100'
|
||||
}`}
|
||||
>
|
||||
<FaChevronLeft className="w-4 h-4" />
|
||||
</button>
|
||||
<span className="text-sm text-gray-600">
|
||||
{currentVideoIndex + 1} / {local.prompts.length}
|
||||
</span>
|
||||
<button
|
||||
onClick={handleNextVideo}
|
||||
disabled={currentVideoIndex === local.prompts.length - 1}
|
||||
className={`p-2 rounded-full ${currentVideoIndex === local.prompts.length - 1
|
||||
? 'text-gray-400 cursor-not-allowed'
|
||||
: 'text-gray-600 hover:bg-gray-100'
|
||||
}`}
|
||||
>
|
||||
<FaChevronRight className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col gap-4 w-full items-center">
|
||||
<div className="w-full">
|
||||
<video
|
||||
key={local.prompts[currentVideoIndex].video_url}
|
||||
controls
|
||||
className="w-full rounded-xl"
|
||||
>
|
||||
<source src={local.prompts[currentVideoIndex].video_url} />
|
||||
</video>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
{(generating && generating === "video") || levelGenerating.find((g) => g === `${local.id}-video`) &&
|
||||
<GenLoader module={module} custom="Generating the videos ... This may take a while ..." />
|
||||
}
|
||||
<Card>
|
||||
<CardContent>
|
||||
<div className="flex flex-col py-2 mt-2">
|
||||
<h2 className="font-semibold text-xl mb-2">Title</h2>
|
||||
<AutoExpandingTextArea
|
||||
value={local.title || ''}
|
||||
onChange={(text) => setLocal(prev => ({ ...prev, title: text }))}
|
||||
className="w-full p-3 border border-gray-300 rounded-md focus:ring-2 focus:ring-blue-500 focus:border-transparent min-h-[80px] transition-all"
|
||||
placeholder="Enter the title"
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent>
|
||||
<div className="flex items-center mb-4 mt-6">
|
||||
<h2 className="font-semibold text-xl">Questions</h2>
|
||||
</div>
|
||||
|
||||
<div className="space-y-5">
|
||||
{local.prompts.length === 0 ? (
|
||||
<div className="py-12 text-center bg-gray-200 rounded-lg border-2 border-dashed border-gray-400">
|
||||
<p className="text-gray-600">No questions added yet</p>
|
||||
</div>
|
||||
) : (
|
||||
local.prompts.map((prompt, index) => (
|
||||
<Card key={index}>
|
||||
<CardContent>
|
||||
<div className="bg-gray-50 rounded-lg pt-4">
|
||||
<div className="flex justify-between items-center mb-3">
|
||||
<h3 className="font-medium text-gray-700">Question {index + 1}</h3>
|
||||
<button
|
||||
type="button"
|
||||
className="p-1.5 text-gray-400 hover:text-red-500 hover:bg-red-50 rounded-full transition-colors"
|
||||
onClick={() => removePrompt(index)}
|
||||
>
|
||||
<AiOutlineDelete className="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
<AutoExpandingTextArea
|
||||
value={prompt.text}
|
||||
onChange={(text) => updatePrompt(index, text)}
|
||||
className="w-full p-3 border border-gray-200 rounded-md focus:ring-2 focus:ring-blue-500 focus:border-transparent min-h-[80px] transition-all bg-white"
|
||||
placeholder={`Enter question ${index + 1}`}
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mt-6">
|
||||
<button
|
||||
type="button"
|
||||
onClick={addPrompt}
|
||||
className="w-full py-3 px-4 bg-gray-50 border border-gray-200 rounded-lg hover:bg-gray-100 transition-colors flex items-center justify-center gap-2 text-gray-600 font-medium"
|
||||
>
|
||||
<AiOutlinePlus className="h-5 w-5" />
|
||||
Add Question
|
||||
</button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</>
|
||||
) : isUnedited ? (
|
||||
<p className="w-full text-gray-600 px-7 py-8 border-2 bg-white rounded-3xl whitespace-pre-line">
|
||||
Generate or edit the questions!
|
||||
</p>
|
||||
) : (
|
||||
<div className="space-y-6">
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<div className="flex flex-col items-start gap-3">
|
||||
<div className="flex flex-row mb-3 gap-4">
|
||||
<BsFileText className="h-5 w-5 text-blue-500 mt-1" />
|
||||
<h3 className="font-semibold text-xl">Title</h3>
|
||||
</div>
|
||||
<div className="w-full px-4 py-3 bg-white shadow-inner rounded-lg border border-gray-100">
|
||||
<p className="text-lg">{local.title || 'Untitled'}</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<div className="flex flex-col items-start gap-3">
|
||||
<div className="flex flex-row mb-3 gap-4">
|
||||
<AiOutlineUnorderedList className="h-5 w-5 text-purple-500 mt-1" />
|
||||
<h3 className="font-semibold text-xl">Questions</h3>
|
||||
</div>
|
||||
<div className="w-full space-y-4">
|
||||
{local.prompts
|
||||
.filter(prompt => prompt.text !== "")
|
||||
.map((prompt, index) => (
|
||||
<div key={index} className="bg-white shadow-inner rounded-lg border border-gray-100 p-4">
|
||||
<h4 className="font-medium text-gray-700 mb-2">Question {index + 1}</h4>
|
||||
<p className="text-gray-700">{prompt.text}</p>
|
||||
</div>
|
||||
))
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default InteractiveSpeaking;
|
||||
544
src/components/ExamEditor/Exercises/Speaking/Speaking1.tsx
Normal file
@@ -0,0 +1,544 @@
|
||||
import React, { useCallback, useEffect, useState } from 'react';
|
||||
import AutoExpandingTextArea from "@/components/Low/AutoExpandingTextarea";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { AiOutlineUnorderedList, AiOutlinePlus, AiOutlineDelete } from 'react-icons/ai';
|
||||
import Header from "../../Shared/Header";
|
||||
import GenLoader from "../Shared/GenLoader";
|
||||
import useSectionEdit from "../../Hooks/useSectionEdit";
|
||||
import useExamEditorStore from "@/stores/examEditor";
|
||||
import { Difficulty, InteractiveSpeakingExercise, LevelPart } from "@/interfaces/exam";
|
||||
import { BsFileText } from "react-icons/bs";
|
||||
import { RiVideoLine } from 'react-icons/ri';
|
||||
import { FaChevronLeft, FaChevronRight } from 'react-icons/fa6';
|
||||
import { Module } from '@/interfaces';
|
||||
|
||||
interface Props {
|
||||
sectionId: number;
|
||||
exercise: InteractiveSpeakingExercise;
|
||||
module?: Module;
|
||||
}
|
||||
|
||||
const Speaking1: React.FC<Props> = ({ sectionId, exercise, module = "speaking" }) => {
|
||||
const { currentModule, dispatch } = useExamEditorStore();
|
||||
const difficulty = useExamEditorStore((state) => state.modules[currentModule].difficulty);
|
||||
const [local, setLocal] = useState(() => {
|
||||
const defaultPrompts = [
|
||||
{ text: "Hello my name is {avatar}, what is yours?", video_url: "" },
|
||||
{ text: "Do you work or do you study?", video_url: "" },
|
||||
...exercise.prompts.slice(2)
|
||||
];
|
||||
return { ...exercise, prompts: defaultPrompts };
|
||||
});
|
||||
|
||||
const [currentVideoIndex, setCurrentVideoIndex] = useState(0);
|
||||
|
||||
const { generating, genResult, state, levelGenResults, levelGenerating } = useExamEditorStore(
|
||||
(state) => state.modules[module].sections.find((section) => section.sectionId === sectionId)!
|
||||
);
|
||||
|
||||
const { editing, setEditing, handleSave, handleDiscard, handleEdit, handlePractice } = useSectionEdit({
|
||||
sectionId,
|
||||
onSave: () => {
|
||||
setEditing(false);
|
||||
|
||||
if (module === "level") {
|
||||
const updatedState = {
|
||||
...state,
|
||||
exercises: (state as LevelPart).exercises.map((ex) =>
|
||||
ex.id === local.id ? local : ex
|
||||
)
|
||||
};
|
||||
dispatch({
|
||||
type: "UPDATE_SECTION_STATE",
|
||||
payload: { sectionId, update: updatedState, module }
|
||||
});
|
||||
} else {
|
||||
dispatch({
|
||||
type: "UPDATE_SECTION_STATE",
|
||||
payload: { sectionId, update: local, module }
|
||||
});
|
||||
}
|
||||
|
||||
if (genResult) {
|
||||
dispatch({
|
||||
type: "UPDATE_SECTION_SINGLE_FIELD",
|
||||
payload: {
|
||||
sectionId,
|
||||
module,
|
||||
field: "genResult",
|
||||
value: undefined
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const speakingScript = levelGenResults?.find((res) => res.generating === `${local.id}-speakingScript`);
|
||||
if (module === "level" && speakingScript) {
|
||||
dispatch({
|
||||
type: "UPDATE_SECTION_SINGLE_FIELD", payload: {
|
||||
sectionId,
|
||||
field: "levelGenResults",
|
||||
value: levelGenResults.filter((res) => res.generating !== `${local.id}-speakingScript`),
|
||||
module
|
||||
}
|
||||
});
|
||||
}
|
||||
},
|
||||
onDiscard: () => {
|
||||
setLocal({
|
||||
...exercise,
|
||||
prompts: [
|
||||
{ text: "Hello my name is {avatar}, what is yours?", video_url: "" },
|
||||
{ text: "Do you work or do you study?", video_url: "" },
|
||||
...exercise.prompts.slice(2)
|
||||
]
|
||||
});
|
||||
},
|
||||
onPractice: () => {
|
||||
const updatedLocal = { ...local, isPractice: !local.isPractice };
|
||||
setLocal(updatedLocal);
|
||||
|
||||
if (module === "level") {
|
||||
const updatedState = {
|
||||
...state,
|
||||
exercises: (state as LevelPart).exercises.map((ex) =>
|
||||
ex.id === local.id ? updatedLocal : ex
|
||||
)
|
||||
};
|
||||
dispatch({
|
||||
type: "UPDATE_SECTION_STATE",
|
||||
payload: { sectionId, update: updatedState, module }
|
||||
});
|
||||
} else {
|
||||
dispatch({
|
||||
type: "UPDATE_SECTION_STATE",
|
||||
payload: { sectionId, update: updatedLocal, module }
|
||||
});
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (genResult && generating === "speakingScript") {
|
||||
if (!difficulty.includes(genResult.result[0].difficulty)) {
|
||||
dispatch({ type: 'UPDATE_MODULE', payload: { updates: { difficulty: [...difficulty, genResult.result[0].difficulty]} } });
|
||||
}
|
||||
const updatedLocal = {
|
||||
...local,
|
||||
first_title: genResult.result[0].first_topic,
|
||||
second_title: genResult.result[0].second_topic,
|
||||
prompts: [
|
||||
local.prompts[0],
|
||||
local.prompts[1],
|
||||
...genResult.result[0].prompts.map((item: any) => ({
|
||||
text: item,
|
||||
video_url: ""
|
||||
}))
|
||||
],
|
||||
difficulty: genResult.result[0].difficulty
|
||||
};
|
||||
setEditing(true);
|
||||
setLocal(updatedLocal);
|
||||
|
||||
dispatch({
|
||||
type: "UPDATE_SECTION_SINGLE_FIELD",
|
||||
payload: {
|
||||
sectionId,
|
||||
module,
|
||||
field: "generating",
|
||||
value: undefined
|
||||
}
|
||||
});
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [genResult, generating]);
|
||||
|
||||
useEffect(() => {
|
||||
if (genResult && generating === "video") {
|
||||
const updatedLocal = { ...local, prompts: genResult.result[0].prompts };
|
||||
setLocal(updatedLocal);
|
||||
|
||||
dispatch({
|
||||
type: "UPDATE_SECTION_STATE",
|
||||
payload: { sectionId, update: updatedLocal, module }
|
||||
});
|
||||
|
||||
|
||||
dispatch({
|
||||
type: "UPDATE_SECTION_SINGLE_FIELD",
|
||||
payload: {
|
||||
sectionId,
|
||||
module,
|
||||
field: "generating",
|
||||
value: undefined
|
||||
}
|
||||
});
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [genResult, generating]);
|
||||
|
||||
useEffect(() => {
|
||||
const speakingScript = levelGenResults?.find((res) => res.generating === `${local.id}-speakingScript`);
|
||||
const isGenerating = levelGenerating?.includes(`${local.id}-speakingScript`);
|
||||
|
||||
if (speakingScript && isGenerating) {
|
||||
if (!difficulty.includes(speakingScript.result[0].difficulty)) {
|
||||
dispatch({ type: 'UPDATE_MODULE', payload: { updates: { difficulty: [...difficulty, speakingScript.result[0].difficulty]} } });
|
||||
}
|
||||
const updatedLocal = {
|
||||
...local,
|
||||
first_title: speakingScript.result[0].first_topic,
|
||||
second_title: speakingScript.result[0].second_topic,
|
||||
difficulty: speakingScript.result[0].difficulty,
|
||||
prompts: [
|
||||
local.prompts[0],
|
||||
local.prompts[1],
|
||||
...speakingScript.result[0].prompts.map((item: any) => ({
|
||||
text: item,
|
||||
video_url: ""
|
||||
}))
|
||||
]
|
||||
};
|
||||
setEditing(true);
|
||||
setLocal(updatedLocal);
|
||||
|
||||
dispatch({
|
||||
type: "UPDATE_SECTION_SINGLE_FIELD", payload: {
|
||||
sectionId,
|
||||
field: "levelGenerating",
|
||||
value: levelGenerating.filter((g) => g !== `${local.id}-speakingScript`),
|
||||
module
|
||||
}
|
||||
});
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [levelGenResults, levelGenerating]);
|
||||
|
||||
useEffect(() => {
|
||||
const speakingVideo = levelGenResults?.find((res) => res.generating === `${local.id}-video`);
|
||||
const isGenerating = levelGenerating?.includes(`${local.id}-video`);
|
||||
|
||||
if (speakingVideo && isGenerating) {
|
||||
const updatedLocal = { ...local, video_url: speakingVideo.result[0].video_url };
|
||||
setLocal(updatedLocal);
|
||||
|
||||
const updatedState = {
|
||||
...state,
|
||||
exercises: (state as LevelPart).exercises.map((ex) =>
|
||||
ex.id === local.id ? updatedLocal : ex
|
||||
)
|
||||
};
|
||||
|
||||
dispatch({
|
||||
type: "UPDATE_SECTION_STATE",
|
||||
payload: { sectionId, update: updatedState, module }
|
||||
});
|
||||
|
||||
dispatch({
|
||||
type: "UPDATE_SECTION_SINGLE_FIELD", payload: {
|
||||
sectionId,
|
||||
field: "levelGenerating",
|
||||
value: levelGenerating.filter((g) => g !== `${local.id}-video`),
|
||||
module
|
||||
}
|
||||
});
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [levelGenResults, levelGenerating]);
|
||||
|
||||
|
||||
const addPrompt = () => {
|
||||
setLocal(prev => ({
|
||||
...prev,
|
||||
prompts: [...prev.prompts, { text: "", video_url: "" }]
|
||||
}));
|
||||
};
|
||||
|
||||
const removePrompt = (index: number) => {
|
||||
if (index < 2) return;
|
||||
setLocal(prev => ({
|
||||
...prev,
|
||||
prompts: prev.prompts.filter((_, i) => i !== index)
|
||||
}));
|
||||
};
|
||||
|
||||
const updatePrompt = (index: number, text: string) => {
|
||||
if (index < 2) return;
|
||||
setLocal(prev => {
|
||||
const newPrompts = [...prev.prompts];
|
||||
newPrompts[index] = { ...newPrompts[index], text };
|
||||
return { ...prev, prompts: newPrompts };
|
||||
});
|
||||
};
|
||||
|
||||
const isUnedited = local.prompts.length === 2;
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
if (genResult && generating === "video") {
|
||||
setLocal({ ...local, prompts: genResult.result[0].prompts });
|
||||
dispatch({ type: "UPDATE_SECTION_STATE", payload: { sectionId, update: { ...local, prompts: genResult.result[0].prompts }, module } });
|
||||
dispatch({
|
||||
type: "UPDATE_SECTION_SINGLE_FIELD",
|
||||
payload: {
|
||||
sectionId,
|
||||
module: module,
|
||||
field: "generating",
|
||||
value: undefined
|
||||
}
|
||||
});
|
||||
dispatch({
|
||||
type: "UPDATE_SECTION_SINGLE_FIELD",
|
||||
payload: {
|
||||
sectionId,
|
||||
module: module,
|
||||
field: "genResult",
|
||||
value: undefined
|
||||
}
|
||||
});
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [genResult, generating]);
|
||||
|
||||
const handlePrevVideo = () => {
|
||||
setCurrentVideoIndex((prev) => (prev > 0 ? prev - 1 : prev));
|
||||
};
|
||||
|
||||
const handleNextVideo = () => {
|
||||
setCurrentVideoIndex((prev) =>
|
||||
(prev < local.prompts.length - 1 ? prev + 1 : prev)
|
||||
);
|
||||
};
|
||||
|
||||
const saveDifficulty = useCallback((diff: Difficulty)=> {
|
||||
if (!difficulty.includes(diff)) {
|
||||
dispatch({ type: 'UPDATE_MODULE', payload: { updates: { difficulty: [...difficulty, diff]} } });
|
||||
}
|
||||
if (module !== "level") {
|
||||
const updatedExercise = { ...exercise, difficulty: diff };
|
||||
dispatch({ type: 'UPDATE_SECTION_STATE', payload: { sectionId, update: updatedExercise, module: currentModule } });
|
||||
} else {
|
||||
const updatedExercise = { ...exercise, difficulty: diff };
|
||||
const newState = { ...state as LevelPart };
|
||||
newState.exercises = (newState as LevelPart).exercises.map((ex) => ex.id === exercise.id ? updatedExercise : ex );
|
||||
dispatch({ type: 'UPDATE_SECTION_STATE', payload: { sectionId, update: newState, module: currentModule } });
|
||||
}
|
||||
}, [currentModule, difficulty, dispatch, exercise, module, sectionId, state]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className='relative pb-4'>
|
||||
<Header
|
||||
title={`Speaking 1 Script`}
|
||||
description='Generate or write the scripts for the videos.'
|
||||
editing={editing}
|
||||
difficulty={exercise.difficulty}
|
||||
saveDifficulty={saveDifficulty}
|
||||
handleSave={handleSave}
|
||||
handleEdit={handleEdit}
|
||||
handleDiscard={handleDiscard}
|
||||
handlePractice={handlePractice}
|
||||
isEvaluationEnabled={!local.isPractice}
|
||||
module="speaking"
|
||||
/>
|
||||
</div>
|
||||
{(generating && generating === "speakingScript") || (levelGenerating.find((g) => g === `${local.id}-speakingScript`)) ? (
|
||||
<GenLoader module={module} />
|
||||
) : (
|
||||
<>
|
||||
{editing ? (
|
||||
<>
|
||||
<Card>
|
||||
<CardContent>
|
||||
<div className="py-2 mt-2">
|
||||
<div className="flex flex-row mb-3 gap-4">
|
||||
<BsFileText className="h-5 w-5 text-blue-500 mt-1" />
|
||||
<h3 className="font-semibold text-xl">Titles</h3>
|
||||
</div>
|
||||
<div className="flex gap-6 mt-6">
|
||||
<div className="flex-1">
|
||||
<h2 className="font-semibold text-lg mb-2">First Title</h2>
|
||||
<AutoExpandingTextArea
|
||||
value={local.first_title || ''}
|
||||
onChange={(text) => setLocal(prev => ({ ...prev, first_title: text }))}
|
||||
className="w-full p-3 border border-gray-300 rounded-md focus:ring-2 focus:ring-blue-500 focus:border-transparent min-h-[80px] transition-all"
|
||||
placeholder="Enter the first title"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<h2 className="font-semibold text-lg mb-2">Second Title</h2>
|
||||
<AutoExpandingTextArea
|
||||
value={local.second_title || ''}
|
||||
onChange={(text) => setLocal(prev => ({ ...prev, second_title: text }))}
|
||||
className="w-full p-3 border border-gray-300 rounded-md focus:ring-2 focus:ring-blue-500 focus:border-transparent min-h-[80px] transition-all"
|
||||
placeholder="Enter the second title"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent>
|
||||
<div className="flex items-center justify-between mb-4 mt-6">
|
||||
<h2 className="font-semibold text-xl">Questions</h2>
|
||||
</div>
|
||||
|
||||
<div className="space-y-5">
|
||||
{local.prompts.length === 2 ? (
|
||||
<div className="py-12 text-center bg-gray-200 rounded-lg border-2 border-dashed border-gray-400">
|
||||
<p className="text-gray-600">No questions added yet</p>
|
||||
</div>
|
||||
) : (
|
||||
local.prompts.slice(2).map((prompt, index) => (
|
||||
<Card key={index}>
|
||||
<CardContent>
|
||||
<div className="bg-gray-50 rounded-lg pt-4">
|
||||
<div className="flex justify-between items-center mb-3">
|
||||
<h3 className="font-medium text-gray-700">Question {index + 1}</h3>
|
||||
<button
|
||||
type="button"
|
||||
className="p-1.5 text-gray-400 hover:text-red-500 hover:bg-red-50 rounded-full transition-colors"
|
||||
onClick={() => removePrompt(index + 2)}
|
||||
>
|
||||
<AiOutlineDelete className="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
<AutoExpandingTextArea
|
||||
value={prompt.text}
|
||||
onChange={(text) => updatePrompt(index + 2, text)}
|
||||
className="w-full p-3 border border-gray-200 rounded-md focus:ring-2 focus:ring-blue-500 focus:border-transparent min-h-[80px] transition-all bg-white"
|
||||
placeholder={`Enter question ${index + 1}`}
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mt-6">
|
||||
<button
|
||||
type="button"
|
||||
onClick={addPrompt}
|
||||
className="w-full py-3 px-4 bg-gray-50 border border-gray-200 rounded-lg hover:bg-gray-100 transition-colors flex items-center justify-center gap-2 text-gray-600 font-medium"
|
||||
>
|
||||
<AiOutlinePlus className="h-5 w-5" />
|
||||
Add Question
|
||||
</button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</>
|
||||
) : isUnedited ? (
|
||||
<p className="w-full text-gray-600 px-7 py-8 border-2 bg-white rounded-3xl whitespace-pre-line">
|
||||
Generate or edit the questions!
|
||||
</p>
|
||||
) : (
|
||||
<div className="space-y-6">
|
||||
{local.prompts.every((p) => p.video_url !== "") && (
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<div className="flex flex-col items-start gap-3">
|
||||
<div className="flex flex-row mb-3 gap-4 w-full justify-between items-center">
|
||||
<div className="flex flex-row gap-4">
|
||||
<RiVideoLine className="h-5 w-5 text-amber-500 mt-1" />
|
||||
<h3 className="font-semibold text-xl">Videos</h3>
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
<button
|
||||
onClick={handlePrevVideo}
|
||||
disabled={currentVideoIndex === 0}
|
||||
className={`p-2 rounded-full ${currentVideoIndex === 0
|
||||
? 'text-gray-400 cursor-not-allowed'
|
||||
: 'text-gray-600 hover:bg-gray-100'
|
||||
}`}
|
||||
>
|
||||
<FaChevronLeft className="w-4 h-4" />
|
||||
</button>
|
||||
<span className="text-sm text-gray-600">
|
||||
{currentVideoIndex + 1} / {local.prompts.length}
|
||||
</span>
|
||||
<button
|
||||
onClick={handleNextVideo}
|
||||
disabled={currentVideoIndex === local.prompts.length - 1}
|
||||
className={`p-2 rounded-full ${currentVideoIndex === local.prompts.length - 1
|
||||
? 'text-gray-400 cursor-not-allowed'
|
||||
: 'text-gray-600 hover:bg-gray-100'
|
||||
}`}
|
||||
>
|
||||
<FaChevronRight className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col gap-4 w-full items-center">
|
||||
<div className="w-full">
|
||||
<video
|
||||
key={local.prompts[currentVideoIndex].video_url}
|
||||
controls
|
||||
className="w-full rounded-xl"
|
||||
>
|
||||
<source src={local.prompts[currentVideoIndex].video_url} />
|
||||
</video>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
{(generating && generating === "video") || levelGenerating.find((g) => g === `${local.id}-video`) &&
|
||||
<GenLoader module={module} custom="Generating the videos ... This may take a while ..." />
|
||||
}
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<div className="flex flex-col items-start">
|
||||
<div className="flex flex-row mb-4 gap-4">
|
||||
<BsFileText className="h-5 w-5 text-blue-500 mt-1" />
|
||||
<h3 className="font-semibold text-xl">Titles</h3>
|
||||
</div>
|
||||
<div className="w-full flex gap-6 mt-6">
|
||||
<div className="flex-1">
|
||||
<h4 className="font-medium text-gray-700 mb-2">First Title</h4>
|
||||
<div className="w-full px-4 py-3 bg-white shadow-inner rounded-lg border border-gray-100">
|
||||
<p className="text-lg">{local.first_title || 'No first title'}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<h4 className="font-medium text-gray-700 mb-2">Second Title</h4>
|
||||
<div className="w-full px-4 py-3 bg-white shadow-inner rounded-lg border border-gray-100">
|
||||
<p className="text-lg">{local.second_title || 'No second title'}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<div className="flex flex-col items-start gap-3">
|
||||
<div className="flex flex-row mb-3 gap-4">
|
||||
<AiOutlineUnorderedList className="h-5 w-5 text-purple-500 mt-1" />
|
||||
<h3 className="font-semibold text-xl">Questions</h3>
|
||||
</div>
|
||||
<div className="w-full space-y-4">
|
||||
{local.prompts.slice(2)
|
||||
.filter(prompt => prompt.text !== "")
|
||||
.map((prompt, index) => (
|
||||
<div key={index} className="bg-white shadow-inner rounded-lg border border-gray-100 p-4">
|
||||
<h4 className="font-medium text-gray-700 mb-2">Question {index + 1}</h4>
|
||||
<p className="text-gray-700">{prompt.text}</p>
|
||||
</div>
|
||||
))
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default Speaking1;
|
||||
462
src/components/ExamEditor/Exercises/Speaking/Speaking2.tsx
Normal file
@@ -0,0 +1,462 @@
|
||||
import AutoExpandingTextArea from "@/components/Low/AutoExpandingTextarea";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { AiOutlinePlus, AiOutlineDelete } from 'react-icons/ai';
|
||||
import { Difficulty, LevelPart, SpeakingExercise } from "@/interfaces/exam";
|
||||
import useExamEditorStore from "@/stores/examEditor";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import useSectionEdit from "../../Hooks/useSectionEdit";
|
||||
import Header from "../../Shared/Header";
|
||||
import { Tooltip } from "react-tooltip";
|
||||
import { BsFileText } from 'react-icons/bs';
|
||||
import { AiOutlineUnorderedList } from 'react-icons/ai';
|
||||
import { BiQuestionMark, BiMessageRoundedDetail } from "react-icons/bi";
|
||||
import GenLoader from "../Shared/GenLoader";
|
||||
import { RiVideoLine } from 'react-icons/ri';
|
||||
import { Module } from "@/interfaces";
|
||||
|
||||
interface Props {
|
||||
sectionId: number;
|
||||
exercise: SpeakingExercise;
|
||||
module?: Module;
|
||||
}
|
||||
|
||||
const Speaking2: React.FC<Props> = ({ sectionId, exercise, module = "speaking" }) => {
|
||||
const { currentModule, dispatch } = useExamEditorStore();
|
||||
const difficulty = useExamEditorStore((state) => state.modules[currentModule].difficulty);
|
||||
const [local, setLocal] = useState(exercise);
|
||||
|
||||
const { sections } = useExamEditorStore((store) => store.modules[module]);
|
||||
const section = sections.find((section) => section.sectionId === sectionId)!;
|
||||
const { generating, genResult, state, levelGenResults, levelGenerating } = section;
|
||||
|
||||
const { editing, setEditing, handleSave, handleDiscard, handleEdit, handlePractice } = useSectionEdit({
|
||||
sectionId,
|
||||
onSave: () => {
|
||||
setEditing(false);
|
||||
|
||||
if (module === "level") {
|
||||
const updatedState = {
|
||||
...state,
|
||||
exercises: (state as LevelPart).exercises.map((ex) =>
|
||||
ex.id === local.id ? local : ex
|
||||
)
|
||||
};
|
||||
dispatch({
|
||||
type: "UPDATE_SECTION_STATE",
|
||||
payload: { sectionId, update: updatedState, module }
|
||||
});
|
||||
} else {
|
||||
dispatch({
|
||||
type: "UPDATE_SECTION_STATE",
|
||||
payload: { sectionId, update: local, module }
|
||||
});
|
||||
}
|
||||
|
||||
if (genResult) {
|
||||
dispatch({
|
||||
type: "UPDATE_SECTION_SINGLE_FIELD",
|
||||
payload: {
|
||||
sectionId,
|
||||
module,
|
||||
field: "genResult",
|
||||
value: undefined
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const speakingScript = levelGenResults.find((res) => res.generating === `${local.id}-speakingScript`)
|
||||
if (module === "level" && speakingScript) {
|
||||
dispatch({
|
||||
type: "UPDATE_SECTION_SINGLE_FIELD", payload: {
|
||||
sectionId,
|
||||
field: "levelGenResults",
|
||||
value: section!.levelGenResults.filter((res) => res.generating !== `${local.id ? `${local.id}-` : ''}speakingScript`),
|
||||
module
|
||||
}
|
||||
})
|
||||
}
|
||||
},
|
||||
onDiscard: () => {
|
||||
setLocal(exercise);
|
||||
},
|
||||
onPractice: () => {
|
||||
const updatedLocal = { ...local, isPractice: !local.isPractice };
|
||||
setLocal(updatedLocal);
|
||||
|
||||
if (module === "level") {
|
||||
const updatedState = {
|
||||
...state,
|
||||
exercises: (state as LevelPart).exercises.map((ex) =>
|
||||
ex.id === local.id ? updatedLocal : ex
|
||||
)
|
||||
};
|
||||
dispatch({
|
||||
type: "UPDATE_SECTION_STATE",
|
||||
payload: { sectionId, update: updatedState, module }
|
||||
});
|
||||
} else {
|
||||
dispatch({
|
||||
type: "UPDATE_SECTION_STATE",
|
||||
payload: { sectionId, update: updatedLocal, module }
|
||||
});
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (genResult && generating === "speakingScript") {
|
||||
if (!difficulty.includes(genResult.result[0].difficulty)) {
|
||||
dispatch({ type: 'UPDATE_MODULE', payload: { updates: { difficulty: [...difficulty, genResult.result[0].difficulty]} } });
|
||||
}
|
||||
const updatedLocal = {
|
||||
...local,
|
||||
title: genResult.result[0].topic,
|
||||
text: genResult.result[0].question,
|
||||
prompts: genResult.result[0].prompts,
|
||||
difficulty: genResult.result[0].difficulty
|
||||
};
|
||||
setEditing(true);
|
||||
setLocal(updatedLocal);
|
||||
|
||||
dispatch({
|
||||
type: "UPDATE_SECTION_SINGLE_FIELD",
|
||||
payload: {
|
||||
sectionId,
|
||||
module,
|
||||
field: "generating",
|
||||
value: undefined
|
||||
}
|
||||
});
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [genResult, generating]);
|
||||
|
||||
useEffect(() => {
|
||||
if (genResult && generating === "video") {
|
||||
const updatedLocal = { ...local, video_url: genResult.result[0].video_url };
|
||||
setLocal(updatedLocal);
|
||||
|
||||
dispatch({
|
||||
type: "UPDATE_SECTION_STATE",
|
||||
payload: { sectionId, update: updatedLocal, module }
|
||||
});
|
||||
|
||||
dispatch({
|
||||
type: "UPDATE_SECTION_SINGLE_FIELD",
|
||||
payload: {
|
||||
sectionId,
|
||||
module,
|
||||
field: "generating",
|
||||
value: undefined
|
||||
}
|
||||
});
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [genResult, generating]);
|
||||
|
||||
useEffect(() => {
|
||||
const speakingScript = levelGenResults.find((res) => res.generating === `${local.id}-speakingScript`);
|
||||
const generating = levelGenerating.find((res) => res === `${local.id}-speakingScript`);
|
||||
if (speakingScript && generating) {
|
||||
if (!difficulty.includes(speakingScript.result[0].difficulty)) {
|
||||
dispatch({ type: 'UPDATE_MODULE', payload: { updates: { difficulty: [...difficulty, speakingScript.result[0].difficulty]} } });
|
||||
}
|
||||
const updatedLocal = {
|
||||
...local,
|
||||
title: speakingScript.result[0].topic,
|
||||
text: speakingScript.result[0].question,
|
||||
prompts: speakingScript.result[0].prompts,
|
||||
difficulty: speakingScript.result[0].difficulty
|
||||
};
|
||||
setEditing(true);
|
||||
setLocal(updatedLocal);
|
||||
dispatch({
|
||||
type: "UPDATE_SECTION_SINGLE_FIELD", payload: {
|
||||
sectionId,
|
||||
field: "levelGenerating",
|
||||
value: section!.levelGenerating.filter((g) => g !== `${local.id ? `${local.id}-` : ''}speakingScript`),
|
||||
module
|
||||
}
|
||||
})
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [levelGenResults, levelGenerating]);
|
||||
|
||||
useEffect(() => {
|
||||
const speakingVideo = levelGenResults.find((res) => res.generating === `${local.id}-video`);
|
||||
const generating = levelGenerating.find((res) => res === `${local.id}-video`);
|
||||
|
||||
if (speakingVideo && generating) {
|
||||
const updatedLocal = { ...local, video_url: speakingVideo.result[0].video_url };
|
||||
setLocal(updatedLocal);
|
||||
|
||||
const updatedState = {
|
||||
...state,
|
||||
exercises: (state as LevelPart).exercises.map((ex) =>
|
||||
ex.id === local.id ? updatedLocal : ex
|
||||
)
|
||||
};
|
||||
|
||||
dispatch({
|
||||
type: "UPDATE_SECTION_STATE",
|
||||
payload: { sectionId, update: updatedState, module }
|
||||
});
|
||||
|
||||
dispatch({
|
||||
type: "UPDATE_SECTION_SINGLE_FIELD", payload: {
|
||||
sectionId,
|
||||
field: "levelGenerating",
|
||||
value: section!.levelGenerating.filter((g) => g !== `${local.id ? `${local.id}-` : ''}video`),
|
||||
module
|
||||
}
|
||||
});
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [levelGenResults, levelGenerating]);
|
||||
|
||||
const addPrompt = () => {
|
||||
setLocal(prev => ({
|
||||
...prev,
|
||||
prompts: [...prev.prompts, ""]
|
||||
}));
|
||||
};
|
||||
|
||||
const removePrompt = (index: number) => {
|
||||
setLocal(prev => ({
|
||||
...prev,
|
||||
prompts: prev.prompts.filter((_, i) => i !== index)
|
||||
}));
|
||||
};
|
||||
|
||||
const updatePrompt = (index: number, text: string) => {
|
||||
setLocal(prev => {
|
||||
const newPrompts = [...prev.prompts];
|
||||
newPrompts[index] = text;
|
||||
return { ...prev, prompts: newPrompts };
|
||||
});
|
||||
};
|
||||
|
||||
const isUnedited = local.text === "" ||
|
||||
(local.title === undefined || local.title === "") ||
|
||||
local.prompts.length === 0;
|
||||
|
||||
const tooltipContent = `
|
||||
<div class='p-2 max-w-xs'>
|
||||
<p class='text-sm text-white'>
|
||||
Prompts are guiding points that help candidates structure their talk. They typically include aspects like:
|
||||
<ul class='list-disc pl-4 mt-1'>
|
||||
<li>Describing what/who/where</li>
|
||||
<li>Explaining why</li>
|
||||
<li>Sharing feelings or preferences</li>
|
||||
</ul>
|
||||
</p>
|
||||
</div>
|
||||
`;
|
||||
|
||||
const saveDifficulty = useCallback((diff: Difficulty)=> {
|
||||
if (!difficulty.includes(diff)) {
|
||||
dispatch({ type: 'UPDATE_MODULE', payload: { updates: { difficulty: [...difficulty, diff]} } });
|
||||
}
|
||||
if (module !== "level") {
|
||||
const updatedExercise = { ...exercise, difficulty: diff };
|
||||
dispatch({ type: 'UPDATE_SECTION_STATE', payload: { sectionId, update: updatedExercise, module: currentModule } });
|
||||
} else {
|
||||
const updatedExercise = { ...exercise, difficulty: diff };
|
||||
const newState = { ...state as LevelPart };
|
||||
newState.exercises = (newState as LevelPart).exercises.map((ex) => ex.id === exercise.id ? updatedExercise : ex );
|
||||
dispatch({ type: 'UPDATE_SECTION_STATE', payload: { sectionId, update: newState, module: currentModule } });
|
||||
}
|
||||
}, [currentModule, difficulty, dispatch, exercise, module, sectionId, state]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className='relative pb-4'>
|
||||
<Header
|
||||
title={`Speaking ${module === "level" ? local.sectionId : sectionId} Script`}
|
||||
description='Generate or write the script for the video.'
|
||||
editing={editing}
|
||||
difficulty={exercise.difficulty}
|
||||
saveDifficulty={saveDifficulty}
|
||||
handleSave={handleSave}
|
||||
handleEdit={handleEdit}
|
||||
handleDiscard={handleDiscard}
|
||||
handlePractice={handlePractice}
|
||||
isEvaluationEnabled={!local.isPractice}
|
||||
module="speaking"
|
||||
/>
|
||||
</div>
|
||||
{((generating && generating === "speakingScript") || (levelGenerating.find((g) => g === `${local.id}-speakingScript`))) ? (
|
||||
<GenLoader module={module} />
|
||||
) : (
|
||||
<>
|
||||
{editing ? (
|
||||
<>
|
||||
<Card>
|
||||
<CardContent>
|
||||
<div className="flex flex-col py-2 mt-2">
|
||||
<h2 className="font-semibold text-xl mb-2">Title</h2>
|
||||
<AutoExpandingTextArea
|
||||
value={local.title || ''}
|
||||
onChange={(text) => setLocal(prev => ({ ...prev, title: text }))}
|
||||
className="w-full p-3 border border-gray-300 rounded-md focus:ring-2 focus:ring-blue-500 focus:border-transparent min-h-[80px] transition-all"
|
||||
placeholder="Enter the topic"
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent>
|
||||
<div className="flex flex-col py-2 mt-2">
|
||||
<h2 className="font-semibold text-xl mb-2">Question</h2>
|
||||
<AutoExpandingTextArea
|
||||
value={local.text}
|
||||
onChange={(text) => setLocal(prev => ({ ...prev, text: text }))}
|
||||
className="w-full p-3 border border-gray-300 rounded-md focus:ring-2 focus:ring-blue-500 focus:border-transparent min-h-[80px] transition-all"
|
||||
placeholder="Enter the main question"
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent>
|
||||
<div className="flex items-center justify-between mb-4 mt-6">
|
||||
<h2 className="font-semibold text-xl">Prompts</h2>
|
||||
<Tooltip id="prompt-tp" />
|
||||
<a
|
||||
data-tooltip-id="prompt-tp"
|
||||
data-tooltip-html={tooltipContent}
|
||||
className='ml-1 w-6 h-6 flex items-center justify-center rounded-full hover:bg-gray-200 border bg-gray-100'
|
||||
>
|
||||
<BiQuestionMark
|
||||
className="w-5 h-5 text-gray-500"
|
||||
/>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div className="space-y-5">
|
||||
{local.prompts.length === 0 ? (
|
||||
<div className="py-12 text-center bg-gray-200 rounded-lg border-2 border-dashed border-gray-400">
|
||||
<p className="text-gray-600">No prompts added yet</p>
|
||||
</div>
|
||||
) : (
|
||||
local.prompts.map((prompt, index) => (
|
||||
<Card key={index}>
|
||||
<CardContent>
|
||||
<div className="bg-gray-50 rounded-lg pt-4">
|
||||
<div className="flex justify-between items-center mb-3">
|
||||
<h3 className="font-medium text-gray-700">Prompt {index + 1}</h3>
|
||||
<button
|
||||
type="button"
|
||||
className="p-1.5 text-gray-400 hover:text-red-500 hover:bg-red-50 rounded-full transition-colors"
|
||||
onClick={() => removePrompt(index)}
|
||||
>
|
||||
<AiOutlineDelete className="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
<AutoExpandingTextArea
|
||||
value={prompt}
|
||||
onChange={(text) => updatePrompt(index, text)}
|
||||
className="w-full p-3 border border-gray-200 rounded-md focus:ring-2 focus:ring-blue-500 focus:border-transparent min-h-[80px] transition-all bg-white"
|
||||
placeholder={`Enter prompt ${index + 1}`}
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mt-6">
|
||||
<button
|
||||
type="button"
|
||||
onClick={addPrompt}
|
||||
className="w-full py-3 px-4 bg-gray-50 border border-gray-200 rounded-lg hover:bg-gray-100 transition-colors flex items-center justify-center gap-2 text-gray-600 font-medium"
|
||||
>
|
||||
<AiOutlinePlus className="h-5 w-5" />
|
||||
Add Prompt
|
||||
</button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</>
|
||||
) : isUnedited ? (
|
||||
<p className="w-full text-gray-600 px-7 py-8 border-2 bg-white rounded-3xl whitespace-pre-line">
|
||||
Generate or edit the script!
|
||||
</p>
|
||||
) : (
|
||||
<div className="space-y-6">
|
||||
{local.video_url && <Card>
|
||||
<CardContent className="pt-6">
|
||||
<div className="flex flex-col items-start gap-3">
|
||||
<div className="flex flex-row mb-3 gap-4">
|
||||
<RiVideoLine className="h-5 w-5 text-amber-500 mt-1" />
|
||||
<h3 className="font-semibold text-xl">Video</h3>
|
||||
</div>
|
||||
<div className="flex flex-col gap-4 w-full items-center">
|
||||
<video controls className="w-full rounded-xl">
|
||||
<source src={local.video_url} />
|
||||
</video>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
}
|
||||
{((generating === "video") || (levelGenerating.find((g) => g === `${local.id}-video`) !== undefined)) &&
|
||||
<GenLoader module={module} custom="Generating the video ... This may take a while ..." />
|
||||
}
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<div className="flex flex-col items-start gap-3">
|
||||
<div className="flex flex-row mb-3 gap-4">
|
||||
<BsFileText className="h-5 w-5 text-blue-500 mt-1" />
|
||||
<h3 className="font-semibold text-xl">Title</h3>
|
||||
</div>
|
||||
<div className="w-full px-4 py-3 bg-white shadow-inner rounded-lg border border-gray-100">
|
||||
<p className="text-lg">{local.title || 'Untitled'}</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<div className="flex flex-col items-start gap-3">
|
||||
<div className="flex flex-row mb-3 gap-4">
|
||||
<BiMessageRoundedDetail className="h-5 w-5 text-green-500 mt-1" />
|
||||
<h3 className="font-semibold text-xl">Question</h3>
|
||||
</div>
|
||||
<div className="w-full px-4 py-3 bg-white shadow-inner rounded-lg border border-gray-100">
|
||||
<p className="text-lg">{local.text || 'No question provided'}</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
{local.prompts && local.prompts.length > 0 && (
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<div className="flex flex-col items-start gap-3">
|
||||
<div className="flex flex-row mb-3 gap-4">
|
||||
<AiOutlineUnorderedList className="h-5 w-5 text-purple-500 mt-1" />
|
||||
<h3 className="font-semibold text-xl">Prompts</h3>
|
||||
</div>
|
||||
<div className="w-full p-4 bg-gray-50 shadow-inner rounded-lg border border-gray-100">
|
||||
<div className="flex flex-col gap-3">
|
||||
{local.prompts.map((prompt, index) => (
|
||||
<div key={index} className="px-4 py-3 bg-white shadow rounded-lg border border-gray-100">
|
||||
<p className="text-gray-700">{prompt}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default Speaking2;
|
||||
34
src/components/ExamEditor/Exercises/Speaking/index.tsx
Normal file
@@ -0,0 +1,34 @@
|
||||
import useExamEditorStore from "@/stores/examEditor";
|
||||
import { SpeakingExercise, InteractiveSpeakingExercise } from "@/interfaces/exam";
|
||||
import Speaking2 from "./Speaking2";
|
||||
import InteractiveSpeaking from "./InteractiveSpeaking";
|
||||
import Speaking1 from "./Speaking1";
|
||||
import { Module } from "@/interfaces";
|
||||
|
||||
interface Props {
|
||||
sectionId: number;
|
||||
exercise: SpeakingExercise | InteractiveSpeakingExercise;
|
||||
module: Module;
|
||||
}
|
||||
|
||||
const Speaking: React.FC<Props> = ({ sectionId, module = "speaking" }) => {
|
||||
const { state } = useExamEditorStore(
|
||||
(state) => state.modules[module].sections.find((section) => section.sectionId === sectionId)!
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="mx-auto p-3 space-y-6">
|
||||
<div className="p-4">
|
||||
<div className="flex flex-col space-y-6">
|
||||
{sectionId === 1 && <Speaking1 sectionId={sectionId} exercise={state as InteractiveSpeakingExercise } />}
|
||||
{sectionId === 2 && <Speaking2 sectionId={sectionId} exercise={state as SpeakingExercise} />}
|
||||
{sectionId === 3 && <InteractiveSpeaking sectionId={sectionId} exercise={state as InteractiveSpeakingExercise} />}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default Speaking;
|
||||