Compare commits

...

332 Commits

Author SHA1 Message Date
Francisco Lima
58aebaa66c Merged in ENCOA-316-ENCOA-317 (pull request #141)
Fix login page having a Card

Approved-by: Tiago Ribeiro
2025-01-29 08:59:55 +00:00
José Marques Lima
b69b6e6c77 Fix login page having a Card 2025-01-28 20:31:19 +00:00
Francisco Lima
86af876f01 Merged in ENCOA-316-ENCOA-317 (pull request #140)
Fix entities Page not rendering

Approved-by: Tiago Ribeiro
2025-01-28 09:35:02 +00:00
Tiago Ribeiro
b685259dc7 Merged develop into ENCOA-316-ENCOA-317 2025-01-28 09:30:34 +00:00
José Marques Lima
16b959fb7a Fix entities Page not rendering 2025-01-27 22:06:34 +00:00
Francisco Lima
db95fc5681 Merged in ENCOA-316-ENCOA-317 (pull request #139)
ENCOA-316 ENCOA-317

Approved-by: Tiago Ribeiro
2025-01-27 09:27:09 +00:00
José Marques Lima
c98af863c3 Merge branch 'develop' of https://bitbucket.org/ecropdev/ielts-ui into ENCOA-316-ENCOA-317 2025-01-25 20:01:52 +00:00
José Marques Lima
37216e2a5a ENCOA-316 ENCOA-317:
Refactor components to remove Layout wrapper and pass it in the App component , implemented a skeleton feedback while loading page and improved API calls related to Dashboard/User Profile
2025-01-25 19:38:29 +00:00
carlos.mesquita
f727ab4792 Merged in feature/ExamGenRework (pull request #138)
ENCOA-315

Approved-by: Tiago Ribeiro
2025-01-22 08:27:46 +00:00
Carlos-Mesquita
1c75a0e59c ENCOA-315: Small fix and merge 2025-01-22 05:24:49 +00:00
Carlos-Mesquita
e36b24ea3f ENCOA-315 2025-01-22 04:46:24 +00:00
Francisco Lima
4d788e13b4 Merged in ENCOA-314 (pull request #137)
ENCOA-314

Approved-by: Tiago Ribeiro
2025-01-20 17:15:33 +00:00
José Marques Lima
ae9a49681e ENCOA-314 :
- Implemented Async Select
- Changed Stats Page User fetching to use Async Select and only fetch the User data when it needs
- Changed Record Filter to use Async Select
- Changed useTicketListener to only fetch needed data
- Added Sort/Projection to remove unnecessary data processing.
- Removed some unnecessary data processing.
2025-01-20 02:52:39 +00:00
Tiago Ribeiro
205449e1ae Merged in develop (pull request #136)
Develop
2025-01-13 22:44:25 +00:00
carlos.mesquita
4724e98993 Merged in feature/ExamGenRework (pull request #135)
ENCOA-312

Approved-by: Tiago Ribeiro
2025-01-13 22:41:51 +00:00
Carlos-Mesquita
6f9be29cd8 ENCOA-312 2025-01-13 21:02:34 +00:00
carlos.mesquita
d8fafa5cae Merged in feature/ExamGenRework (pull request #134)
ENCOA-311

Approved-by: Tiago Ribeiro
2025-01-13 08:08:19 +00:00
Carlos-Mesquita
ccbbf30058 ENCOA-311 2025-01-13 01:18:19 +00:00
Tiago Ribeiro
387418b9bd Merged in develop (pull request #133)
Develop
2025-01-06 21:32:31 +00:00
carlos.mesquita
715a841483 Merged in feature/ExamGenRework (pull request #132)
Removed a non sense entity ownership classroom name check I added a while back, patched the same entity + same name + same admin query

Approved-by: Tiago Ribeiro
2025-01-06 21:21:05 +00:00
Carlos-Mesquita
f6cd509aa4 Removed a non sense entity ownership classroom name check I added a while back, patched the same entity + same name + same admin query 2025-01-06 20:46:18 +00:00
carlos.mesquita
393b1a6be9 Merged in feature/ExamGenRework (pull request #131)
Feature/ExamGenRework

Approved-by: Tiago Ribeiro
2025-01-06 09:12:01 +00:00
Carlos-Mesquita
bc89f4b9ce ENCOA-310 2025-01-05 22:35:57 +00:00
Carlos-Mesquita
8f77f28aaa ENCOA-309 2025-01-05 21:03:52 +00:00
Carlos-Mesquita
61e07dae95 ENCOA-308 2025-01-05 19:04:08 +00:00
Carlos-Mesquita
0739e044a1 ENCOA-307 2025-01-05 17:46:11 +00:00
Tiago Ribeiro
7f91a92962 Merged in develop (pull request #130)
Develop
2024-12-31 16:44:27 +00:00
Tiago Ribeiro
af9f70880a Bugfix related to the listening audio 2024-12-31 15:47:26 +00:00
Tiago Ribeiro
26fa1691c4 Added the entity name to the classrooms list 2024-12-31 14:04:31 +00:00
Tiago Ribeiro
548163d66c Some improvements in payment record 2024-12-31 11:56:37 +00:00
Tiago Ribeiro
8ff0d16402 Merged in feature/ExamGenRework (pull request #129)
Feature/ExamGenRework
2024-12-30 19:05:18 +00:00
Tiago Ribeiro
4c746b93bc Merged with develop 2024-12-30 19:04:18 +00:00
Tiago Ribeiro
502cc64f99 Updated the exams to work based on entities 2024-12-30 18:48:27 +00:00
Tiago Ribeiro
f64b50df9e Updated part of the payment 2024-12-30 18:39:02 +00:00
Tiago Ribeiro
17154be8bf Updated the expiry date to be based on the expiry date 2024-12-30 15:36:20 +00:00
Tiago Ribeiro
b52259794e ENCOA-298 2024-12-30 11:11:44 +00:00
Carlos-Mesquita
bd9e249704 Unfinished grading attempt at solving it 2024-12-28 03:30:15 +00:00
Carlos-Mesquita
f642e41bfa ENCOA-295 2024-12-26 12:26:17 +00:00
Tiago Ribeiro
7b5d021bf3 Hotfix 2024-12-24 12:40:02 +00:00
Carlos-Mesquita
958f74bd9c Merge branch 'feature/ExamGenRework' of https://bitbucket.org/ecropdev/ielts-ui into feature/ExamGenRework 2024-12-24 11:53:39 +00:00
Carlos-Mesquita
bac2a08748 ENCOA-289 2024-12-24 11:52:34 +00:00
Tiago Ribeiro
770056e0c4 Improved part of the performance of the dashboards 2024-12-24 10:31:52 +00:00
Tiago Ribeiro
f8e9cfbeff Solved another bug 2024-12-23 16:34:01 +00:00
Tiago Ribeiro
408cfbb500 Bug solved with the Practice Modal 2024-12-23 15:11:20 +00:00
Tiago Ribeiro
2146f57941 Solved a bug 2024-12-23 15:09:35 +00:00
Tiago Ribeiro
e9c961e633 ENCOA-267 2024-12-23 10:18:52 +00:00
Tiago Ribeiro
9cf13e3f26 ENCOA-294 & ENCOA-293 2024-12-23 09:55:03 +00:00
Tiago Ribeiro
f1d97aa6c9 ENCOA-292 2024-12-23 09:38:49 +00:00
carlos.mesquita
d938535d9f Merged in feature/ExamGenRework (pull request #128)
ENCOA-283 or ENCOA-282, I don't know someone deleted the issue

Approved-by: Tiago Ribeiro
2024-12-23 09:37:30 +00:00
Tiago Ribeiro
319da200c6 Merged develop into feature/ExamGenRework 2024-12-23 09:37:16 +00:00
Tiago Ribeiro
345b784daf Merge branch 'develop' of bitbucket.org:ecropdev/ielts-ui into develop 2024-12-23 09:36:19 +00:00
Tiago Ribeiro
1b15a035df ENCOA-286 2024-12-23 09:35:40 +00:00
Carlos-Mesquita
8d7b47312e Merge branch 'feature/ExamGenRework' of https://bitbucket.org/ecropdev/ielts-ui into feature/ExamGenRework 2024-12-22 21:20:23 +00:00
Carlos-Mesquita
860f1295e5 ENCOA-283 or ENCOA-282, I don't know someone deleted the issue 2024-12-22 21:19:27 +00:00
carlos.mesquita
0cbdba1ab8 Merged in feature/ExamGenRework (pull request #127)
ENCOA-277, ENCOA-276, ENCOA-282, ENCOA-283

Approved-by: Tiago Ribeiro
2024-12-22 11:54:52 +00:00
carlos.mesquita
4ee3724196 Merged develop into feature/ExamGenRework 2024-12-21 19:26:10 +00:00
Carlos-Mesquita
98a1636d0c ENCOA-277, ENCOA-276, ENCOA-282, ENCOA-283 2024-12-21 19:23:53 +00:00
carlos.mesquita
020f65c566 Merged in feature/ExamGenRework (pull request #126)
Removed the exclusion warning when there is only duplicated users as there is already a warning message for that

Approved-by: Tiago Ribeiro
2024-12-16 15:30:10 +00:00
Carlos-Mesquita
f6d387ce2d Merge branch 'feature/ExamGenRework' of https://bitbucket.org/ecropdev/ielts-ui into feature/ExamGenRework 2024-12-16 14:58:59 +00:00
Carlos-Mesquita
d3d5e59aad Removed the exclusion warning when there is only duplicated users as there is already a warning message for that 2024-12-16 14:56:39 +00:00
Tiago Ribeiro
84f66f0bbb Added possibility for corporates and other to view the entity statistics 2024-12-15 19:01:08 +00:00
carlos.mesquita
8d0f98d186 Merged in feature/ExamGenRework (pull request #125)
ENCOA-274

Approved-by: Tiago Ribeiro
2024-12-14 12:25:32 +00:00
carlos.mesquita
ed9de74f28 Merged develop into feature/ExamGenRework 2024-12-13 21:31:10 +00:00
Carlos-Mesquita
bcf3cf0667 ENCOA-281 2024-12-13 21:29:54 +00:00
Tiago Ribeiro
f3057c675f ENCOA-275 2024-12-13 15:13:27 +00:00
Tiago Ribeiro
61d1bbbe13 ENCOA-268 2024-12-12 16:14:54 +00:00
Tiago Ribeiro
6bb817f9af Stuff with the new corporate 2024-12-12 15:31:24 +00:00
Tiago Ribeiro
3b6836c15a Groups stuff 2024-12-12 15:19:44 +00:00
Tiago Ribeiro
858e29eb93 ENCOA-261 2024-12-12 15:09:56 +00:00
Tiago Ribeiro
240e36f15a ENCOA-279 2024-12-12 15:06:00 +00:00
Tiago Ribeiro
3e74827c47 ENCOA-266 2024-12-12 12:28:57 +00:00
Tiago Ribeiro
578d29066f ENCOA-271 2024-12-12 09:42:21 +00:00
Tiago Ribeiro
1a7d35317b ENCOA-263 2024-12-11 22:00:43 +00:00
Tiago Ribeiro
ce35ba71f4 Improved a bit of the speed of the application 2024-12-11 18:32:29 +00:00
Tiago Ribeiro
76cbf8dc41 ENCOA-269 2024-12-11 17:42:57 +00:00
Tiago Ribeiro
71ed1013b7 ENCOA-270, ENCOA-265, ENCOA-262, ENCOA-258 2024-12-11 16:54:37 +00:00
Carlos-Mesquita
fa0c257040 Fixed a bug from the previous commit, ENCOA-274 2024-12-11 16:09:59 +00:00
carlos.mesquita
cf85b5a822 Merged develop into feature/ExamGenRework 2024-12-11 15:34:09 +00:00
Carlos-Mesquita
efba1939e5 ENCOA-274 2024-12-11 15:28:38 +00:00
Tiago Ribeiro
eabfcd026b ENCOA-273 2024-12-11 14:09:10 +00:00
Tiago Ribeiro
d074ec390c ENCOA-272 2024-12-11 11:58:52 +00:00
carlos.mesquita
6a6c4661c4 Merged in feature/ExamGenRework (pull request #124)
ENCOA-260, ENCOA-259

Approved-by: Tiago Ribeiro
2024-12-11 07:40:52 +00:00
Carlos-Mesquita
7538392e44 ENCOA-260, ENCOA-259 2024-12-09 18:37:51 +00:00
carlos.mesquita
8920eb8441 Merged in feature/ExamGenRework (pull request #123)
ENCOA-257 Fixed FillBlanks MC

Approved-by: Tiago Ribeiro
2024-12-05 21:17:00 +00:00
Carlos-Mesquita
35d28fbff6 Missed a .log 2024-12-05 18:40:59 +00:00
carlos.mesquita
19b6626b71 Merged develop into feature/ExamGenRework 2024-12-05 18:38:01 +00:00
Carlos-Mesquita
8bd8b61041 Merge branch 'feature/ExamGenRework' of https://bitbucket.org/ecropdev/ielts-ui into feature/ExamGenRework 2024-12-05 18:36:56 +00:00
Carlos-Mesquita
4f32e3cf93 ENCOA-257 Fixed FillBlanks MC 2024-12-05 18:36:04 +00:00
carlos.mesquita
70530b3f6c Merged in feature/ExamGenRework (pull request #122)
ENCOA-224, ENCOA-256: Added the import templates, Speaking didn't have the navbar yet, added multiple choice to reading since they've placed that in the import

Approved-by: Tiago Ribeiro
2024-12-04 09:16:53 +00:00
Tiago Ribeiro
7d08db28aa Merged develop into feature/ExamGenRework 2024-12-04 08:41:01 +00:00
Carlos-Mesquita
3b6fd2bc6b ENCOA-224, ENCOA-256: Added the import templates, Speaking didn't have the navbar yet, added multiple choice to reading since they've placed that in the import 2024-12-04 04:15:31 +00:00
carlos.mesquita
04b2cb3907 Merged in feature/ExamGenRework (pull request #121)
ENCOA-255 Fixed the level import

Approved-by: Tiago Ribeiro
2024-12-03 20:06:49 +00:00
Carlos-Mesquita
ec47d750bf Merge remote-tracking branch 'origin/develop' into feature/ExamGenRework 2024-12-03 12:02:11 +00:00
Carlos-Mesquita
98ed842932 Merge branch 'feature/ExamGenRework' of https://bitbucket.org/ecropdev/ielts-ui into feature/ExamGenRework 2024-12-03 12:01:10 +00:00
Carlos-Mesquita
5252faafb7 ENCOA-255 Fixed the level import 2024-12-03 12:00:04 +00:00
Tiago Ribeiro
7801a7d05a Merge branch 'develop' of bitbucket.org:ecropdev/ielts-ui into develop 2024-12-03 11:01:03 +00:00
Tiago Ribeiro
a36be67c8b Forgot a permission on the role page 2024-12-03 11:00:20 +00:00
carlos.mesquita
1b97f86a37 Merged in feature/ExamGenRework (pull request #120)
ENCOA-253, ENCOA-248, ENCOA-246

Approved-by: Tiago Ribeiro
2024-12-03 08:36:10 +00:00
carlos.mesquita
444fb15c29 Merged develop into feature/ExamGenRework 2024-12-02 17:23:12 +00:00
Carlos-Mesquita
490c5ad7d3 ENCOA-253, ENCOA-248, ENCOA-246 2024-12-02 17:16:12 +00:00
Tiago Ribeiro
b6f61c6be1 More permissions 2024-12-02 17:12:23 +00:00
Tiago Ribeiro
2e5545f181 Some quick changes to permissions 2024-12-02 17:09:22 +00:00
Tiago Ribeiro
cd14ac537d ENCOA-245 & ENCOA-240 2024-12-02 10:45:12 +00:00
Tiago Ribeiro
24d613e9cd ENCOA-249 2024-12-02 10:00:38 +00:00
Tiago Ribeiro
1f1c2b4aaf ENCOA-251 2024-12-02 09:22:17 +00:00
Tiago Ribeiro
0faa908538 ENCOA-239: Bug: Corporate after viewing assignment the dashboard button forwards the account to the wrong view 2024-11-29 16:20:05 +00:00
Tiago Ribeiro
5cfd2c90b7 ENCOA-242: Bug: Super Admin can't create accounts in the Settings 2024-11-29 16:14:21 +00:00
carlos.mesquita
8efaa67574 Merged in feature/ExamGenRework (pull request #119)
Speaking endpoints and polling fixed

Approved-by: Tiago Ribeiro
2024-11-27 08:25:39 +00:00
Carlos-Mesquita
c90bc271d3 Missed a console.log 2024-11-27 08:12:03 +00:00
Carlos-Mesquita
09e97a28a5 Merge branch 'feature/ExamGenRework' of https://bitbucket.org/ecropdev/ielts-ui into feature/ExamGenRework 2024-11-27 08:08:50 +00:00
Carlos-Mesquita
a96d4c6e52 Speaking endpoints and polling fixed 2024-11-27 08:04:18 +00:00
carlos.mesquita
432c6ed6fd Merged in feature/ExamGenRework (pull request #118)
Exam Edit on ExamList

Approved-by: Tiago Ribeiro
2024-11-27 07:36:07 +00:00
Tiago Ribeiro
942b2c853c Merged develop into feature/ExamGenRework 2024-11-27 07:35:37 +00:00
Carlos-Mesquita
a2a513077f Exam Edit on ExamList 2024-11-27 02:01:50 +00:00
carlos.mesquita
a0e79f7a5c Merged in feature/ExamGenRework (pull request #117)
Wrong answeredEveryQuestion

Approved-by: Tiago Ribeiro
2024-11-26 23:54:52 +00:00
Carlos-Mesquita
ca5977e78b Wrong answeredEveryQuestion 2024-11-26 23:51:47 +00:00
carlos.mesquita
40d049ad36 Merged in feature/ExamGenRework (pull request #116)
A slash

Approved-by: Tiago Ribeiro
2024-11-26 22:37:30 +00:00
Carlos-Mesquita
dacab73265 Merge branch 'feature/ExamGenRework' of https://bitbucket.org/ecropdev/ielts-ui into feature/ExamGenRework 2024-11-26 22:35:29 +00:00
Carlos-Mesquita
af5ae2a687 A slash 2024-11-26 22:34:25 +00:00
carlos.mesquita
44da859d30 Merged in feature/ExamGenRework (pull request #115)
More modal patches and a bug in level that I'm still trying to solve

Approved-by: Tiago Ribeiro
2024-11-26 17:25:22 +00:00
Tiago Ribeiro
6a1bc92270 Merged develop into feature/ExamGenRework 2024-11-26 17:25:08 +00:00
Carlos-Mesquita
48d3cfe5f8 Removed the used words check from fillBlanks since allow repetition wasn't implemented and messes up imports 2024-11-26 17:19:01 +00:00
Carlos-Mesquita
4e94773861 More modal patches and a bug in level that I'm still trying to solve 2024-11-26 16:47:26 +00:00
carlos.mesquita
f2941e2ba3 Merged in feature/ExamGenRework (pull request #114)
More navigation bugs and fixed broken modal during transition

Approved-by: Tiago Ribeiro
2024-11-26 15:54:46 +00:00
Carlos-Mesquita
67909c1d7c Merge branch 'feature/ExamGenRework' of https://bitbucket.org/ecropdev/ielts-ui into feature/ExamGenRework 2024-11-26 15:47:17 +00:00
Carlos-Mesquita
efb153a33d More navigation bugs and fixed broken modal during transition 2024-11-26 15:46:20 +00:00
Tiago Ribeiro
2e96508205 Solved more merge conflicts 2024-11-26 15:42:55 +00:00
Tiago Ribeiro
61b6da749e Updated the packages 2024-11-26 15:37:23 +00:00
Tiago Ribeiro
c6e8d3527d Solved merge conflicts 2024-11-26 15:33:12 +00:00
Tiago Ribeiro
9faf82ee9c Fixed a typo in the records 2024-11-26 15:04:24 +00:00
Carlos-Mesquita
1fc439cb25 Fixed some navigation issues and updated Listening 2024-11-26 14:33:49 +00:00
Carlos-Mesquita
de08164dd8 Merge, do not push to develop yet, Listening.tsx is not updated 2024-11-26 10:33:02 +00:00
Carlos-Mesquita
2ed4e6509e Updated the eval calls to the backend, passed the navigation logic of level to useExamNavigation hook 2024-11-26 09:04:38 +00:00
Tiago Ribeiro
39ff336af5 Rolled back the Speaking avatars 2024-11-25 18:12:36 +00:00
Carlos-Mesquita
bb5326a331 Forgot to stage post merge change 2024-11-25 16:55:24 +00:00
Carlos-Mesquita
a1501d6c23 Merge branch 'feature/ExamGenRework' of https://bitbucket.org/ecropdev/ielts-ui into feature/ExamGenRework 2024-11-25 16:54:24 +00:00
Carlos-Mesquita
114da173be Navigation rework, added prompt edit to components that were missing 2024-11-25 16:50:46 +00:00
Tiago Ribeiro
bce3a25dc2 Added new permission types 2024-11-25 11:22:58 +00:00
Tiago Ribeiro
47762544fc Fixed a problem where users were being redirected to the dashboard 2024-11-25 10:33:37 +00:00
Tiago Ribeiro
55a03b283f Updated the Finish screen to also show the practice score 2024-11-25 10:32:15 +00:00
Tiago Ribeiro
e5087d4d58 Updated the look of the Entities and Classrooms selectors 2024-11-25 10:15:16 +00:00
Tiago Ribeiro
2160a42964 Corrected a problem related to the permission toggling 2024-11-25 09:47:17 +00:00
Tiago Ribeiro
593d349617 Updated the entities roles to disallow users from updating their own role 2024-11-23 18:57:05 +00:00
Tiago Ribeiro
a6bd3a9f3b Added new columns to the statistical page 2024-11-23 12:18:39 +00:00
Tiago Ribeiro
f6fc701fb7 Added the statistical page as a permission based page 2024-11-22 17:20:09 +00:00
Tiago Ribeiro
50bbb0dacf Updated the grading system to work based on entities 2024-11-22 15:36:21 +00:00
Tiago Ribeiro
f301001ebe Revamped the statistical page to work with the new entity system, along with some other improvements to it 2024-11-21 15:37:53 +00:00
Tiago Ribeiro
0eed8e4612 Corrected the behaviour of the exam after the timer has ended 2024-11-20 10:26:59 +00:00
Tiago Ribeiro
e6d77af53f Updated the score to be reflective of the ungraded questions 2024-11-19 16:21:08 +00:00
Tiago Ribeiro
bb24fe3c6d Quick fix related to the back button of the Listening Exam 2024-11-18 15:28:51 +00:00
Tiago Ribeiro
4f60819dcc Updated the PracticeBadge 2024-11-16 18:19:11 +00:00
Tiago Ribeiro
501606233f Created a simple Practice Badge to showcase when an exercise is a practice exercise 2024-11-16 12:03:08 +00:00
Tiago Ribeiro
d564d86feb Updated the Assignment View to ignore practice questions 2024-11-15 16:02:43 +00:00
carlos.mesquita
c067e26e0c Merged in feature/ExamGenRework (pull request #112)
Added listening import, part divider was showing between questions because it was with exerciseIndex instead of partIndex, fixed a reorder bug when deleting questions

Approved-by: Tiago Ribeiro
2024-11-15 11:21:40 +00:00
carlos.mesquita
e40f880342 Merged develop into feature/ExamGenRework 2024-11-15 10:54:05 +00:00
Carlos-Mesquita
e9b7bd14cc Added listening import, part divider was showing between questions because it was with exerciseIndex instead of partIndex, fixed a reorder bug when deleting questions 2024-11-15 02:51:27 +00:00
carlos.mesquita
2e5c4295dc Merged in feature/ExamGenRework (pull request #111)
Added Speaking to level, fixed a bug where it was causing level to crash if the listening was already created and the section was switched, added true false exercises to listening
2024-11-14 11:12:01 +00:00
Tiago Ribeiro
ba71995ace Redirect to dashboard instead of /exam 2024-11-14 08:41:16 +00:00
Carlos-Mesquita
a18bdfcef6 Merge remote-tracking branch 'origin/develop' into feature/ExamGenRework 2024-11-13 20:38:22 +00:00
Carlos-Mesquita
07e03c8981 Merge branch 'feature/ExamGenRework' of https://bitbucket.org/ecropdev/ielts-ui into feature/ExamGenRework 2024-11-13 20:34:10 +00:00
Carlos-Mesquita
8cb09e349f Added Speaking to level, fixed a bug where it was causing level to crash if the listening was already created and the section was switched, added true false exercises to listening 2024-11-13 20:32:59 +00:00
Tiago Ribeiro
70a461bc69 Continuation of the bug solving 2024-11-13 09:56:08 +00:00
Tiago Ribeiro
6aa5385939 Merge branch 'develop' of bitbucket.org:ecropdev/ielts-ui into develop 2024-11-13 09:13:04 +00:00
Tiago Ribeiro
49749bfbe9 Solved a bug related to the partial exams 2024-11-13 09:12:30 +00:00
carlos.mesquita
69d0b2bdf4 Merged in feature/ExamGenRework (pull request #110)
isPractice hotfix

Approved-by: Tiago Ribeiro
2024-11-12 18:14:31 +00:00
carlos.mesquita
9000ca9001 Merged develop into feature/ExamGenRework 2024-11-12 17:54:43 +00:00
Carlos-Mesquita
153d7f5448 isPractice hotfix 2024-11-12 17:53:16 +00:00
carlos.mesquita
6f5f53d564 Merged in feature/ExamGenRework (pull request #109)
Feature/ExamGenRework

Approved-by: Tiago Ribeiro
2024-11-12 17:00:12 +00:00
Carlos-Mesquita
311036fe86 Only hooked up the section state had forgot to plugin into Header's isEvaluationEnabled 2024-11-12 15:10:23 +00:00
Carlos-Mesquita
49c63544a1 Wasn't staged :/ 2024-11-12 14:39:30 +00:00
Carlos-Mesquita
36c3bb4392 Commented Speaking from level for the time being 2024-11-12 14:34:40 +00:00
Carlos-Mesquita
1cc068db3e Merge remote-tracking branch 'origin/develop' into feature/ExamGenRework 2024-11-12 14:28:51 +00:00
Carlos-Mesquita
fdf411d133 ENCOA-228 Now when user navigates between modules the generation items persist. Reading, listening and writing added to level module 2024-11-12 14:17:54 +00:00
Tiago Ribeiro
b2dc9b2e31 ENCOA-233: Added the option for certain exercises to not count towards scores 2024-11-12 11:03:19 +00:00
Tiago Ribeiro
1787e3ed53 ENCOA-222 & ENCOA-223
ENCOA-222: Added an option for non-assignment exams to view the
transcript of a Listening audio;

ENCOA-223: Updated the Listening exam to show all of the
exercises/questions of each part on a single page;
2024-11-11 19:14:16 +00:00
Tiago Ribeiro
711a0743c2 ENCOA-234: Changed the login page when a user is going into the /official-exam 2024-11-11 10:26:24 +00:00
carlos.mesquita
0a3a00cd3f Merged in feature/ExamGenRework (pull request #108)
Feature/ExamGenRework

Approved-by: Tiago Ribeiro
2024-11-10 10:47:17 +00:00
Carlos-Mesquita
696c968ebc Merge remote-tracking branch 'origin/develop' into feature/ExamGenRework 2024-11-10 07:09:25 +00:00
Carlos-Mesquita
af21ff2f20 Merge remote-tracking branch 'origin/develop' into feature/ExamGenRework 2024-11-10 06:56:26 +00:00
Carlos-Mesquita
4219341951 Leftovers from the previous commit 2024-11-10 06:19:19 +00:00
Carlos-Mesquita
322d7905c3 Reverted Level to only utas placement test exercises, Speaking, bug fixes, placeholder 2024-11-10 04:24:23 +00:00
Tiago Ribeiro
a010a630ac Removed a console.log 2024-11-10 00:18:47 +00:00
Tiago Ribeiro
7ac15fc767 Removed the autoStartDate and replaced it with the current startDate 2024-11-10 00:13:09 +00:00
Tiago Ribeiro
042b07c267 Removed redirection from the official-exam to records 2024-11-09 18:11:18 +00:00
Carlos-Mesquita
c507eae507 Forgot to stage it 2024-11-09 06:53:17 +00:00
Tiago Ribeiro
f0a97d42a4 Updated the generation page to now work with the entity permission system 2024-11-08 09:44:28 +00:00
Tiago Ribeiro
065497dfa3 Updated the code to return to official-exam if they came from that page 2024-11-07 22:51:45 +00:00
Tiago Ribeiro
7ae91d7bc1 Updated some troubles related to the Level Exam 2024-11-07 14:55:44 +00:00
Tiago Ribeiro
78cf011bf7 Solved a problem with the assignees stuff related to the query 2024-11-07 12:51:14 +00:00
Tiago Ribeiro
fae7b729fe Updated the assignments active filter to work with the startDate 2024-11-07 12:08:28 +00:00
Tiago Ribeiro
0325bb68f5 Added the audio player to the level exam 2024-11-07 11:17:05 +00:00
Carlos-Mesquita
50481a836e Now only when the user submits the listening exam are the mp3 are uploaded onto firebase bucket 2024-11-07 11:06:33 +00:00
Tiago Ribeiro
bead6d98ae Updated the official exam to hide the sidebar during an assignment 2024-11-07 09:53:08 +00:00
Tiago Ribeiro
55737781bc Quick update to the official exam page 2024-11-06 23:33:51 +00:00
carlos.mesquita
4ce208281d Merged in feature/ExamGenRework (pull request #107)
Feature/ExamGenRework

Approved-by: Tiago Ribeiro
2024-11-06 22:40:48 +00:00
Tiago Ribeiro
0741c4c647 Merged develop into feature/ExamGenRework 2024-11-06 22:40:34 +00:00
Carlos-Mesquita
dc8f00c318 When merging forgot to place entities as optional and = [] 2024-11-06 20:22:21 +00:00
Carlos-Mesquita
7045b4e3c7 Ts changes weren't staged, it compiles still doesnt build building 2024-11-06 19:49:02 +00:00
Carlos-Mesquita
a371b171bb Merge branch 'feature/ExamGenRework' of https://bitbucket.org/ecropdev/ielts-ui into feature/ExamGenRework 2024-11-06 19:44:58 +00:00
Carlos-Mesquita
5165b6ae6d Listening Convo Edit and a bunch of ts errors 2024-11-06 19:43:06 +00:00
carlos.mesquita
817100b0e4 Merged in feature/ExamGenRework (pull request #106)
Feature/ExamGenRework

Approved-by: Tiago Ribeiro
2024-11-06 15:48:05 +00:00
Tiago Ribeiro
7de860ad44 Merged develop into feature/ExamGenRework 2024-11-06 14:53:02 +00:00
Tiago Ribeiro
27b72c162b Created a page the client wanted to start and resume assignments as a student 2024-11-06 11:42:35 +00:00
Carlos-Mesquita
b5ac908d09 Merge with develop 2024-11-06 10:59:26 +00:00
Carlos-Mesquita
b50913eda8 Listening preview and some more patches 2024-11-06 09:23:34 +00:00
Carlos-Mesquita
ffa2045a2d Blanks Exercises were removing the blanks on the editor and text but not on solutions, added submit and preview to writing and reading 2024-11-05 17:53:55 +00:00
Tiago Ribeiro
f686985c6e ENCOA-220: Assignment exams were being merged 2024-11-05 11:43:49 +00:00
Tiago Ribeiro
e8b56485ee ENCOA-217: Adapted the invite system to now work based on Entities instead of Users/Groups 2024-11-05 10:40:33 +00:00
Tiago Ribeiro
df41611093 ENCOA-219: Hide System exams from non-admin users 2024-11-05 09:50:13 +00:00
Tiago Ribeiro
54c5b2063b ENCOA-216: Added a button to remove inactive assignees 2024-11-05 09:43:26 +00:00
Tiago Ribeiro
225640ad8a Some more changes I guess 2024-11-04 23:48:49 +00:00
Tiago Ribeiro
7dacc69321 Removed some verification 2024-11-04 23:46:52 +00:00
Tiago Ribeiro
e6f9d9b79a Sidebar generation page 2024-11-04 23:46:18 +00:00
Tiago Ribeiro
b5a40ea220 Tiny improvements 2024-11-04 23:36:29 +00:00
Carlos-Mesquita
15c9c4d4bd Exam generation rework, batch user tables, fastapi endpoint switch 2024-11-04 23:29:14 +00:00
Tiago Ribeiro
774f5e72c0 Quick fix to solve a problem related to timezone regarding the assignments 2024-11-04 16:30:13 +00:00
Tiago Ribeiro
a502b2b863 Added a button to enable complete exams 2024-11-04 10:27:50 +00:00
Tiago Ribeiro
dc4694eb17 Made it so for planned assignments, if the user is allowed to view but not edit, they can view it 2024-11-01 16:44:36 +00:00
Tiago Ribeiro
ce353f34c7 I think I solved a bit of the time ended 2024-11-01 10:38:10 +00:00
Tiago Ribeiro
a2bc997e8f Tiny change because of private exams 2024-10-31 16:01:50 +00:00
Tiago Ribeiro
f29daa0d94 Improved part of the assignments pages 2024-10-31 10:42:35 +00:00
Tiago Ribeiro
28c5d13682 Some quick hotfixes 2024-10-31 10:34:37 +00:00
Tiago Ribeiro
35ca933339 Updated the batch user creation to work without corporate 2024-10-29 11:09:07 +00:00
Tiago Ribeiro
dd94f245eb Updated the assignment creation e-mail to send the URL for the assignment directly 2024-10-29 10:26:18 +00:00
Tiago Ribeiro
ef857fee59 Continued with clearing more of the team's requests 2024-10-28 15:35:57 +00:00
Tiago Ribeiro
fa0c502467 Cleared of the stuff the EnCoach team wanted changed 2024-10-28 14:40:26 +00:00
Tiago Ribeiro
0becd295b0 Fixed the thing with the entity users 2024-10-18 17:49:03 +01:00
Tiago Ribeiro
184a5fd820 Added the entity to the classroom 2024-10-18 14:33:08 +01:00
Tiago Ribeiro
70a027f85b Quick fix 2024-10-18 14:24:47 +01:00
Tiago Ribeiro
87d7d6f12b Added more filters to the classroom 2024-10-17 22:41:20 +01:00
Tiago Ribeiro
4917583c67 Created a system to go directly to an assignment from a URL 2024-10-17 18:24:39 +01:00
Tiago Ribeiro
a0a9402945 Updated entities 2024-10-17 16:18:50 +01:00
Tiago Ribeiro
22b8aed127 Continued with the entities for the batch users 2024-10-17 12:02:35 +01:00
Tiago Ribeiro
532b49165c Fixed a problem with the update of a user 2024-10-17 08:45:18 +01:00
Tiago Ribeiro
1fb7343aa7 Solved some issues with the redirect as well as adding a way to create entities 2024-10-16 10:23:59 +01:00
Tiago Ribeiro
de31f77181 Merge branch 'group-entity-permissions-revamping' into develop 2024-10-11 10:50:04 +01:00
Tiago Ribeiro
a53ee79c0a Continued creating the permission system 2024-10-11 10:47:35 +01:00
Tiago Ribeiro
55204e2ce1 Started implementing the roles permissions 2024-10-10 19:13:18 +01:00
Tiago Ribeiro
c43ab9a911 Continued with the transformation of the Entities 2024-10-08 10:44:57 +01:00
Tiago Ribeiro
1ef4efcacf Continued updating the code to work with entities better 2024-10-07 15:49:58 +01:00
Tiago Ribeiro
b5200c88fc Updated the dashboard to the v2 version 2024-10-03 11:32:43 +01:00
Tiago Ribeiro
3d4a604aa2 Started working on the assignments page 2024-10-02 19:20:05 +01:00
Tiago Ribeiro
9f1f564e25 Merged in develop (pull request #105)
Develop
2024-10-01 16:52:32 +00:00
carlos.mesquita
8adacc51a6 Merged in bugfix/mongo-migration-bugs (pull request #104)
Added back the demographic view

Approved-by: Tiago Ribeiro
2024-10-01 16:52:00 +00:00
Tiago Ribeiro
564e6438cb Continued creating the entity system 2024-10-01 17:39:43 +01:00
Carlos-Mesquita
3c1c4489f8 Updated user had only demographicInformation and was causing the diagnostic view to not show up after submitting the info 2024-10-01 17:11:04 +01:00
Carlos-Mesquita
044ec8d966 Added back the demographic view 2024-10-01 15:34:56 +01:00
Tiago Ribeiro
bae02e5192 Improved the data refresh 2024-09-25 16:23:14 +01:00
Tiago Ribeiro
dd94228672 Created a new system for the Groups that will persist after having entities 2024-09-25 16:18:43 +01:00
carlos.mesquita
8c392f8b49 Merged in bugfix/mongo-migration-bugs (pull request #103)
Bugfix/mongo migration bugs

Approved-by: Tiago Ribeiro
2024-09-23 07:40:57 +00:00
Carlos Mesquita
07c9074d15 More bugs, some updates where not using set 2024-09-23 01:09:34 +01:00
Carlos Mesquita
71bac76c3a More mongodb migration bugs, remove _id from find, and a stat endpoint firebase leftover bug 2024-09-23 00:04:06 +01:00
carlos.mesquita
fb293dc98c Merged main into bugfix/mongo-migration-bugs 2024-09-22 22:47:23 +00:00
Tiago Ribeiro
3ce97b4dcd Removed sorting in exchange for filtering 2024-09-11 16:33:05 +01:00
Tiago Ribeiro
7bfd000213 Solved a problem where it was only getting the first 25 students 2024-09-11 11:29:48 +01:00
Tiago Ribeiro
2a10933206 Solved some problems with the excel of master statistical 2024-09-10 12:00:14 +01:00
Tiago Ribeiro
33a46c227b Solved a bug for the master statistical 2024-09-10 10:55:02 +01:00
Tiago Ribeiro
5153c3d5f1 Merge branch 'main' into develop 2024-09-09 17:52:04 +01:00
Tiago Ribeiro
85c8f622ee Added Student ID to the Master Statistical 2024-09-09 14:43:05 +01:00
Tiago Ribeiro
b9c097d42c Had a bug in pagination 2024-09-09 09:07:30 +01:00
Tiago Ribeiro
192132559b Solved a problem with the download of the excel 2024-09-09 08:19:32 +01:00
Tiago Ribeiro
6d1e8a9788 Had some errors on updating groups 2024-09-09 00:06:51 +01:00
Tiago Ribeiro
1c61d50a5c Improved some of the querying for the assignments 2024-09-09 00:02:34 +01:00
Tiago Ribeiro
9f0ba418e5 Added filtering and pagination for the assignment creator 2024-09-08 23:24:27 +01:00
Tiago Ribeiro
6fd2e64e04 Merge branch 'main' of bitbucket.org:ecropdev/ielts-ui 2024-09-08 23:09:25 +01:00
carlos.mesquita
2c01e6b460 Merged in feature/training-content (pull request #101)
Feature/training content

Approved-by: Tiago Ribeiro
2024-09-08 22:08:32 +00:00
Tiago Ribeiro
6e0c4d4361 Added search per exam 2024-09-08 23:07:47 +01:00
Tiago Ribeiro
745eef981f Added some more pagination 2024-09-08 23:02:48 +01:00
carlos.mesquita
7a33f42bcd Merged main into feature/training-content 2024-09-08 21:49:22 +00:00
Carlos Mesquita
02564c8426 Had the type hardcoded 2024-09-08 22:47:43 +01:00
Tiago Ribeiro
eab6ab03b7 Not shown when not completed 2024-09-08 22:46:09 +01:00
Carlos Mesquita
6f534662e1 Shuffles bugfix 2024-09-08 22:45:24 +01:00
Tiago Ribeiro
fbc7abdabb Solved a bug 2024-09-08 22:35:14 +01:00
Tiago Ribeiro
392eac2ef9 Merge branch 'main' into develop 2024-09-08 19:57:57 +01:00
Tiago Ribeiro
b7349b5df8 Tried to solve some more issues with counts 2024-09-08 19:56:44 +01:00
Tiago Ribeiro
298901a642 Updated the UserList to show the corporates 2024-09-08 19:50:59 +01:00
Tiago Ribeiro
88eafafe12 Stopped sessions from being cached 2024-09-08 19:36:04 +01:00
Tiago Ribeiro
31a01a3157 Allow admins create other admins 2024-09-08 19:27:12 +01:00
Tiago Ribeiro
a5b3a7e94d Solved a problem with the _id 2024-09-08 19:25:14 +01:00
Tiago Ribeiro
49e8237e99 A bit of error handling I guess 2024-09-08 18:54:44 +01:00
Tiago Ribeiro
d5769c2cb9 Updated more of the page 2024-09-08 18:39:52 +01:00
Tiago Ribeiro
e49a325074 Had a small error on the groups 2024-09-08 18:00:47 +01:00
Tiago Ribeiro
e6528392a2 Changed the totals of the admin pretty much 2024-09-08 16:06:54 +01:00
Joao Ramos
a073ca1cce Temporarily disabled Play Again button 2024-09-08 13:56:08 +01:00
Tiago Ribeiro
620e4dd787 Solved some problems, bypassed some stuff 2024-09-08 11:35:09 +01:00
João Ramos
7e30ca5750 Merged in bug-fixing-8-sep-24_2 (pull request #100)
Bug fixing 8 sep 24 2

Approved-by: Tiago Ribeiro
2024-09-08 09:22:05 +00:00
João Ramos
e3847baadb Merged in bug-fixing-8-sep-24 (pull request #99)
Bug fixing 8 sep 24

Approved-by: Tiago Ribeiro
2024-09-08 09:21:53 +00:00
Joao Ramos
2e065eddcb Added an workaround for the broken email system 2024-09-08 10:19:49 +01:00
Joao Ramos
4e91b2f1fb ENCOA-202: Fixed issue updating user 2024-09-08 10:08:24 +01:00
Joao Ramos
5b8631ab6a Readded master statistical to corporate 2024-09-08 01:30:12 +01:00
Joao Ramos
f9f29eabb3 Fixed an issue after merging 2024-09-08 01:23:56 +01:00
Tiago Ribeiro
898edb152f Moved the Dockerfile env 2024-09-07 23:59:58 +01:00
Tiago Ribeiro
bf0d696b2f Some hard coding at least for now 2024-09-07 23:51:04 +01:00
Tiago Ribeiro
d91b1c14e7 Removed an error 2024-09-07 23:41:01 +01:00
Tiago Ribeiro
cdd42b2f07 Merge branch 'main' into migration-mongodb 2024-09-07 23:09:37 +01:00
João Ramos
34bc9df9ea Merged in ENCOA-200_StatisticalCorporate (pull request #97)
ENCOA-200: Added Master Statistical to Corporate

Approved-by: Tiago Ribeiro
2024-09-07 21:57:22 +00:00
Tiago Ribeiro
15cc7c8cc9 Improved a bit of the pagination 2024-09-07 22:48:52 +01:00
Carlos Mesquita
b4ab620c78 lint warnings 2024-09-07 22:42:22 +01:00
Carlos Mesquita
6e4ef249b8 Finished refactoring 2024-09-07 22:39:14 +01:00
Carlos Mesquita
c2b4bb29d6 Refactored /api/users 2024-09-07 21:50:29 +01:00
Carlos Mesquita
cab469007b Refactored /api/tickets /api/training 2024-09-07 21:25:51 +01:00
Carlos Mesquita
d6782bd86e Refactored /api/paypal, /api/permissions, /api/reset /api/sessions, /api/stats 2024-09-07 20:43:55 +01:00
Tiago Ribeiro
6251f8f4db Continued migrating more and more files 2024-09-07 18:29:20 +01:00
Joao Ramos
fb9d11f38d ENCOA-200: Added Master Statistical to Corporate 2024-09-07 18:19:25 +01:00
Carlos Mesquita
bb8dca69cf Merge conflict 2024-09-07 18:09:14 +01:00
Carlos Mesquita
53b31b306d /api/packages refactored, forgot to commit some changes 2024-09-07 18:08:03 +01:00
Tiago Ribeiro
d173cdb02a Updated the Grading System 2024-09-07 18:00:05 +01:00
Tiago Ribeiro
07f0ea25bb Merge branch 'migration-mongodb' of bitbucket.org:ecropdev/ielts-ui into migration-mongodb 2024-09-07 17:55:33 +01:00
Carlos Mesquita
e7ee55d608 Merge branch 'migration-mongodb' of https://bitbucket.org/ecropdev/ielts-ui into migration-mongodb 2024-09-07 17:54:19 +01:00
Carlos Mesquita
7fa4edf37d /api/groups and /api/invites refactored, fixed some inserts/updates in which I didn't include the id 2024-09-07 17:54:10 +01:00
Tiago Ribeiro
49022394b0 Did the same treatment to Corporate and Teacher dashboards 2024-09-07 17:53:16 +01:00
Tiago Ribeiro
3be0d158e3 Improved the performance of the MasterCorporate 2024-09-07 17:34:41 +01:00
Tiago Ribeiro
56f374bbfe Updated the pagination on the useUsers and migrading the grading 2024-09-07 16:53:58 +01:00
Carlos Mesquita
417c9176fe /api/exam refactor 2024-09-07 16:39:07 +01:00
Carlos Mesquita
e3400e8564 /api/evaluate refactor 2024-09-07 16:17:24 +01:00
Carlos Mesquita
d680905a87 Merge branch 'migration-mongodb' of https://bitbucket.org/ecropdev/ielts-ui into migration-mongodb 2024-09-07 16:03:32 +01:00
Carlos Mesquita
c07e3f86fb Refactored discounts and replaced my previous commit id queries to use id not _id 2024-09-07 16:03:26 +01:00
Tiago Ribeiro
238a25aaeb Updated the make_user to use MongoDB 2024-09-07 16:01:03 +01:00
Carlos Mesquita
171231cd21 Refactored codes 2024-09-07 15:41:09 +01:00
Carlos Mesquita
6ed342bb6f Merge branch 'migration-mongodb' of https://bitbucket.org/ecropdev/ielts-ui into migration-mongodb 2024-09-07 15:15:17 +01:00
Carlos Mesquita
6f7ef1abef Refactored pages/api/assignments to mongodb 2024-09-07 15:13:41 +01:00
Tiago Ribeiro
e33fa00fa3 Updated the groups and users 2024-09-07 15:13:13 +01:00
Tiago Ribeiro
c0b814081e Updated the register endpoint to use MongoDB 2024-09-07 13:58:52 +01:00
Tiago Ribeiro
e8b7c5ff80 Started migrating the DB to MongoDB 2024-09-07 13:37:08 +01:00
Tiago Ribeiro
8c94bcac52 Corrected a problem with too many participants 2024-09-07 13:02:47 +01:00
carlos.mesquita
8803a8c166 Merged in feature/training-content (pull request #95)
ENCOA-69: Training update, most of the styles in the old tips were standardized, before all the styles were hardcoded into the tip
2024-09-07 11:42:06 +00:00
João Ramos
2f63fd196b Merged in ENCOA-183_MasterCorporateGrouping (pull request #96)
ENCOA-183 MasterCorporateGrouping

Approved-by: Tiago Ribeiro
2024-09-07 11:41:29 +00:00
Joao Ramos
42471170ce Improved grouping for master statistical 2024-09-07 12:38:28 +01:00
Carlos Mesquita
2bf9afca9c Merge remote-tracking branch 'origin/develop' into feature/training-content 2024-09-07 11:38:18 +01:00
Carlos Mesquita
9c41ddee60 Training update, most of the styles in the old tips were standardized, before all the styles were hardcoded into the tip, the new tips may still have some hardcoded styles but the vast majority only uses standard html or custom ones that are picked up in FormatTip to attribute styles 2024-09-07 11:37:04 +01:00
Joao Ramos
9993c7a8a7 ENCOA-183: Initial test changes for corporates grouped by name 2024-09-07 11:04:00 +01:00
João Ramos
a22c9d102f Merged in ENCOA-131_MasterStatistical (pull request #94)
Added level part display on excel and a pseudo sorting

Approved-by: Tiago Ribeiro
2024-09-07 08:13:29 +00:00
485 changed files with 47486 additions and 45033 deletions

View File

@@ -23,6 +23,8 @@ COPY . .
# 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

20278
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,113 +1,116 @@
{
"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/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",
"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",
"iron-session": "^6.3.1",
"lodash": "^4.17.21",
"moment": "^2.29.4",
"moment-timezone": "^0.5.44",
"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/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"
}
"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",
"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/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"
}
}

BIN
public/blue-stock-photo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 419 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 418 KiB

View File

@@ -1,5 +1,5 @@
import {Dialog, Transition} from "@headlessui/react";
import {Fragment} from "react";
import { Dialog, Transition } from "@headlessui/react";
import { Fragment, useCallback, useEffect, useState } from "react";
import Button from "./Low/Button";
interface Props {
@@ -11,10 +11,54 @@ interface Props {
onCancel: () => void;
}
export default function AbandonPopup({isOpen, abandonPopupTitle, abandonPopupDescription, abandonConfirmButtonText, onAbandon, onCancel}: Props) {
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}>
<Dialog onClose={onCancel} className="relative z-50">
<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"
@@ -39,10 +83,10 @@ export default function AbandonPopup({isOpen, abandonPopupTitle, abandonPopupDes
<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={onCancel} variant="outline" className="max-w-[200px] self-end w-full">
<Button color="purple" onClick={() => blockMultipleClicksClose(true)} variant="outline" className="max-w-[200px] self-end w-full">
Cancel
</Button>
<Button color="purple" onClick={onAbandon} className="max-w-[200px] self-end w-full">
<Button color="purple" onClick={() => blockMultipleClicksClose(false)} className="max-w-[200px] self-end w-full">
{abandonConfirmButtonText}
</Button>
</div>

View File

@@ -1,18 +1,19 @@
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 { 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 { 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[];
@@ -22,6 +23,7 @@ interface Props {
allowArchive?: boolean;
allowUnarchive?: boolean;
allowExcelDownload?: boolean;
entityObj?: EntityWithRoles
}
export default function AssignmentCard({
@@ -30,6 +32,7 @@ export default function AssignmentCard({
assigner,
startDate,
endDate,
entityObj,
assignees,
results,
exams,
@@ -49,7 +52,6 @@ export default function AssignmentCard({
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);
@@ -65,26 +67,26 @@ export default function AssignmentCard({
const uniqModules = uniqBy(exams, (x) => x.module);
const shouldRenderPDF = () => {
if(released && allowDownload) {
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 uniqModules.every(({ module }) => module !== "level");
}
return false;
}
};
const shouldRenderExcel = () => {
if(released && allowExcelDownload) {
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 uniqModules.some(({ module }) => module === "level");
}
return false;
}
};
return (
<div
@@ -116,9 +118,10 @@ export default function AssignmentCard({
<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}) => (
{uniqModules.map(({ module }) => (
<div
key={module}
className={clsx(

View 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>
);
}

View File

@@ -17,7 +17,7 @@ import moment from "moment";
interface Props {
user: User;
mutateUser: KeyedMutator<User>;
mutateUser: (user: User) => void;
}
export default function DemographicInformationInput({user, mutateUser}: Props) {
@@ -42,7 +42,7 @@ export default function DemographicInformationInput({user, mutateUser}: Props) {
setIsLoading(true);
axios
.patch("/api/users/update", {
.patch<{user: User}>("/api/users/update", {
demographicInformation: {
country,
phone: `+${countryCodes.findOne("countryCode" as any, country!).countryCallingCode}${phone}`,
@@ -54,7 +54,7 @@ export default function DemographicInformationInput({user, mutateUser}: Props) {
},
agentInformation: user.type === "agent" ? {companyName, commercialRegistration} : undefined,
})
.then((response) => mutateUser((response.data as {user: User}).user))
.then((response) => mutateUser(response.data.user))
.catch(() => {
toast.error("Something went wrong, please try again later!", {toastId: "user-update-error"});
})
@@ -89,7 +89,15 @@ export default function DemographicInformationInput({user, mutateUser}: Props) {
<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 />
<Input
type="tel"
name="phone"
label="Phone number"
onChange={(e) => setPhone(e)}
value={phone}
placeholder="Enter phone number"
required
/>
</div>
{user.type === "student" && (
<Input

View File

@@ -1,7 +1,7 @@
import {infoButtonStyle} from "@/constants/buttonStyles";
import {Module} from "@/interfaces";
import {User} from "@/interfaces/user";
import useExamStore from "@/stores/examStore";
import useExamStore from "@/stores/exam";
import {getExam, getExamById} from "@/utils/exams";
import {MODULE_ARRAY} from "@/utils/moduleUtils";
import {writingMarking} from "@/utils/score";
@@ -28,8 +28,7 @@ export default function Diagnostic({onFinish}: Props) {
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;
@@ -41,9 +40,8 @@ export default function Diagnostic({onFinish}: Props) {
Promise.all(examPromises).then((exams) => {
if (exams.every((x) => !!x)) {
setExams(exams.map((x) => x!));
setSelectedModules(exams.map((x) => x!.module));
router.push("/exercises");
dispatch({type: 'INIT_EXAM', payload: {exams: exams.map((x) => x!), modules: exams.map((x) => x!.module)}})
router.push("/exam");
}
});
};

View File

@@ -2,23 +2,36 @@ import React, { useState, ReactNode, useRef, useEffect } from 'react';
import { animated, useSpring } from '@react-spring/web';
interface DropdownProps {
title: ReactNode;
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 [isOpen, setIsOpen] = useState<boolean>(open);
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);
@@ -56,28 +69,35 @@ const Dropdown: React.FC<DropdownProps> = ({
});
return (
<>
<div className={wrapperClassName}>
<button
onClick={() => setIsOpen(!isOpen)}
onClick={() => toggleOpen(!isOpen)}
className={className}
disabled={disabled}
>
{title}
<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 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}}>
<div ref={contentRef} className={contentWrapperClassName} style={{ paddingBottom: bottomPadding }}>
{children}
</div>
</animated.div>
</>
</div>
);
};

View 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;

View 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;

View File

@@ -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
}

View 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;

View 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;
};

View 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"
/>
);
};

View File

@@ -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;

View File

@@ -0,0 +1,347 @@
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";
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]) => ({
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]) => ({
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]) => ({
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]) => ({
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]) => ({
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;

View File

@@ -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;

View File

@@ -0,0 +1,338 @@
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";
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]) => ({
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]) => ({
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]) => ({
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]) => ({
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 = {
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]) => ({
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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;
};

View 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;

View 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;

View File

@@ -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;

View File

@@ -0,0 +1,261 @@
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';
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,
{
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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -0,0 +1,177 @@
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";
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: "",
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;

View File

@@ -0,0 +1,300 @@
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';
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: "",
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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View File

@@ -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;

View File

@@ -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;

View 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;

View 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;

View 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;

View File

@@ -0,0 +1,230 @@
import React, { useCallback, useEffect, useState } from 'react';
import {
MdAdd,
} from 'react-icons/md';
import Alert, { AlertItem } from '../Shared/Alert';
import { Difficulty, ReadingPart, TrueFalseExercise } from '@/interfaces/exam';
import QuestionsList from '../Shared/QuestionsList';
import Header from '../../Shared/Header';
import SortableQuestion from '../Shared/SortableQuestion';
import clsx from 'clsx';
import useExamEditorStore from '@/stores/examEditor';
import useSectionEdit from '../../Hooks/useSectionEdit';
import { toast } from 'react-toastify';
import validateTrueFalseQuestions from './validation';
import setEditingAlert from '../Shared/setEditingAlert';
import { DragEndEvent } from '@dnd-kit/core';
import { handleTrueFalseReorder } from '@/stores/examEditor/reorder/local';
import PromptEdit from '../Shared/PromptEdit';
const TrueFalse: React.FC<{ exercise: TrueFalseExercise, 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 [alerts, setAlerts] = useState<AlertItem[]>([]);
const updateLocal = (exercise: TrueFalseExercise) => {
setLocal(exercise);
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 addQuestion = () => {
const newId = (parseInt(local.questions[local.questions.length - 1].id) + 1).toString();
updateLocal({
...local,
questions: [
...local.questions,
{
prompt: "",
solution: undefined,
id: newId
}
]
});
};
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 = validateTrueFalseQuestions(
local.questions,
setAlerts
);
if (!isValid) {
toast.error("Please fix the errors before saving!");
return;
}
setEditing(false);
setAlerts([]);
//dispatch({ type: 'UPDATE_ROOT', payload: { updates: { globalEdit: globalEdit.filter(id => id !== sectionId) } } });
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: () => {
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: false
};
const newState = { ...section };
newState.exercises = newState.exercises.map((ex) =>
ex.id === exercise.id ? updatedExercise : ex
);
updateLocal({...local, isPractice: !local.isPractice})
dispatch({ type: 'UPDATE_SECTION_STATE', payload: { sectionId, update: newState, module: currentModule } });
}
});
useEffect(() => {
validateTrueFalseQuestions(local.questions, setAlerts);
}, [local.questions]);
useEffect(() => {
setEditingAlert(editing, setAlerts);
}, [editing]);
const handleDragEnd = (event: DragEndEvent) => {
setEditing(true);
setLocal(handleTrueFalseReorder(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='True/False/Not Given Exercise'
description='Edit questions and their solutions'
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={(text) => updateLocal({ ...local, prompt: text })}
/>
<div className="space-y-4">
<QuestionsList
ids={local.questions.map(q => q.id)}
handleDragEnd={handleDragEnd}
>
{local.questions.map((question, index) => (
<SortableQuestion
key={question.id}
id={question.id}
index={index}
deleteQuestion={deleteQuestion}
>
<>
<input
type="text"
value={question.prompt}
onChange={(e) => updateQuestion(index, '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="flex gap-3">
{['true', 'false', 'not_given'].map((value) => (
<label
key={value}
className="flex-1 cursor-pointer"
>
<div
className={clsx(
"p-3 text-center rounded-lg border-2 transition-all flex items-center justify-center gap-2",
question.solution === value
? 'border-blue-500 bg-blue-50 text-blue-700'
: 'border-gray-200 hover:border-gray-300'
)}
>
<input
type="radio"
name={`solution-${question.id}`}
value={value}
checked={question.solution === value}
onChange={(e) => updateQuestion(index, 'solution', e.target.value)}
className="w-4 h-4 text-blue-600 bg-gray-100 border-gray-300 focus:ring-blue-500 sr-only"
/>
<span>
{value.replace('_', ' ').charAt(0).toUpperCase() + value.slice(1).replace('_', ' ')}
</span>
</div>
</label>
))}
</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 TrueFalse;

View File

@@ -0,0 +1,46 @@
import { AlertItem } from "../Shared/Alert";
const validateTrueFalseQuestions = (
questions: {
id: string;
prompt: string;
solution?: string;
}[],
setAlerts: React.Dispatch<React.SetStateAction<AlertItem[]>>
): boolean => {
let hasErrors = false;
const emptyPrompts = questions.filter(q => !q.prompt.trim());
if (emptyPrompts.length > 0) {
hasErrors = true;
setAlerts(prev => {
const filteredAlerts = prev.filter(alert => !alert.tag?.startsWith('empty-prompt'));
return [...filteredAlerts, ...emptyPrompts.map(q => ({
variant: "error" as const,
tag: `empty-prompt-${q.id}`,
description: `Question ${q.id} has an empty prompt`
}))];
});
} else {
setAlerts(prev => prev.filter(alert => !alert.tag?.startsWith('empty-prompt')));
}
const missingSolutions = questions.filter(q => q.solution === undefined);
if (missingSolutions.length > 0) {
hasErrors = true;
setAlerts(prev => {
const filteredAlerts = prev.filter(alert => !alert.tag?.startsWith('missing-solution'));
return [...filteredAlerts, ...missingSolutions.map(q => ({
variant: "error" as const,
tag: `missing-solution-${q.id}`,
description: `Question ${q.id} is missing a solution`
}))];
});
} else {
setAlerts(prev => prev.filter(alert => !alert.tag?.startsWith('missing-solution')));
}
return !hasErrors;
};
export default validateTrueFalseQuestions;

View File

@@ -0,0 +1,344 @@
import React, { useState, useEffect, useCallback } from 'react';
import { Card, CardContent } from '@/components/ui/card';
import {
MdAdd,
MdEdit,
MdEditOff,
MdDelete,
} from 'react-icons/md';
import QuestionsList from '../Shared/QuestionsList';
import SortableQuestion from '../Shared/SortableQuestion';
import { DragEndEvent } from '@dnd-kit/core';
import Header from '../../Shared/Header';
import clsx from 'clsx';
import Alert, { AlertItem } from '../Shared/Alert';
import AutoExpandingTextArea from '@/components/Low/AutoExpandingTextarea';
import { Difficulty, ReadingPart, WriteBlanksExercise } from '@/interfaces/exam';
import useExamEditorStore from '@/stores/examEditor';
import useSectionEdit from '../../Hooks/useSectionEdit';
import setEditingAlert from '../Shared/setEditingAlert';
import { toast } from 'react-toastify';
import { validateEmptySolutions, validateQuestionText, validateWordCount } from './validation';
import { handleWriteBlanksReorder } from '@/stores/examEditor/reorder/local';
import { ParsedQuestion, parseText, reconstructText } from './parsing';
import PromptEdit from '../Shared/PromptEdit';
const WriteBlanks: React.FC<{ sectionId: number; exercise: WriteBlanksExercise; }> = ({ sectionId, exercise }) => {
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 [editingPrompt, setEditingPrompt] = useState(false);
const [errors, setErrors] = useState<{ [key: string]: string[] }>({});
const [parsedQuestions, setParsedQuestions] = useState<ParsedQuestion[]>([]);
const { editing, handleSave, handleDiscard, handleDelete, handlePractice, setEditing } = useSectionEdit({
sectionId,
onSave: () => {
const isQuestionTextValid = validateQuestionText(
parsedQuestions,
setAlerts
);
const isSolutionsValid = validateEmptySolutions(
local.solutions,
setAlerts
);
if (!isQuestionTextValid || !isSolutionsValid) {
toast.error("Please fix the errors before saving!");
return;
}
setEditing(false);
setAlerts([]);
//dispatch({ type: 'UPDATE_ROOT', payload: { updates: {globalEdit: globalEdit.filter(id => id !== sectionId)} } });
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: () => {
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(() => {
setParsedQuestions(parseText(local.text));
}, [local.text]);
const updateLocal = (exercise: WriteBlanksExercise) => {
setLocal(exercise);
setEditing(true);
};
const addQuestion = () => {
const existingIds = parsedQuestions.map(q => parseInt(q.id));
const newId = (Math.max(...existingIds, 0) + 1).toString();
const newQuestion = {
id: newId,
questionText: "New question"
};
const updatedQuestions = [...parsedQuestions, newQuestion];
const updatedText = reconstructText(updatedQuestions);
const updatedSolutions = [...local.solutions, {
id: newId,
solution: [""]
}];
updateLocal({
...local,
text: updatedText,
solutions: updatedSolutions
});
};
const updateQuestionText = (id: string, newText: string) => {
const updatedQuestions = parsedQuestions.map(q =>
q.id === id ? { ...q, questionText: newText } : q
);
const updatedText = reconstructText(updatedQuestions);
updateLocal({ ...local, text: updatedText });
};
const deleteQuestion = (id: string) => {
if (parsedQuestions.length == 1) {
toast.error("There needs to be at least one question!");
return;
}
const updatedQuestions = parsedQuestions.filter(q => q.id !== id);
const updatedText = reconstructText(updatedQuestions);
const updatedSolutions = local.solutions.filter(s => s.id !== id);
updateLocal({
...local,
text: updatedText,
solutions: updatedSolutions
});
};
const addSolutionToQuestion = (questionId: string) => {
const newSolutions = [...local.solutions];
const questionIndex = newSolutions.findIndex(s => s.id === questionId);
if (questionIndex !== -1) {
newSolutions[questionIndex] = {
...newSolutions[questionIndex],
solution: [...newSolutions[questionIndex].solution, ""]
};
updateLocal({ ...local, solutions: newSolutions });
}
};
const updateSolution = (questionId: string, solutionIndex: number, value: string) => {
const wordCount = value.trim().split(/\s+/).length;
const newSolutions = [...local.solutions];
const questionIndex = newSolutions.findIndex(s => s.id === questionId);
if (questionIndex !== -1) {
const newSolutionArray = [...newSolutions[questionIndex].solution];
newSolutionArray[solutionIndex] = value;
newSolutions[questionIndex] = {
...newSolutions[questionIndex],
solution: newSolutionArray
};
updateLocal({ ...local, solutions: newSolutions });
}
if (wordCount > local.maxWords) {
setAlerts(prev => {
const filteredAlerts = prev.filter(alert => alert.tag !== `solution-error-${questionId}-${solutionIndex}`);
return [...filteredAlerts, {
variant: "error",
tag: `solution-error-${questionId}-${solutionIndex}`,
description: `Alternative solution ${solutionIndex + 1} for question ${questionId} exceeds maximum of ${local.maxWords} words (current: ${wordCount} words)`
}];
});
} else {
setAlerts(prev => prev.filter(alert => alert.tag !== `solution-error-${questionId}-${solutionIndex}`));
}
};
const deleteSolution = (questionId: string, solutionIndex: number) => {
const newSolutions = [...local.solutions];
const questionIndex = newSolutions.findIndex(s => s.id === questionId);
if (questionIndex !== -1) {
if (newSolutions[questionIndex].solution.length == 1) {
toast.error("There needs to be at least one solution!");
return;
}
const newSolutionArray = newSolutions[questionIndex].solution.filter((_, i) => i !== solutionIndex);
newSolutions[questionIndex] = {
...newSolutions[questionIndex],
solution: newSolutionArray
};
updateLocal({ ...local, solutions: newSolutions });
}
};
const handleDragEnd = (event: DragEndEvent) => {
setEditing(true);
setLocal(handleWriteBlanksReorder(event, local));
}
useEffect(() => {
setEditingAlert(editing, setAlerts);
}, [editing]);
useEffect(() => {
validateWordCount(local.solutions, local.maxWords, setAlerts);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [local.maxWords, local.solutions]);
useEffect(() => {
validateQuestionText(parsedQuestions, setAlerts);
}, [parsedQuestions]);
useEffect(() => {
validateEmptySolutions(local.solutions, setAlerts);
}, [local.solutions]);
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={"Write Blanks: Questions"}
description="Edit questions and their solutions"
editing={editing}
difficulty={exercise.difficulty}
saveDifficulty={saveDifficulty}
handleSave={handleSave}
handleDiscard={handleDiscard}
handleDelete={handleDelete}
handlePractice={handlePractice}
isEvaluationEnabled={!local.isPractice}
/>
<div className="space-y-4">
{alerts.length > 0 && <Alert alerts={alerts} />}
<Card className="mb-6">
<CardContent className="p-4 space-y-4">
<PromptEdit value={local.prompt} onChange={(prompt: string) => updateLocal({ ...local, prompt })} wrapperCard={false}/>
<div className="flex justify-between items-start gap-4">
<div className="flex items-center gap-4">
<label className="flex items-center gap-2">
<span className="font-medium text-gray-800">Maximum words per solution:</span>
<input
type="number"
value={local.maxWords}
onChange={(e) => updateLocal({ ...local, maxWords: parseInt(e.target.value) })}
className="w-20 p-2 border rounded-lg focus:ring-2 focus:ring-blue-500 focus:outline-none"
min="1"
/>
</label>
</div>
</div>
</CardContent>
</Card>
<div className="space-y-4">
<QuestionsList
ids={parsedQuestions.map(q => q.id)}
handleDragEnd={handleDragEnd}
>
{parsedQuestions.map((question) => {
const questionSolutions = local.solutions.find(s => s.id === question.id)?.solution || [];
return (
<SortableQuestion
key={question.id}
id={question.id}
index={parseInt(question.id)}
deleteQuestion={() => deleteQuestion(question.id)}
variant="writeBlanks"
questionText={question.questionText}
onQuestionChange={(value) => updateQuestionText(question.id, value)}
>
<div className="space-y-4">
{questionSolutions.map((solution, solutionIndex) => (
<div key={solutionIndex} className="flex gap-2">
<input
type="text"
value={solution}
onChange={(e) => updateSolution(question.id, solutionIndex, e.target.value)}
className={clsx(
"flex-1 p-3 border rounded-lg focus:ring-2 focus:ring-blue-500 focus:outline-none",
errors[question.id]?.[solutionIndex] && "border-red-500"
)}
placeholder="Enter solution..."
/>
<button
onClick={() => deleteSolution(question.id, solutionIndex)}
className="p-2 hover:bg-gray-100 rounded-lg transition-colors"
>
<MdDelete size={20} className="text-gray-500" />
</button>
</div>
))}
<button
onClick={() => addSolutionToQuestion(question.id)}
className="w-full 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>
</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>
</div>
);
};
export default WriteBlanks;

View File

@@ -0,0 +1,27 @@
export interface ParsedQuestion {
id: string;
questionText: string;
}
const parseText = (text: string): ParsedQuestion[] => {
const lines = text.split('\\n').filter(line => line.trim());
return lines.map(line => {
const match = line.match(/(.*?)\{\{(\d+)\}\}/);
if (match) {
return {
questionText: match[1],
id: match[2]
};
}
return { questionText: line, id: '' };
}).filter(q => q.id);
};
const reconstructText = (questions: ParsedQuestion[]): string => {
return questions.map(q => `${q.questionText}{{${q.id}}}`).join('\\n') + '\\n';
};
export {
parseText,
reconstructText
}

View File

@@ -0,0 +1,84 @@
import { AlertItem } from "../Shared/Alert";
import { ParsedQuestion } from "./parsing";
export const validateQuestionText = (
parsedQuestions: ParsedQuestion[],
setAlerts: React.Dispatch<React.SetStateAction<AlertItem[]>>
): boolean => {
const unmodifiedQuestions = parsedQuestions.filter(q => q.questionText === "New question");
if (unmodifiedQuestions.length > 0) {
setAlerts(prev => {
const filteredAlerts = prev.filter(alert => !alert.tag?.startsWith('unmodified-question'));
return [...filteredAlerts, ...unmodifiedQuestions.map(q => ({
variant: "error" as const,
tag: `unmodified-question-${q.id}`,
description: `Question ${q.id} is unmodified`
}))];
});
return false;
}
setAlerts(prev => prev.filter(alert => !alert.tag?.startsWith('unmodified-question')));
return true;
};
export const validateEmptySolutions = (
solutions: Array<{ id: string; solution: string[] }>,
setAlerts: React.Dispatch<React.SetStateAction<AlertItem[]>>
): boolean => {
const questionsWithEmptySolutions = solutions.flatMap(solution =>
solution.solution.map((sol, index) => ({
questionId: solution.id,
solutionIndex: index,
isEmpty: !sol.trim()
})).filter(({ isEmpty }) => isEmpty)
);
if (questionsWithEmptySolutions.length > 0) {
setAlerts(prev => {
const filteredAlerts = prev.filter(alert => !alert.tag?.startsWith('empty-solution'));
return [...filteredAlerts, ...questionsWithEmptySolutions.map(({ questionId, solutionIndex }) => ({
variant: "error" as const,
tag: `empty-solution-${questionId}-${solutionIndex}`,
description: `Solution ${solutionIndex + 1} for question ${questionId} cannot be empty`
}))];
});
return false;
}
setAlerts(prev => prev.filter(alert => !alert.tag?.startsWith('empty-solution')));
return true;
};
export const validateWordCount = (
solutions: Array<{ id: string; solution: string[] }>,
maxWords: number,
setAlerts: React.Dispatch<React.SetStateAction<AlertItem[]>>
): boolean => {
let isValid = true;
solutions.forEach((solution) => {
solution.solution.forEach((value, solutionIndex) => {
const wordCount = value.trim().split(/\s+/).length;
if (wordCount > maxWords) {
isValid = false;
setAlerts(prev => {
const filteredAlerts = prev.filter(alert =>
alert.tag !== `solution-error-${solution.id}-${solutionIndex}`
);
return [...filteredAlerts, {
variant: "error",
tag: `solution-error-${solution.id}-${solutionIndex}`,
description: `Solution ${solutionIndex + 1} for question ${solution.id} exceeds maximum of ${maxWords} words (current: ${wordCount} words)`
}];
});
} else {
setAlerts(prev =>
prev.filter(alert =>
alert.tag !== `solution-error-${solution.id}-${solutionIndex}`
)
);
}
});
});
return isValid;
};

View File

@@ -0,0 +1,160 @@
import { useSensors, useSensor, PointerSensor, KeyboardSensor, DragEndEvent, DndContext, closestCenter } from "@dnd-kit/core";
import { sortableKeyboardCoordinates, arrayMove, SortableContext, horizontalListSortingStrategy } from "@dnd-kit/sortable";
import { useState } from "react";
import { BsCursorText } from "react-icons/bs";
import { MdSpaceBar } from "react-icons/md";
import { toast } from "react-toastify";
import { formatDisplayContent, formatStorageContent, PromptPart, reconstructLine } from "./parsing";
import SortableBlank from "./SortableBlank";
import { validatePlaceholders } from "./validation";
interface Props {
parts: PromptPart[];
onUpdate: (newText: string) => void;
}
interface EditingState {
text: string;
isPlaceholderMode: boolean;
}
const BlanksFormEditor: React.FC<Props> = ({ parts, onUpdate }) => {
const sensors = useSensors(
useSensor(PointerSensor),
useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates })
);
const [editingState, setEditingState] = useState<EditingState>({
text: formatDisplayContent(reconstructLine(parts)),
isPlaceholderMode: true
});
const handleTextChange = (newText: string) => {
const placeholder = parts.find(p => p.isPlaceholder);
if (!placeholder) return;
const displayPlaceholder = formatDisplayContent(placeholder.content);
if (!newText.includes(displayPlaceholder)) {
const placeholderIndex = editingState.text.indexOf(displayPlaceholder);
if (placeholderIndex >= 0) {
const beforePlaceholder = newText.slice(0, Math.min(placeholderIndex, newText.length));
const afterPlaceholder = newText.slice(Math.min(placeholderIndex, newText.length));
newText = beforePlaceholder + displayPlaceholder + afterPlaceholder;
} else {
newText = newText + ' ' + displayPlaceholder;
}
}
setEditingState(prev => ({
...prev,
text: newText
}));
};
const handleDragEnd = (event: DragEndEvent) => {
const { active, over } = event;
if (!over || active.id === over.id) return;
const oldIndex = parts.findIndex(part => part.id === active.id);
const newIndex = parts.findIndex(part => part.id === over.id);
const newParts = [...parts];
const [movedPart] = newParts.splice(oldIndex, 1);
newParts.splice(newIndex, 0, movedPart);
onUpdate(reconstructLine(newParts));
setEditingState(prev => ({
...prev,
text: formatDisplayContent(reconstructLine(newParts))
}));
};
const toggleEditMode = () => {
setEditingState(prev => ({
...prev,
isPlaceholderMode: !prev.isPlaceholderMode
}));
};
const saveTextChanges = () => {
const placeholderId = parts.find(p => p.isPlaceholder)?.id;
if (!placeholderId) return;
const validation = validatePlaceholders(editingState.text, placeholderId);
if (!validation.isValid) {
toast.error(validation.message);
setEditingState(prev => ({
...prev,
text: formatDisplayContent(reconstructLine(parts))
}));
return;
}
onUpdate(formatStorageContent(editingState.text));
};
return (
<div className="flex flex-row items-center gap-2">
<div className="flex-grow">
{editingState.isPlaceholderMode ? (
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragEnd={handleDragEnd}
>
<SortableContext
items={parts.map(part => part.id)}
strategy={horizontalListSortingStrategy}
>
<div className="flex flex-wrap items-center gap-1 min-h-[40px] p-2 border rounded-lg bg-white">
{parts.map((part) => (
<SortableBlank
key={part.id}
id={part.id}
isPlaceholder={part.isPlaceholder}
>
{part.isPlaceholder ? (
<div className="bg-blue-200 px-2 py-1 rounded cursor-move">
{formatDisplayContent(part.content)}
</div>
) : /^\s+$/.test(part.content) ? (
<div className="px-1 border-l-2 border-r-2 border-transparent">
&nbsp;
</div>
) : (
<div className="px-1">
{part.content}
</div>
)}
</SortableBlank>
))}
</div>
</SortableContext>
</DndContext>
) : (
<input
type="text"
value={editingState.text}
onChange={(e) => handleTextChange(e.target.value)}
onPaste={(e) => e.preventDefault()}
onBlur={saveTextChanges}
className="w-full p-2 border rounded-lg focus:ring-2 focus:ring-blue-500 focus:outline-none"
/>
)}
</div>
<button
className={`p-2 rounded ${editingState.isPlaceholderMode ? 'bg-blue-500 text-white' : 'bg-gray-200'}`}
onClick={toggleEditMode}
title={editingState.isPlaceholderMode ? "Switch to text editing" : "Switch to placeholder editing"}
>
{editingState.isPlaceholderMode ? <BsCursorText size={20} /> : <MdSpaceBar size={20} />}
</button>
</div>
);
};
export default BlanksFormEditor;

View File

@@ -0,0 +1,41 @@
import React from 'react';
import { useSortable } from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
interface SortableBlankProps {
id: string;
isPlaceholder?: boolean;
children: React.ReactNode;
}
const SortableBlank: React.FC<SortableBlankProps> = ({ id, isPlaceholder, children }) => {
const {
attributes,
listeners,
setNodeRef,
transform,
transition,
isDragging,
} = useSortable({ id });
const style: React.CSSProperties = {
transform: CSS.Transform.toString(transform),
transition,
opacity: isDragging ? 0.5 : undefined,
cursor: isPlaceholder ? 'move' : 'default',
};
const draggableProps = isPlaceholder ? { ...attributes, ...listeners } : {};
return (
<div
ref={setNodeRef}
style={style}
{...draggableProps}
>
{children}
</div>
);
};
export default SortableBlank;

View File

@@ -0,0 +1,302 @@
import AutoExpandingTextArea from "@/components/Low/AutoExpandingTextarea";
import { Card, CardContent } from "@/components/ui/card";
import { WriteBlanksExercise, ReadingPart, Difficulty } from "@/interfaces/exam";
import useExamEditorStore from "@/stores/examEditor";
import { DragEndEvent } from "@dnd-kit/core";
import { arrayMove } from "@dnd-kit/sortable";
import { useState, useEffect, useCallback } from "react";
import { MdEditOff, MdEdit, MdDelete, MdAdd } from "react-icons/md";
import { toast } from "react-toastify";
import useSectionEdit from "../../Hooks/useSectionEdit";
import Alert, { AlertItem } from "../Shared/Alert";
import QuestionsList from "../Shared/QuestionsList";
import setEditingAlert from "../Shared/setEditingAlert";
import SortableQuestion from "../Shared/SortableQuestion";
import { ParsedQuestion, parseLine, reconstructLine } from "./parsing";
import { validateQuestions, validateEmptySolutions, validateWordCount } from "./validation";
import Header from "../../Shared/Header";
import BlanksFormEditor from "./BlanksFormEditor";
import PromptEdit from "../Shared/PromptEdit";
const WriteBlanksForm: React.FC<{ sectionId: number; exercise: WriteBlanksExercise }> = ({ sectionId, exercise }) => {
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 [editingPrompt, setEditingPrompt] = useState(false);
const [parsedQuestions, setParsedQuestions] = useState<ParsedQuestion[]>([]);
const { editing, handleSave, handleDiscard, handleDelete, handlePractice, setEditing } = useSectionEdit({
sectionId,
onSave: () => {
const isQuestionsValid = validateQuestions(parsedQuestions, setAlerts);
const isSolutionsValid = validateEmptySolutions(local.solutions, setAlerts);
if (!isQuestionsValid || !isSolutionsValid) {
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: () => {
setLocal(exercise);
setParsedQuestions([]);
},
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(() => {
const questions = local.text.split('\\n')
.filter(line => line.trim())
.map(line => {
const match = line.match(/{{(\d+)}}/);
return {
id: match ? match[1] : `unknown-${Date.now()}`,
parts: parseLine(line),
editingPlaceholders: true
};
});
setParsedQuestions(questions);
}, [local.text]);
useEffect(() => {
setEditingAlert(editing, setAlerts);
}, [editing]);
useEffect(() => {
validateWordCount(local.solutions, local.maxWords, setAlerts);
}, [local.maxWords, local.solutions]);
const updateLocal = (exercise: WriteBlanksExercise) => {
setLocal(exercise);
setEditing(true);
};
const addQuestion = () => {
const existingIds = parsedQuestions.map(q => parseInt(q.id));
const newId = (Math.max(...existingIds, 0) + 1).toString();
const newLine = `New question with blank {{${newId}}}`;
const updatedQuestions = [...parsedQuestions, {
id: newId,
parts: parseLine(newLine),
editingPlaceholders: true
}];
const newText = updatedQuestions
.map(q => reconstructLine(q.parts))
.join('\\n') + '\\n';
const updatedSolutions = [...local.solutions, {
id: newId,
solution: [""]
}];
updateLocal({
...local,
text: newText,
solutions: updatedSolutions
});
};
const deleteQuestion = (id: string) => {
if (parsedQuestions.length === 1) {
toast.error("There needs to be at least one question!");
return;
}
const updatedQuestions = parsedQuestions.filter(q => q.id !== id);
const newText = updatedQuestions
.map(q => reconstructLine(q.parts))
.join('\\n') + '\\n';
const updatedSolutions = local.solutions.filter(s => s.id !== id);
updateLocal({
...local,
text: newText,
solutions: updatedSolutions
});
};
const handleQuestionUpdate = (questionId: string, newText: string) => {
const updatedQuestions = parsedQuestions.map(q =>
q.id === questionId ? { ...q, parts: parseLine(newText) } : q
);
const updatedText = updatedQuestions
.map(q => reconstructLine(q.parts))
.join('\\n') + '\\n';
updateLocal({ ...local, text: updatedText });
};
const addSolution = (questionId: string) => {
const newSolutions = local.solutions.map(s =>
s.id === questionId
? { ...s, solution: [...s.solution, ""] }
: s
);
updateLocal({ ...local, solutions: newSolutions });
};
const updateSolution = (questionId: string, index: number, value: string) => {
const newSolutions = local.solutions.map(s =>
s.id === questionId
? { ...s, solution: s.solution.map((sol, i) => i === index ? value : sol) }
: s
);
updateLocal({ ...local, solutions: newSolutions });
};
const deleteSolution = (questionId: string, index: number) => {
const solutions = local.solutions.find(s => s.id === questionId);
if (solutions && solutions.solution.length <= 1) {
toast.error("Each question must have at least one solution!");
return;
}
const newSolutions = local.solutions.map(s =>
s.id === questionId
? { ...s, solution: s.solution.filter((_, i) => i !== index) }
: s
);
updateLocal({ ...local, solutions: newSolutions });
};
const handleQuestionsReorder = (event: DragEndEvent) => {
const { active, over } = event;
if (!over || active.id === over.id) return;
const oldIndex = parsedQuestions.findIndex(q => q.id === active.id);
const newIndex = parsedQuestions.findIndex(q => q.id === over.id);
const reorderedQuestions = arrayMove(parsedQuestions, oldIndex, newIndex);
const newText = reorderedQuestions
.map(q => reconstructLine(q.parts))
.join('\\n') + '\\n';
updateLocal({ ...local, text: newText });
};
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="Write Blanks: Form Exercise"
description="Edit questions and their solutions"
editing={editing}
difficulty={exercise.difficulty}
saveDifficulty={saveDifficulty}
handleSave={handleSave}
handleDiscard={handleDiscard}
handleDelete={handleDelete}
handlePractice={handlePractice}
/>
<div className="space-y-4">
{alerts.length > 0 && <Alert alerts={alerts} />}
<PromptEdit value={local.prompt} onChange={(prompt: string) => updateLocal({ ...local, prompt })}/>
<div className="space-y-4">
<QuestionsList
ids={parsedQuestions.map(q => q.id)}
handleDragEnd={handleQuestionsReorder}
>
{parsedQuestions.map((question, index) => (
<SortableQuestion
key={question.id}
id={question.id}
index={index}
deleteQuestion={() => deleteQuestion(question.id)}
variant="del-up"
>
<div className="space-y-4">
<BlanksFormEditor
parts={question.parts}
onUpdate={(newText) => handleQuestionUpdate(question.id, newText)}
/>
<div className="space-y-2">
<h4 className="text-sm font-medium text-gray-700">Solutions:</h4>
{local.solutions.find(s => s.id === question.id)?.solution.map((solution, index) => (
<div key={index} className="flex gap-2 items-center">
<input
type="text"
value={solution}
onChange={(e) => updateSolution(question.id, 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={() => deleteSolution(question.id, 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={() => addSolution(question.id)}
className="w-full 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>
</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>
</div>
);
};
export default WriteBlanksForm;

View File

@@ -0,0 +1,79 @@
export interface PromptPart {
id: string;
content: string;
isPlaceholder?: boolean;
}
export interface ParsedQuestion {
id: string;
parts: PromptPart[];
editingPlaceholders: boolean;
}
const parseLine = (line: string): PromptPart[] => {
const parts: PromptPart[] = [];
let lastIndex = 0;
const regex = /{{(\d+)}}/g;
let match;
while ((match = regex.exec(line)) !== null) {
if (match.index > lastIndex) {
const textBefore = line.slice(lastIndex, match.index);
const words = textBefore.split(/(\s+)/).filter(Boolean);
words.forEach(word => {
parts.push({
id: `text-${Date.now()}-${parts.length}`,
content: word
});
});
}
const placeholderId = match[1];
parts.push({
id: placeholderId,
content: match[0],
isPlaceholder: true
});
lastIndex = match.index + match[0].length;
}
if (lastIndex < line.length) {
const textAfter = line.slice(lastIndex);
const words = textAfter.split(/(\s+)/).filter(Boolean);
words.forEach(word => {
parts.push({
id: `text-${Date.now()}-${parts.length}`,
content: word
});
});
}
return parts;
};
const reconstructLine = (parts: PromptPart[]): string => {
const text = parts
.map(part => part.content)
.join(' ')
.replace(/\s+/g, ' ')
.trim();
return text;
};
const formatDisplayContent = (content: string): string => {
return content.replace(/{{(\d+)}}/g, '[$1]');
};
const formatStorageContent = (content: string): string => {
return content.replace(/\[(\d+)\]/g, '{{$1}}');
};
export {
parseLine,
reconstructLine,
formatDisplayContent,
formatStorageContent
}

View File

@@ -0,0 +1,117 @@
import { AlertItem } from "../Shared/Alert";
import { ParsedQuestion, reconstructLine } from "./parsing";
const validatePlaceholders = (text: string, originalId: string): { isValid: boolean; message?: string } => {
const matches = text.match(/\[(\d+)\]/g) || [];
if (matches.length === 0) {
return {
isValid: false,
message: "Each question must have exactly one blank"
};
}
if (matches.length > 1) {
return {
isValid: false,
message: "Only one blank is allowed per question"
};
}
const idMatch = matches[0]?.match(/\[(\d+)\]/);
if (!idMatch || idMatch[1] !== originalId) {
return {
isValid: false,
message: "The blank ID cannot be changed"
};
}
return { isValid: true };
};
const validateQuestions = (
parsedQuestions: ParsedQuestion[],
setAlerts: React.Dispatch<React.SetStateAction<AlertItem[]>>
): boolean => {
const emptyQuestions = parsedQuestions.filter(q => reconstructLine(q.parts).trim() === '');
if (emptyQuestions.length > 0) {
setAlerts(prev => {
const filteredAlerts = prev.filter(alert => !alert.tag?.startsWith('empty-question'));
return [...filteredAlerts, ...emptyQuestions.map(q => ({
variant: "error" as const,
tag: `empty-question-${q.id}`,
description: `Question ${q.id} is empty`
}))];
});
return false;
}
setAlerts(prev => prev.filter(alert => !alert.tag?.startsWith('empty-question')));
return true;
};
const validateEmptySolutions = (
solutions: Array<{ id: string; solution: string[] }>,
setAlerts: React.Dispatch<React.SetStateAction<AlertItem[]>>
): boolean => {
const questionsWithEmptySolutions = solutions.flatMap(solution =>
solution.solution.map((sol, index) => ({
questionId: solution.id,
solutionIndex: index,
isEmpty: !sol.trim()
})).filter(({ isEmpty }) => isEmpty)
);
if (questionsWithEmptySolutions.length > 0) {
setAlerts(prev => {
const filteredAlerts = prev.filter(alert => !alert.tag?.startsWith('empty-solution'));
return [...filteredAlerts, ...questionsWithEmptySolutions.map(({ questionId, solutionIndex }) => ({
variant: "error" as const,
tag: `empty-solution-${questionId}-${solutionIndex}`,
description: `Solution ${solutionIndex + 1} for question ${questionId} cannot be empty`
}))];
});
return false;
}
setAlerts(prev => prev.filter(alert => !alert.tag?.startsWith('empty-solution')));
return true;
};
const validateWordCount = (
solutions: Array<{ id: string; solution: string[] }>,
maxWords: number,
setAlerts: React.Dispatch<React.SetStateAction<AlertItem[]>>
): boolean => {
let isValid = true;
solutions.forEach((solution) => {
solution.solution.forEach((value, solutionIndex) => {
const wordCount = value.trim().split(/\s+/).length;
if (wordCount > maxWords) {
isValid = false;
setAlerts(prev => {
const filteredAlerts = prev.filter(alert =>
alert.tag !== `solution-error-${solution.id}-${solutionIndex}`
);
return [...filteredAlerts, {
variant: "error",
tag: `solution-error-${solution.id}-${solutionIndex}`,
description: `Solution ${solutionIndex + 1} for question ${solution.id} exceeds maximum of ${maxWords} words (current: ${wordCount} words)`
}];
});
} else {
setAlerts(prev =>
prev.filter(alert =>
alert.tag !== `solution-error-${solution.id}-${solutionIndex}`
)
);
}
});
});
return isValid;
};
export {
validateQuestions,
validateEmptySolutions,
validateWordCount,
validatePlaceholders
}

View File

@@ -0,0 +1,178 @@
import { useCallback, useEffect, useState } from "react";
import useExamEditorStore from "@/stores/examEditor";
import ExamEditorStore, { ModuleState } from "@/stores/examEditor/types";
import AutoExpandingTextArea from "@/components/Low/AutoExpandingTextarea";
import { Difficulty, LevelPart, WritingExercise } from "@/interfaces/exam";
import Header from "../../Shared/Header";
import Alert, { AlertItem } from "../Shared/Alert";
import clsx from "clsx";
import useSectionEdit from "../../Hooks/useSectionEdit";
import GenLoader from "../Shared/GenLoader";
import setEditingAlert from "../Shared/setEditingAlert";
import { Module } from "@/interfaces";
interface Props {
sectionId: number;
exercise: WritingExercise;
module: Module;
index?: number;
}
const Writing: React.FC<Props> = ({ sectionId, exercise, module, index }) => {
const { currentModule, dispatch } = useExamEditorStore();
const difficulty = useExamEditorStore((state) => state.modules[currentModule].difficulty);
const { type, academic_url } = useExamEditorStore(
(state) => state.modules[currentModule]
);
const { generating, genResult, state } = useExamEditorStore(
(state) => state.modules[currentModule].sections.find((section) => section.sectionId === sectionId)!
);
const [local, setLocal] = useState(exercise);
const [prompt, setPrompt] = useState(exercise.prompt);
const [loading, setLoading] = useState(generating && generating == "exercises");
const [alerts, setAlerts] = useState<AlertItem[]>([]);
const level = module === "level";
const { editing, handleSave, handleDiscard, handleDelete, handlePractice, handleEdit, setEditing } = useSectionEdit({
sectionId,
onSave: () => {
const newExercise = { ...local } as WritingExercise;
newExercise.prompt = prompt;
newExercise.difficulty = exercise.difficulty;
setAlerts([]);
setEditing(false);
if (!level) {
dispatch({ type: "UPDATE_SECTION_STATE", payload: { sectionId: sectionId, update: newExercise, module } });
}
if (genResult) {
dispatch({ type: "UPDATE_SECTION_SINGLE_FIELD", payload: { sectionId, module: currentModule, field: "genResult", value: undefined } })
}
},
onDiscard: () => {
setEditing(false);
setLocal(exercise);
setPrompt(exercise.prompt);
},
onDelete: () => {
if (level) {
dispatch({
type: "UPDATE_SECTION_STATE", payload: {
sectionId: sectionId,
update: {
exercises: (state as LevelPart).exercises.filter((_, i) => i !== index)
},
module
}
});
}
},
onPractice: () => {
const newState = {
...state,
isPractice: !local.isPractice
};
setLocal((prev) => ({ ...prev, isPractice: !local.isPractice }))
dispatch({ type: 'UPDATE_SECTION_STATE', payload: { sectionId, update: newState, module: currentModule } });
}
});
useEffect(() => {
const loading = generating && generating == "writing";
setLoading(loading);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [generating]);
useEffect(() => {
if (genResult) {
setEditing(true);
setPrompt(genResult.result[0].prompt);
if (!difficulty.includes(genResult.result[0].difficulty)) {
dispatch({ type: 'UPDATE_MODULE', payload: { updates: { difficulty: [...difficulty, genResult.result[0].difficulty]} } });
}
const updatedExercise = { ...exercise, difficulty: genResult.result[0].difficulty };
dispatch({ type: 'UPDATE_SECTION_STATE', payload: { sectionId, update: updatedExercise, module: currentModule } });
dispatch({ type: "UPDATE_SECTION_SINGLE_FIELD", payload: { sectionId, module: currentModule, field: "generating", value: undefined } })
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [genResult, dispatch, sectionId, setEditing, currentModule]);
useEffect(() => {
setEditingAlert(prompt !== local.prompt, setAlerts);
}, [prompt, local.prompt]);
const saveDifficulty = useCallback((diff: Difficulty)=> {
if (!difficulty.includes(diff)) {
dispatch({ type: 'UPDATE_MODULE', payload: { updates: { difficulty: [...difficulty, diff]} } });
}
if (!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, level, sectionId, state]);
return (
<>
<div className={clsx('relative', level ? "px-4 mt-2" : "pb-2")}>
<Header
title={`${sectionId === 1 ? (type === "academic" ? "Visual Information" : "Letter") : "Essay"} Instructions`}
description='Generate or edit the instructions for the task'
editing={editing}
difficulty={exercise.difficulty}
saveDifficulty={saveDifficulty}
handleSave={handleSave}
handleDelete={module == "level" ? handleDelete : undefined}
handleEdit={handleEdit}
handleDiscard={handleDiscard}
handlePractice={handlePractice}
isEvaluationEnabled={!local.isPractice}
module={"writing"}
/>
{alerts.length !== 0 && <Alert alerts={alerts} />}
</div>
<div className={clsx(level ? "mt-2 px-4" : "mt-4")}>
{loading ?
<GenLoader module={currentModule} /> :
<>
{
editing ? (
<div className="text-gray-600 p-4">
<AutoExpandingTextArea
value={prompt}
onChange={(text) => setPrompt(text)}
placeholder="Instructions ..."
/>
</div>
) : (
<p className={
clsx("w-full px-7 py-8 border-2 bg-white rounded-3xl whitespace-pre-line",
prompt === "" ? "text-gray-600/50" : "text-gray-600"
)
}>
{prompt === "" ? "Instructions ..." : prompt}
</p>
)
}
{academic_url && sectionId == 1 && (
<div className="flex items-center justify-center mt-8">
<div className="max-w-lg self-center rounded-xl cursor-pointer">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img src={academic_url} alt="Visual Information" />
</div>
</div>
)}
</>
}
</div>
</>
);
};
export default Writing;

View File

@@ -0,0 +1,84 @@
import useExamEditorStore from '@/stores/examEditor';
import ExamEditorStore from '@/stores/examEditor/types';
import { useCallback, useState } from 'react';
interface Props {
sectionId: number;
editing?: boolean;
setEditing?: React.Dispatch<React.SetStateAction<boolean>>;
onSave?: () => void;
onDiscard?: () => void;
onDelete?: () => void;
onPractice?: () => void;
onEdit?: () => void;
}
const useSectionEdit = ({
sectionId,
editing: externalEditing = false,
setEditing: externalSetEditing,
onSave,
onDiscard,
onDelete,
onPractice,
onEdit
}: Props) => {
const { dispatch } = useExamEditorStore();
const [internalEditing, setInternalEditing] = useState<boolean>(externalEditing);
const editing = externalSetEditing !== undefined ? externalEditing : internalEditing;
const setEditing = externalSetEditing !== undefined ? externalSetEditing : setInternalEditing;
const updateRoot = useCallback((updates: Partial<ExamEditorStore>) => {
dispatch({ type: 'UPDATE_ROOT', payload: { updates } });
}, [dispatch]);
const handleEdit = useCallback(() => {
setEditing(!editing);
if (onEdit) {
onEdit();
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [sectionId, editing, setEditing, updateRoot]);
const handleSave = useCallback(() => {
if (onSave) {
onSave();
} else {
setEditing(false);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [setEditing, updateRoot, onSave, sectionId]);
const handleDiscard = useCallback(() => {
setEditing(false);
onDiscard?.();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [setEditing, updateRoot, onDiscard, sectionId]);
const handleDelete = useCallback(() => {
setEditing(!editing);
onDelete?.();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [setEditing, editing, updateRoot, onDelete, sectionId]);
const handlePractice = useCallback(() => {
onPractice?.();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [setEditing, editing, updateRoot, onPractice, sectionId]);
return {
editing,
setEditing,
handleEdit,
handleSave,
handleDiscard,
handleDelete,
handlePractice,
};
};
export default useSectionEdit;

View File

@@ -0,0 +1,84 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import useExamEditorStore from "@/stores/examEditor";
import { Module } from "@/interfaces";
import { debounce } from "lodash";
import { SectionSettings } from "@/stores/examEditor/types";
// Since all the other components have a local state
// that then gets updated all at once, if the keydowns
// aren't here aren't throttled things can get messy
const useSettingsState = <T extends SectionSettings>(
module: Module,
sectionId: number,
) => {
const globalSettings = useExamEditorStore((state) => {
const settings = state.modules[module].sections.find(
(section) => section.sectionId === sectionId
)?.settings;
return settings as T;
});
const dispatch = useExamEditorStore((state) => state.dispatch);
const [localSettings, setLocalSettings] = useState<T>(() =>
globalSettings || {} as T
);
const pendingUpdatesRef = useRef<Partial<T>>({});
useEffect(() => {
if (globalSettings) {
setLocalSettings(globalSettings);
}
}, [globalSettings]);
const debouncedUpdateGlobal = useMemo(() => {
const debouncedFn = debounce(() => {
if (Object.keys(pendingUpdatesRef.current).length > 0) {
dispatch({
type: 'UPDATE_SECTION_SETTINGS',
payload: { sectionId, update: pendingUpdatesRef.current, module}
});
pendingUpdatesRef.current = {};
}
}, 1000);
return debouncedFn;
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [dispatch, sectionId]);
useEffect(() => {
return () => {
if (Object.keys(pendingUpdatesRef.current).length > 0) {
dispatch({
type: 'UPDATE_SECTION_SETTINGS',
payload: {sectionId, update: pendingUpdatesRef.current, module}
});
}
};
}, [dispatch, module, sectionId]);
const updateLocalAndScheduleGlobal = useCallback((updates: Partial<T>, schedule: boolean = true) => {
setLocalSettings(prev => ({
...prev,
...updates
}));
pendingUpdatesRef.current = {
...pendingUpdatesRef.current,
...updates
};
if (schedule) {
debouncedUpdateGlobal();
}
}, [debouncedUpdateGlobal]);
return {
localSettings,
updateLocalAndScheduleGlobal
};
};
export default useSettingsState;

View File

@@ -0,0 +1,59 @@
import React from 'react';
import { FaPencilAlt } from 'react-icons/fa';
import { Module } from '@/interfaces';
import clsx from 'clsx';
import WordUploader from './WordUploader';
import GenLoader from '../Exercises/Shared/GenLoader';
import useExamEditorStore from '@/stores/examEditor';
const ImportOrFromScratch: React.FC<{
module: Module;
setNumberOfLevelParts: (parts: number) => void;
}> = ({ module, setNumberOfLevelParts }) => {
const { currentModule, dispatch } = useExamEditorStore();
const { importing } = useExamEditorStore((store) => store.modules[currentModule])
const handleClick = () => {
dispatch({ type: "UPDATE_MODULE", payload: { updates: { importModule: false } } });
}
return (
<>
{importing ? (
<GenLoader module={module} custom={`Importing ${module} exam ...`} className='flex flex-grow justify-center bg-slate-200 ' />
) : (
<div className="grid grid-cols-2 w-full flex-1 gap-6">
<button
onClick={handleClick}
className={clsx(
"flex flex-col items-center flex-1 gap-6 justify-center p-8",
"border-2 border-gray-200 rounded-xl",
`bg-ielts-${module}/20 hover:bg-ielts-${module}/30`,
"transition-all duration-300",
"shadow-sm hover:shadow-md group")}
>
<div className="transform group-hover:scale-105 transition-transform duration-300">
<FaPencilAlt className={clsx("w-20 h-20 transition-colors duration-300",
module === "reading" && "text-indigo-800 group-hover:text-indigo-950",
module === "listening" && "text-purple-800 group-hover:text-purple-950",
module === "level" && "text-teal-700 group-hover:text-teal-900"
)} />
</div>
<span className={clsx("text-lg font-bold transition-colors duration-300",
module === "reading" && "text-indigo-800 group-hover:text-indigo-950",
module === "listening" && "text-purple-800 group-hover:text-purple-950",
module === "level" && "text-teal-700 group-hover:text-teal-900"
)}>
Start from Scratch
</span>
</button>
<div className='h-full'>
<WordUploader module={module} setNumberOfLevelParts={setNumberOfLevelParts} />
</div>
</div>
)}
</>
);
};
export default ImportOrFromScratch;

View File

@@ -0,0 +1,213 @@
import Button from "@/components/Low/Button";
import { Module } from "@/interfaces";
import { Dialog, DialogPanel, DialogTitle, Transition, TransitionChild } from "@headlessui/react";
import { capitalize } from "lodash";
import React, { Fragment, useCallback, useEffect, useState } from "react";
import { FaFileDownload } from "react-icons/fa";
import { HiOutlineDocumentText } from "react-icons/hi";
import { IoInformationCircleOutline } from "react-icons/io5";
interface Props {
module: Module;
state: { isOpen: boolean, type: "exam" | "solutions" };
setState: React.Dispatch<React.SetStateAction<{ isOpen: boolean, type: "exam" | "solutions" }>>;
}
const Templates: React.FC<Props> = ({ module, state, setState }) => {
const [isClosing, setIsClosing] = useState(false);
const [mounted, setMounted] = useState(false);
useEffect(() => {
if (state.isOpen) {
setMounted(true);
}
}, [state]);
useEffect(() => {
if (!state.isOpen && mounted) {
const timer = setTimeout(() => {
setMounted(false);
setIsClosing(false);
}, 300);
return () => clearTimeout(timer);
}
}, [state, mounted]);
const blockMultipleClicksClose = useCallback(() => {
if (isClosing) return;
setIsClosing(true);
setState({ isOpen: false, type: state.type });
const timer = setTimeout(() => {
setIsClosing(false);
}, 300);
return () => clearTimeout(timer);
}, [isClosing, setState, state]);
if (!mounted && !state.isOpen) return null;
const moduleExercises = {
"reading": [
"Multiple Choice",
"Write Blanks",
"True False",
"Paragraph Match",
"Idea Match"
],
"listening": [
"Multiple Choice",
"True False",
"Write Blanks: Questions",
"Write Blanks: Fill",
"Write Blanks: Form",
],
"level": [
"Fill Blanks: Multiple Choice",
"Multiple Choice: Blank Space",
"Multiple Choice: Underline",
"Multiple Choice: Reading Passage"
],
"writing": [],
"speaking": [],
}
const handleTemplateDownload = () => {
const fileName = `${capitalize(module)}${state.type === "exam" ? "Exam" : "Solutions"}Template`;
const url = `https://firebasestorage.googleapis.com/v0/b/encoach-staging.appspot.com/o/import_templates%2F${fileName}.docx?alt=media&token=b771a535-bf95-4060-889c-a086df65d480`;
const link = document.createElement('a');
link.href = url;
link.download = `${fileName}.docx`;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
};
return (
<Transition
show={state.isOpen}
as={Fragment}
beforeEnter={() => setIsClosing(false)}
beforeLeave={() => setIsClosing(true)}
afterLeave={() => {
setIsClosing(false);
setMounted(false);
}}
>
<Dialog onClose={() => blockMultipleClicksClose()} className="relative z-50">
<TransitionChild
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" />
</TransitionChild>
<TransitionChild
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">
<DialogPanel className={`bg-ielts-${module}-light w-full max-w-6xl h-fit p-8 rounded-xl flex flex-col gap-4`}>
<DialogTitle className="flex font-bold text-xl justify-center text-gray-700"><span>{`${capitalize(module)} ${state.type === "exam" ? 'Exam' : 'Solutions'} Import`}</span></DialogTitle>
<div className="flex flex-col w-full mt-4 gap-6">
{state.type === "exam" ? (
<>
<div className="flex flex-col gap-3 bg-gray-50 rounded-lg p-4">
<div className="flex items-center gap-2">
<HiOutlineDocumentText className={`w-5 h-5 text-ielts-${module}`} />
<h2 className="text-lg font-semibold">
The {module} exam import accepts the following exercise types:
</h2>
</div>
<ul className="flex flex-col pl-10 gap-2">
{moduleExercises[module].map((item, index) => (
<li key={index} className="text-gray-700 list-disc">
{item}
</li>
))}
</ul>
</div>
<div className="flex flex-col gap-3 bg-gray-50 rounded-lg p-4">
<div className="flex items-center gap-2">
<IoInformationCircleOutline className={`w-5 h-5 text-ielts-${module}`} />
<h2 className="text-lg font-semibold">
The uploaded document must:
</h2>
</div>
<ul className="flex flex-col pl-10 gap-2">
<li className="text-gray-700 list-disc">
be a Word .docx document.
</li>
<li className="text-gray-700 list-disc">
have clear part and exercise delineation (e.g. Part 1, ... , Part X, Question 1 - 10, ... , Question y - x).
</li>
{["reading", "level"].includes(module) && (
<li className="text-gray-700 list-disc">
a part must only contain a single reading passage and it must be between the part delineator (e.g. Part 1) and the part exercises.
</li>
)}
<li className="text-gray-700 list-disc">
if solutions are going to be uploaded, the exercise numbers/id&apos;s must match the ones in the solutions.
</li>
</ul>
</div>
</>
) :
<>
<div className="flex flex-col gap-3 bg-gray-50 rounded-lg p-4">
<div className="flex items-center gap-2">
<IoInformationCircleOutline className={`w-5 h-5 text-ielts-${module}`} />
<h2 className="text-lg font-semibold">
The uploaded document must:
</h2>
</div>
<ul className="flex flex-col pl-10 gap-2">
<li className="text-gray-700 list-disc">
be a Word .docx document.
</li>
<li className="text-gray-700 list-disc">
match the exercise numbers/id&apos;s that are in the exam document.
</li>
</ul>
</div>
</>
}
<div className="bg-gray-50 rounded-lg p-4">
<p className="text-gray-600">
{`The downloadable template is an example of a file that can be imported. Your document doesn't need to be a carbon copy of the template - it can have different styling and formatting but it must adhere to the previous requirements${state.type === "exam" ? " and exercises of the same type should have the same formatting" : ""}.`}
</p>
</div>
<div className="w-full flex justify-between mt-4 gap-8">
<Button color="purple" onClick={() => blockMultipleClicksClose()} variant="outline" className="self-end w-full bg-white">
Close
</Button>
<Button color="purple" onClick={handleTemplateDownload} variant="solid" className="self-end w-full">
<div className="flex items-center gap-2">
<FaFileDownload size={24} />
Download Template
</div>
</Button>
</div>
</div>
</DialogPanel>
</div>
</TransitionChild>
</Dialog>
</Transition>
);
}
export default Templates;

View File

@@ -0,0 +1,301 @@
import React, { useCallback, useRef, useState } from 'react';
import Image from 'next/image';
import clsx from 'clsx';
import { FaFileUpload, FaCheckCircle, FaLock, FaTimes } from 'react-icons/fa';
import { capitalize } from 'lodash';
import { Module } from '@/interfaces';
import { toast } from 'react-toastify';
import useExamEditorStore from '@/stores/examEditor';
import { LevelPart, ListeningPart, ReadingPart } from '@/interfaces/exam';
import { defaultSectionSettings } from '@/stores/examEditor/defaults';
import Templates from './Templates';
import { IoInformationCircleOutline } from 'react-icons/io5';
const WordUploader: React.FC<{ module: Module, setNumberOfLevelParts: (parts: number) => void; }> = ({ module, setNumberOfLevelParts }) => {
const { currentModule, dispatch } = useExamEditorStore();
const examInputRef = useRef<HTMLInputElement>(null);
const solutionsInputRef = useRef<HTMLInputElement>(null);
const [showUploaders, setShowUploaders] = useState(false);
const [examFile, setExamFile] = useState<File | null>(null);
const [solutionsFile, setSolutionsFile] = useState<File | null>(null);
const [templateState, setTemplateState] = useState<{ isOpen: boolean, type: "exam" | "solutions" }>({ isOpen: false, type: "exam" });
const handleExamChange = (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
if (file) {
if (file.type === 'application/msword' ||
file.type === 'application/vnd.openxmlformats-officedocument.wordprocessingml.document') {
setExamFile(file);
}
}
};
const handleSolutionsChange = (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
if (file) {
if (file.type === 'application/msword' ||
file.type === 'application/vnd.openxmlformats-officedocument.wordprocessingml.document') {
setSolutionsFile(file);
}
}
};
const handleImport = useCallback(async () => {
try {
if (!examFile) {
toast.error('Exam file is required');
return;
}
dispatch({ type: "UPDATE_MODULE", payload: { updates: { importing: true }, module } })
const formData = new FormData();
formData.append('exercises', examFile);
if (solutionsFile) {
formData.append('solutions', solutionsFile);
}
const response = await fetch(`/api/exam/${module}/import/`, {
method: 'POST',
body: formData,
});
if (!response.ok) {
toast.error(`An unknown error has occured while import ${module} exam!`);
return;
}
const data = await response.json();
toast.success(`${capitalize(module)} exam imported successfully!`);
setExamFile(null);
setSolutionsFile(null);
setShowUploaders(false);
const newSectionsStates = data.parts.map(
(part: ReadingPart | ListeningPart | LevelPart, index: number) => defaultSectionSettings(module, index + 1, part)
);
if (module === "level") {
setNumberOfLevelParts(data.parts.length);
}
dispatch({
type: "UPDATE_MODULE", payload: {
updates: {
sections: newSectionsStates,
minTimer: data.minTimer,
importModule: false,
importing: false,
},
module
}
});
} catch (error) {
toast.error(`Make sure you've imported a valid word document (.docx)!`);
} finally {
dispatch({ type: "UPDATE_MODULE", payload: { updates: { importing: false }, module } })
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [
examFile,
solutionsFile,
dispatch,
currentModule
]);
return (
<>
<Templates module={module} state={templateState} setState={setTemplateState} />
{!showUploaders ? (
<div
onClick={() => setShowUploaders(true)}
className="flex flex-col items-center gap-6 h-full justify-center p-8 border-2 border-blue-200 rounded-xl
bg-gradient-to-b from-blue-50 to-blue-100
hover:from-blue-100 hover:to-blue-200
cursor-pointer transition-all duration-300
shadow-sm hover:shadow-md group"
>
<div className="transform group-hover:scale-105 transition-transform duration-300">
<Image
src="/microsoft-word-icon.png"
width={200}
height={100}
alt="Upload Word"
className="drop-shadow-md"
/>
</div>
<span className="text-lg font-bold text-stone-600 group-hover:text-stone-800 transition-colors duration-300">
Upload {capitalize(module)} Exam
</span>
</div>
) : (
<div className="flex flex-col h-full gap-4 p-6 justify-between border-2 border-blue-200 rounded-xl bg-white shadow-md">
<div className='flex flex-col flex-1 justify-center gap-8'>
<div
onClick={() => examInputRef.current?.click()}
className={clsx(
"relative p-6 border-2 border-dashed rounded-lg cursor-pointer transition-all duration-300",
examFile ? "border-green-300 bg-green-50" : "border-gray-300 hover:border-blue-400 hover:bg-blue-50"
)}
>
<div className="flex items-center gap-3">
<FaFileUpload className={clsx(
"w-8 h-8",
examFile ? "text-green-500" : "text-gray-400"
)} />
<div className="flex-grow">
<h3 className="font-semibold text-gray-700">Exam Document</h3>
<p className="text-sm text-gray-500">Required</p>
</div>
{examFile ? (
<div className="flex items-center gap-2">
<FaCheckCircle className="w-6 h-6 text-green-500" />
<button
onClick={(e) => {
e.stopPropagation();
setExamFile(null);
}}
className="p-1.5 hover:bg-green-100 rounded-full transition-colors duration-200"
>
<FaTimes className="w-4 h-4 text-green-600" />
</button>
</div>
) : (
<button
onClick={(e) => {
e.stopPropagation();
setTemplateState({ isOpen: true, type: "exam" });
}}
className="p-1.5 hover:bg-gray-200 rounded-full transition-colors duration-200"
>
<IoInformationCircleOutline size={28} />
</button>
)}
</div>
{examFile && (
<div className="mt-2 text-sm text-green-600 font-medium">
{examFile.name}
</div>
)}
<input
type="file"
ref={examInputRef}
onChange={handleExamChange}
accept=".docx"
className="hidden"
/>
</div>
<div
onClick={() => solutionsInputRef.current?.click()}
className={clsx(
"relative p-6 border-2 border-dashed rounded-lg cursor-pointer transition-all duration-300",
solutionsFile ? "border-green-300 bg-green-50" : "border-gray-300 hover:border-blue-400 hover:bg-blue-50"
)}
>
<div className="flex items-center gap-3">
<FaFileUpload className={clsx(
"w-8 h-8",
solutionsFile ? "text-green-500" : "text-gray-400"
)} />
<div className="flex-grow">
<h3 className="font-semibold text-gray-700">Solutions Document</h3>
<p className="text-sm text-gray-500">Optional</p>
</div>
{solutionsFile ? (
<div className="flex items-center gap-2">
<FaCheckCircle className="w-6 h-6 text-green-500" />
<button
onClick={(e) => {
e.stopPropagation();
setSolutionsFile(null);
}}
className="p-1.5 hover:bg-green-100 rounded-full transition-colors duration-200"
>
<FaTimes className="w-4 h-4 text-green-600" />
</button>
</div>
) : (
<>
<span className="text-xs text-gray-400 font-medium px-2 py-1 bg-gray-100 rounded">
OPTIONAL
</span>
<button
onClick={(e) => {
e.stopPropagation();
setTemplateState({ isOpen: true, type: "solutions" });
}}
className="p-1.5 hover:bg-gray-200 rounded-full transition-colors duration-200"
>
<IoInformationCircleOutline size={28} />
</button>
</>
)}
</div>
{solutionsFile && (
<div className="mt-2 text-sm text-green-600 font-medium">
{solutionsFile.name}
</div>
)}
<input
type="file"
ref={solutionsInputRef}
onChange={handleSolutionsChange}
accept=".docx"
className="hidden"
/>
</div>
</div>
<div className="flex gap-4">
<button
onClick={() => setShowUploaders(false)}
className={
clsx("px-6 py-2.5 text-sm font-semibold text-gray-700 bg-white border-2 border-gray-200",
"rounded-lg hover:bg-gray-50 hover:border-gray-300",
"transition-all duration-300 min-w-[120px]",
"focus:outline-none focus:ring-2 focus:ring-gray-200 focus:ring-offset-2",
"active:scale-95")}
>
<span className="flex items-center justify-center gap-2">
<FaTimes className="w-4 h-4" />
Cancel
</span>
</button>
<button
onClick={handleImport}
disabled={!examFile}
className={clsx(
"flex-grow px-6 py-2.5 text-sm font-semibold rounded-lg",
"transition-all duration-300 min-w-[120px]",
"focus:outline-none focus:ring-2 focus:ring-offset-2",
"flex items-center justify-center gap-2",
examFile
? "bg-gradient-to-r from-blue-500 to-blue-600 text-white hover:from-blue-600 hover:to-blue-700 active:scale-95 focus:ring-blue-500"
: "bg-gradient-to-r from-gray-100 to-gray-200 text-gray-400 cursor-not-allowed border-2 border-gray-200"
)}
>
{examFile ? (
<>
<FaFileUpload className="w-4 h-4" />
Import Files
</>
) : (
<>
<FaLock className="w-4 h-4" />
Upload Exam First
</>
)}
</button>
</div>
</div>
)}
</>
);
};
export default WordUploader;

View File

@@ -0,0 +1,63 @@
import { useCallback, useEffect, useState } from "react";
import useExamEditorStore from "@/stores/examEditor";
import ExamEditorStore, { Generating } from "@/stores/examEditor/types";
import Header from "../../Shared/Header";
import { Module } from "@/interfaces";
import GenLoader from "../../Exercises/Shared/GenLoader";
interface Props {
sectionId: number;
title: string;
description: string;
editing: boolean;
renderContent: (editing: boolean, listeningSection?: number) => React.ReactNode;
mode?: "edit" | "delete";
onSave: () => void;
onDiscard: () => void;
onEdit?: () => void;
module: Module;
context: Generating;
}
const SectionContext: React.FC<Props> = ({ sectionId, title, description, renderContent, editing, onSave, onDiscard, onEdit, mode = "edit", module, context }) => {
const { currentModule } = useExamEditorStore();
const { generating, levelGenerating } = useExamEditorStore(
(state) => state.modules[currentModule].sections.find((section) => section.sectionId === sectionId)!
);
const [loading, setLoading] = useState(generating && generating === context);
useEffect(() => {
const gen = module === "level" ? levelGenerating.find(g => g === context) !== undefined : generating && generating === context;
if (loading !== gen) {
setLoading(gen);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [generating, levelGenerating]);
return (
<div className="p-8 shadow-inner border border-gray-200 bg-gray-50 rounded-xl">
<div className='relative pb-4'>
<Header
title={title}
description={description}
editing={editing}
handleSave={onSave}
handleDiscard={onDiscard}
handleEdit={onEdit}
module={module}
/>
</div>
<div className="mt-4">
{loading ? (
<GenLoader module={module} />
) : (
renderContent(editing)
)}
</div>
</div>
);
};
export default SectionContext;

View File

@@ -0,0 +1,40 @@
import useExamEditorStore from "@/stores/examEditor";
import ListeningContext from "./listening";
import ReadingContext from "./reading";
import GenLoader from "../../Exercises/Shared/GenLoader";
interface Props {
sectionId: number;
}
const LevelContext: React.FC<Props> = ({ sectionId }) => {
const { currentModule } = useExamEditorStore();
const { generating, readingSection, listeningSection, state } = useExamEditorStore(
(state) => state.modules[currentModule].sections.find((section) => section.sectionId === sectionId)!
);
const hasReadingContext =
'text' in state &&
state.text !== undefined &&
typeof state.text === 'object' &&
'content' in state.text &&
state.text.content !== undefined &&
state.text.content !== "";
return (
<>
{generating && (
(generating === "passage" && <GenLoader module="reading" />) ||
(generating === "listeningScript" && <GenLoader module="listening" />)
)}
{(readingSection || listeningSection || hasReadingContext) && (
<div className="space-y-4 mb-4">
{(readingSection !== undefined || hasReadingContext) && <ReadingContext sectionId={sectionId} module="level" />}
{listeningSection && <ListeningContext sectionId={sectionId} listeningSection={listeningSection} module="level" level={true} />}
</div>
)}
</>
);
};
export default LevelContext;

View File

@@ -0,0 +1,194 @@
import { useCallback, useEffect, useState } from "react";
import { LevelPart, ListeningPart, Script } from "@/interfaces/exam";
import SectionContext from ".";
import useExamEditorStore from "@/stores/examEditor";
import useSectionEdit from "../../Hooks/useSectionEdit";
import ScriptRender from "../../Exercises/Script";
import { Card, CardContent } from "@/components/ui/card";
import Dropdown from "@/components/Dropdown";
import AudioPlayer from "@/components/Low/AudioPlayer";
import { MdHeadphones } from "react-icons/md";
import clsx from "clsx";
import { Module } from "@/interfaces";
import GenLoader from "../../Exercises/Shared/GenLoader";
interface Props {
module: Module;
sectionId: number;
listeningSection?: number;
level?: boolean;
}
const ListeningContext: React.FC<Props> = ({ sectionId, module, listeningSection, level = false }) => {
const { dispatch } = useExamEditorStore();
const { genResult, state, generating, levelGenResults, levelGenerating, scriptLoading } = useExamEditorStore(
(state) => state.modules[module].sections.find((section) => section.sectionId === sectionId)!
);
const listeningPart = state as ListeningPart | LevelPart;
const [isDialogDropdownOpen, setIsDialogDropdownOpen] = useState(false);
const [scriptLocal, setScriptLocal] = useState(listeningPart.script);
const { editing, handleSave, handleDiscard, setEditing, handleEdit } = useSectionEdit({
sectionId,
onSave: () => {
const newState = { ...listeningPart };
newState.script = scriptLocal;
dispatch({ type: 'UPDATE_SECTION_STATE', payload: { sectionId, update: newState, module } })
setEditing(false);
if (genResult) {
dispatch({ type: "UPDATE_SECTION_SINGLE_FIELD", payload: { sectionId, module, field: "genResult", value: undefined } })
}
if (levelGenResults.find((res) => res.generating === "listeningScript")) {
dispatch({ type: "UPDATE_SECTION_SINGLE_FIELD", payload: { sectionId, module, field: "levelGenResults", value: levelGenResults.filter((res) => res.generating !== "listeningScript") } })
}
},
onDiscard: () => {
setScriptLocal(listeningPart.script);
},
});
useEffect(() => {
if (listeningPart.script == undefined) {
setScriptLocal(undefined);
} else {
setScriptLocal(listeningPart.script);
}
}, [listeningPart])
useEffect(() => {
if (genResult && generating === "listeningScript") {
setEditing(true);
setScriptLocal(genResult.result[0].script);
setIsDialogDropdownOpen(true);
dispatch({ type: "UPDATE_SECTION_SINGLE_FIELD", payload: { sectionId, module, field: "generating", value: undefined } })
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [genResult]);
useEffect(() => {
if (genResult && generating === "audio") {
dispatch({ type: "UPDATE_SECTION_SINGLE_FIELD", payload: { sectionId, module, field: "generating", value: undefined } })
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [genResult]);
useEffect(() => {
const scriptRes = levelGenResults.find((res) => res.generating === "listeningScript");
if (levelGenResults && scriptRes) {
setEditing(true);
setScriptLocal(scriptRes.result[0].script);
dispatch({ type: "UPDATE_SECTION_SINGLE_FIELD", payload: { sectionId, module, field: "levelGenerating", value: levelGenerating.filter(g => g !== "listeningScript") } })
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [levelGenResults]);
useEffect(() => {
const scriptRes = levelGenResults.find((res) => res.generating === "audio");
if (levelGenResults && scriptRes) {
dispatch({ type: "UPDATE_SECTION_SINGLE_FIELD", payload: { sectionId, module, field: "levelGenerating", value: levelGenerating.filter(g => g !== "audio") } })
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [levelGenResults]);
const memoizedRenderContent = useCallback(() => {
if (scriptLocal === undefined && !editing && !scriptLoading) {
return (
<Card>
<CardContent className="py-10">
<span>Edit, generate or import your own audio.</span>
</CardContent>
</Card>
);
}
return (
<>
{(generating === "audio" || scriptLoading) ? (
<GenLoader
module="listening"
custom={scriptLoading ? 'Transcribing Audio ...' : 'Generating audio ...'}
/>
) : (
<>
{listeningPart.audio?.source !== undefined && (
<AudioPlayer
key={`${sectionId}-${scriptLocal?.length}`}
src={listeningPart.audio?.source ?? ''}
color="listening"
/>
)}
</>
)}
{!scriptLoading && <Dropdown
className="mt-8 w-full flex items-center justify-between p-4 bg-white hover:bg-gray-50 transition-colors border rounded-xl border-gray-200"
contentWrapperClassName="rounded-xl mt-2"
customTitle={
<div className="flex items-center space-x-3">
<MdHeadphones className={clsx(
"h-5 w-5",
`text-ielts-${module}`
)} />
<span className="font-medium text-gray-900">
{listeningSection === undefined
? ([1, 3].includes(sectionId) ? "Conversation" : "Monologue")
: ([1, 3].includes(listeningSection) ? "Conversation" : "Monologue")}
</span>
</div>
}
open={isDialogDropdownOpen}
setIsOpen={setIsDialogDropdownOpen}
>
<ScriptRender
key={scriptLocal?.length}
local={scriptLocal}
setLocal={setScriptLocal}
section={level ? listeningSection! : sectionId}
editing={editing}
/>
</Dropdown>
}
</>
);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [
scriptLoading,
generating,
listeningPart.audio?.source,
listeningPart.script,
sectionId,
module,
isDialogDropdownOpen,
setIsDialogDropdownOpen,
setScriptLocal,
level,
scriptLocal,
editing,
listeningSection
]);
return (
<SectionContext
sectionId={sectionId}
title={
listeningSection === undefined ?
([1, 3].includes(sectionId) ? "Conversation" : "Monologue") :
([1, 3].includes(listeningSection) ? "Conversation" : "Monologue")
}
description={`Enter the section's ${(sectionId === 1 || sectionId === 3) ? "conversation" : "monologue"} or import your own`}
renderContent={memoizedRenderContent}
editing={editing}
onSave={handleSave}
onEdit={handleEdit}
onDiscard={handleDiscard}
module={module}
context="listeningScript"
/>
);
};
export default ListeningContext;

View File

@@ -0,0 +1,145 @@
import { useEffect, useState } from "react";
import { LevelPart, ReadingPart } from "@/interfaces/exam";
import Input from "@/components/Low/Input";
import AutoExpandingTextArea from "@/components/Low/AutoExpandingTextarea";
import Passage from "../../Shared/Passage";
import SectionContext from ".";
import useExamEditorStore from "@/stores/examEditor";
import useSectionEdit from "../../Hooks/useSectionEdit";
import { Module } from "@/interfaces";
interface Props {
module: Module;
sectionId: number;
}
const ReadingContext: React.FC<Props> = ({ sectionId, module }) => {
const { dispatch } = useExamEditorStore();
const sectionState = useExamEditorStore(
(state) => state.modules[module].sections.find((section) => section.sectionId === sectionId)!
);
const { genResult, state, levelGenResults, levelGenerating } = sectionState;
const readingPart = state as ReadingPart | LevelPart;
const [title, setTitle] = useState(readingPart.text?.title || '');
const [content, setContent] = useState(readingPart.text?.content || '');
const [passageOpen, setPassageOpen] = useState(false);
const { editing, handleSave, handleDiscard, handleEdit, setEditing } = useSectionEdit({
sectionId,
onSave: () => {
let newState = { ...state } as ReadingPart | LevelPart;
newState.text = {
title, content
}
dispatch({ type: 'UPDATE_SECTION_STATE', payload: { sectionId, update: newState, module } })
setEditing(false);
if (genResult) {
dispatch({ type: "UPDATE_SECTION_SINGLE_FIELD", payload: { sectionId, module, field: "genResult", value: undefined } })
}
if (levelGenResults.find((res) => res.generating === "passage")) {
dispatch({ type: "UPDATE_SECTION_SINGLE_FIELD", payload: { sectionId, module, field: "levelGenResults", value: levelGenResults.filter((res) => res.generating !== "passage") } })
}
},
onDiscard: () => {
setTitle(readingPart.text?.title || '');
setContent(readingPart.text?.content || '');
},
onEdit: () => {
setPassageOpen(false);
}
});
useEffect(() => {
if (readingPart.text === undefined) {
setTitle('');
setContent('');
}
}, [readingPart])
useEffect(() => {
if (genResult && genResult.generating === "passage") {
setEditing(true);
setTitle(genResult.result[0].title);
setContent(genResult.result[0].text);
dispatch({ type: "UPDATE_SECTION_SINGLE_FIELD", payload: { sectionId, module, field: "generating", value: undefined } })
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [genResult]);
useEffect(() => {
const passageRes = [...levelGenResults].reverse()
.find((res) => res.generating === "passage");
if (levelGenResults && passageRes) {
setEditing(true);
setTitle(passageRes.result[0].title);
setContent(passageRes.result[0].text);
dispatch({ type: "UPDATE_SECTION_SINGLE_FIELD", payload: { sectionId, module, field: "levelGenerating", value: levelGenerating.filter(g => g !== "passage") } })
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [levelGenResults]);
const renderContent = (editing: boolean) => {
if (editing) {
return (
<div className="flex flex-col text-mti-gray-dim p-4 gap-4">
<Input
type="text"
placeholder="Insert a title here"
name="title"
label="Title"
onChange={setTitle}
roundness="xl"
defaultValue={title}
required
/>
<div className="flex flex-col gap-3">
<label className="font-normal text-base text-mti-gray-dim">Content *</label>
<AutoExpandingTextArea
value={content}
placeholder="Insert a passage here"
onChange={(text) => setContent(text)}
/>
</div>
</div>
);
}
return content === "" || title === "" ? (
<p className="w-full text-gray-600 px-7 py-8 border-2 bg-white rounded-3xl whitespace-pre-line">
Generate or edit the passage to add exercises!
</p>
) : (
<Passage
title={title}
content={content}
open={passageOpen}
setIsOpen={setPassageOpen}
/>
);
};
return (
<SectionContext
sectionId={sectionId}
title="Reading Passage"
description="The reading passage that the exercises will refer to."
renderContent={renderContent}
editing={editing}
onSave={handleSave}
onEdit={handleEdit}
module={module}
onDiscard={handleDiscard}
context="passage"
/>
);
};
export default ReadingContext;

View File

@@ -0,0 +1,124 @@
import { Exercise } from "@/interfaces/exam";
import ExerciseItem, { isExerciseItem } from "./types";
import MultipleChoice from "../../Exercises/MultipleChoice";
import ExerciseLabel from "../../Shared/ExerciseLabel";
import writeBlanks from "./writeBlanks";
import TrueFalse from "../../Exercises/TrueFalse";
import fillBlanks from "./fillBlanks";
import MatchSentences from "../../Exercises/MatchSentences";
import Writing from "../../Exercises/Writing";
import Speaking2 from "../../Exercises/Speaking/Speaking2";
import Speaking1 from "../../Exercises/Speaking/Speaking1";
import InteractiveSpeaking from "../../Exercises/Speaking/InteractiveSpeaking";
const getExerciseItems = (exercises: Exercise[], sectionId: number): ExerciseItem[] => {
const items: ExerciseItem[] = exercises.map((exercise, index) => {
let firstQuestionId, lastQuestionId;
switch (exercise.type) {
case "multipleChoice":
firstQuestionId = exercise.questions[0].id;
lastQuestionId = exercise.questions[exercise.questions.length - 1].id;
return {
id: index.toString(),
sectionId,
label: (
<ExerciseLabel
type='Multiple Choice Questions'
firstId={firstQuestionId}
lastId={lastQuestionId}
prompt={exercise.prompt}
/>
),
content: <MultipleChoice exercise={exercise} sectionId={sectionId} />
};
case "trueFalse":
firstQuestionId = exercise.questions[0].id
lastQuestionId = exercise.questions[exercise.questions.length - 1].id;
return {
id: index.toString(),
sectionId,
label: (
<ExerciseLabel
type='True/False/Not Given'
firstId={firstQuestionId}
lastId={lastQuestionId}
prompt={exercise.prompt}
/>
),
content: <TrueFalse exercise={exercise} sectionId={sectionId} />
};
case "matchSentences":
firstQuestionId = exercise.sentences[0].id;
lastQuestionId = exercise.sentences[exercise.sentences.length - 1].id;
return {
id: index.toString(),
sectionId,
label: (
<ExerciseLabel
type={exercise.variant == "ideaMatch" ? "Idea Match" : "Paragraph Match"}
firstId={firstQuestionId}
lastId={lastQuestionId}
prompt={exercise.prompt}
/>
),
content: <MatchSentences exercise={exercise} sectionId={sectionId} />
};
case "fillBlanks":
return fillBlanks(exercise, index, sectionId);
case "writeBlanks":
return writeBlanks(exercise, index, sectionId);
case "writing":
return {
id: index.toString(),
sectionId,
label: (
<ExerciseLabel
type={`Writing Task: ${exercise.variant === "letter" ? "Letter" : "Essay"}`}
firstId={exercise.sectionId!.toString()}
lastId={exercise.sectionId!.toString()}
prompt={exercise.prompt}
/>
),
content: <Writing key={exercise.id} exercise={exercise} sectionId={sectionId} index={index} module="level" />
};
case "speaking":
return {
exerciseId: exercise.id,
id: index.toString(),
sectionId,
label: (
<ExerciseLabel
type={`Speaking Section 2: Question`}
firstId={(index+1).toString()}
lastId={(index+1).toString()}
prompt={exercise.prompts[2]}
/>
),
content: <Speaking2 key={exercise.id} exercise={exercise} sectionId={sectionId} module="level" />
};
case "interactiveSpeaking":
const content = exercise.sectionId === 1 ? <Speaking1 key={exercise.id} exercise={exercise} sectionId={sectionId} module="level" /> :
<InteractiveSpeaking key={exercise.id} exercise={exercise} sectionId={sectionId} module="level"/>
return {
exerciseId: exercise.id,
id: index.toString(),
sectionId,
label: (
<ExerciseLabel
type={`${exercise.sectionId === 1 ? 'Speaking Section 1': 'Interactive Speaking'}: Question`}
firstId={(index+1).toString()}
lastId={(index+1).toString()}
prompt={exercise.prompts[2].text}
/>
),
content: content
};
default:
return {} as unknown as ExerciseItem;
}
}).filter(isExerciseItem);
return items;
};
export default getExerciseItems;

View File

@@ -0,0 +1,78 @@
import { FillBlanksExercise, FillBlanksMCOption } from "@/interfaces/exam";
import ExerciseItem from "./types";
import ExerciseLabel from "../../Shared/ExerciseLabel";
import FillBlanksLetters from "../../Exercises/Blanks/Letters";
import FillBlanksMC from "../../Exercises/Blanks/MultipleChoice";
interface LetterWord {
letter: string;
word: string;
}
function isLetterWordArray(words: (string | LetterWord | FillBlanksMCOption)[]): words is LetterWord[] {
return words.length > 0 &&
words.every(item =>
typeof item === 'object' &&
'letter' in item &&
'word' in item &&
!('options' in item)
);
}
function isFillBlanksMCOptionArray(words: (string | LetterWord | FillBlanksMCOption)[]): words is FillBlanksMCOption[] {
return words.length > 0 &&
words.every(item =>
typeof item === 'object' &&
'id' in item &&
'options' in item &&
typeof (item as FillBlanksMCOption).options === 'object' &&
'A' in (item as FillBlanksMCOption).options &&
'B' in (item as FillBlanksMCOption).options &&
'C' in (item as FillBlanksMCOption).options &&
'D' in (item as FillBlanksMCOption).options
);
}
const fillBlanks = (exercise: FillBlanksExercise, index: number, sectionId: number): ExerciseItem => {
const firstWordId = exercise.solutions[0].id;
const lastWordId = exercise.solutions[exercise.solutions.length - 1].id;
if (isLetterWordArray(exercise.words)) {
return {
id: index.toString(),
sectionId,
label: (
<ExerciseLabel
type='Fill Blanks Question'
firstId={firstWordId}
lastId={lastWordId}
prompt={exercise.prompt}
/>
),
content: <FillBlanksLetters exercise={exercise} sectionId={sectionId} />
};
}
if (isFillBlanksMCOptionArray(exercise.words)) {
return {
id: index.toString(),
sectionId,
label: (
<ExerciseLabel
type='Fill Blanks: MC Question'
firstId={firstWordId}
lastId={lastWordId}
prompt={exercise.prompt}
/>
),
content: <FillBlanksMC exercise={exercise} sectionId={sectionId} />
};
}
// Don't know where the fillBlanks with words as string fits
throw new Error(`Unsupported Exercise`);
}
export default fillBlanks;

View File

@@ -0,0 +1,329 @@
import { SortableContext, verticalListSortingStrategy } from "@dnd-kit/sortable";
import SortableSection from "../../Shared/SortableSection";
import { Difficulty, Exercise, InteractiveSpeakingExercise, LevelPart, ListeningPart, ReadingPart, SpeakingExercise, WritingExercise } from "@/interfaces/exam";
import ExerciseItem from "./types";
import Dropdown from "@/components/Dropdown";
import useExamEditorStore from "@/stores/examEditor";
import Writing from "../../Exercises/Writing";
import Speaking from "../../Exercises/Speaking";
import { ReactNode, useEffect } from "react";
import {
DndContext,
PointerSensor,
useSensor,
useSensors,
closestCenter,
} from '@dnd-kit/core';
import GenLoader from "../../Exercises/Shared/GenLoader";
import { ExamPart, Generating } from "@/stores/examEditor/types";
import React from "react";
import getExerciseItems from "./exercises";
import { Action } from "@/stores/examEditor/reducers";
import { writingTask } from "@/stores/examEditor/sections";
import { createSpeakingExercise } from "./speaking";
interface QuestionItemsResult {
ids: string[];
items: ExerciseItem[];
}
const SectionExercises: React.FC<{ sectionId: number; }> = ({ sectionId }) => {
const dispatch = useExamEditorStore(state => state.dispatch);
const currentModule = useExamEditorStore(state => state.currentModule);
const {sections, expandedSections, difficulty} = useExamEditorStore(state => state.modules[currentModule]);
const section = useExamEditorStore(
state => state.modules[currentModule].sections.find(
section => section.sectionId === sectionId
)
);
const genResult = section?.genResult;
const generating = section?.generating;
const levelGenResults = section?.levelGenResults;
const levelGenerating = section?.levelGenerating;
const sectionState = section?.state;
useEffect(() => {
if (genResult && genResult.generating === "exercises" && genResult.module === currentModule) {
const newExercises = genResult.result[0].exercises;
const newDifficulties = newExercises
.map((ex: Exercise) => ex.difficulty)
.filter((diff: Difficulty) => !difficulty.includes(diff));
if (newDifficulties.length > 0) {
dispatch({ type: 'UPDATE_MODULE', payload: { updates: { difficulty: [...difficulty, ...newDifficulties]} } });
}
dispatch({
type: "UPDATE_SECTION_STATE", payload: {
sectionId,
module: genResult.module,
update: {
exercises: [...(sectionState as ExamPart).exercises, ...newExercises]
}
}
})
dispatch({ type: "UPDATE_SECTION_SINGLE_FIELD", payload: { sectionId, module: currentModule, field: "generating", value: undefined } })
dispatch({ type: "UPDATE_SECTION_SINGLE_FIELD", payload: { sectionId, module: currentModule, field: "genResult", value: undefined } })
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [genResult, dispatch, sectionId, currentModule]);
const handleExerciseGen = (
results: any[],
assignExercisesFn: (results: any[]) => any[],
{
sectionId,
currentModule,
sectionState,
levelGenerating,
levelGenResults
}: {
sectionId: number;
currentModule: string;
sectionState: ExamPart;
levelGenerating?: Generating[];
levelGenResults: any[];
}
) => {
const nonWritingOrSpeaking = results[0]?.generating.startsWith("exercises");
const newExercises = assignExercisesFn(results);
const newDifficulties = newExercises
.map((ex: Exercise) => ex.difficulty)
.filter((diff: Difficulty | undefined): diff is Difficulty =>
diff !== undefined && !difficulty.includes(diff)
);
if (newDifficulties.length > 0) {
dispatch({ type: 'UPDATE_MODULE', payload: { updates: { difficulty: [...difficulty, ...newDifficulties]} } });
}
const updates = [
{
type: "UPDATE_SECTION_STATE",
payload: {
sectionId,
module: "level",
update: {
exercises: [
...sectionState.exercises,
...newExercises
]
}
}
},
{
type: "UPDATE_SECTION_SINGLE_FIELD",
payload: {
sectionId,
module: currentModule,
field: "levelGenerating",
value: levelGenerating?.filter(g =>
nonWritingOrSpeaking
? !g?.startsWith("exercises")
: !results.flatMap(res => res.generating as Generating).includes(g)
)
}
},
{
type: "UPDATE_SECTION_SINGLE_FIELD",
payload: {
sectionId,
module: currentModule,
field: "levelGenResults",
value: levelGenResults.filter(res =>
nonWritingOrSpeaking
? !res.generating.startsWith("exercises")
: !results.flatMap(res => res.generating as Generating).includes(res.generating)
)
}
}
] as Action[];
updates.forEach(update => dispatch(update));
};
useEffect(() => {
if (levelGenResults && levelGenResults?.some(res => res.generating.startsWith("exercises"))) {
const results = levelGenResults.filter(res =>
res.generating.startsWith("exercises")
);
const assignExercises = (results: any[]) =>
results
.map(res => res.result[0].exercises)
.flat();
handleExerciseGen(
results,
assignExercises,
{
sectionId,
currentModule,
sectionState: sectionState as ExamPart,
levelGenerating,
levelGenResults
}
);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [levelGenResults, sectionState, levelGenerating, sectionId, currentModule]);
useEffect(() => {
if (levelGenResults && levelGenResults?.some(res =>
res.generating === "writing_letter" || res.generating === "writing_2"
)) {
const results = levelGenResults.filter(res =>
res.generating === "writing_letter" || res.generating === "writing_2"
);
const assignExercises = (results: any[]) =>
results.map(res => ({
...writingTask(res.generating === "writing_letter" ? 1 : 2),
prompt: res.result[0].prompt,
difficulty: res.result[0].difficulty,
variant: res.generating === "writing_letter" ? "letter" : "essay"
}) as WritingExercise);
handleExerciseGen(
results,
assignExercises,
{
sectionId,
currentModule,
sectionState: sectionState as ExamPart,
levelGenerating,
levelGenResults
}
);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [levelGenResults, sectionState, levelGenerating, sectionId, currentModule]);
useEffect(() => {
if (levelGenResults && levelGenResults?.some(res => res.generating.startsWith("speaking"))) {
const results = levelGenResults.filter(res =>
res.generating.startsWith("speaking")
);
const assignExercises = (results: any[]) =>
results.map(createSpeakingExercise);
handleExerciseGen(
results,
assignExercises,
{
sectionId,
currentModule,
sectionState: sectionState as ExamPart,
levelGenerating,
levelGenResults
}
);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [levelGenResults, sectionState, levelGenerating, sectionId, currentModule]);
const currentSection = sections.find((s) => s.sectionId === sectionId)!;
const sensors = useSensors(
useSensor(PointerSensor),
);
const questionItems = (): QuestionItemsResult => {
const part = currentSection.state as ReadingPart | ListeningPart | LevelPart;
const items = getExerciseItems(part.exercises, sectionId);
return {
items,
ids: items.map(item => item.id)
}
};
const background = (component: ReactNode) => {
return (
<div className="p-8 shadow-inner border border-gray-200 bg-gray-50 rounded-xl">
{component}
</div>
);
}
if (currentModule == "writing") return background(<Writing sectionId={sectionId} exercise={currentSection.state as WritingExercise} module="writing" />);
if (currentModule == "speaking") return background(<Speaking sectionId={sectionId} exercise={currentSection.state as SpeakingExercise} module="speaking" />);
const questions = questionItems();
// #############################################################################
// Typescript checks so that the compiler and builder don't freak out
const filteredIds = (questions.ids ?? []).filter(Boolean);
function isValidItem(item: ExerciseItem | undefined): item is ExerciseItem {
return item !== undefined &&
typeof item.id === 'string' &&
typeof item.sectionId === 'number' &&
React.isValidElement(item.label) &&
React.isValidElement(item.content);
}
const filteredItems = (questions.items ?? []).filter(isValidItem);
// #############################################################################
const onFocus = (questionId: string, id: string | undefined) => {
if (id) {
dispatch({ type: "UPDATE_SECTION_SINGLE_FIELD", payload: { module: currentModule, sectionId, field: "focusedExercise", value: { questionId, id } } })
}
}
return (
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragEnd={(e) => dispatch({ type: "REORDER_EXERCISES", payload: { event: e, sectionId, module: currentModule } })}
>
{expandedSections.includes(sectionId) &&
questions.items &&
questions.items.length > 0 &&
questions.ids &&
questions.ids.length > 0 && (
<div className="mt-4 p-6 rounded-xl shadow-inner border bg-gray-50">
<SortableContext
items={filteredIds}
strategy={verticalListSortingStrategy}
>
{filteredItems.map(item => (
<SortableSection key={item.id} id={item.id}>
<Dropdown
className={`w-full text-left p-4 mb-2 bg-gradient-to-r from-ielts-${currentModule}/60 to-ielts-${currentModule} text-white rounded-lg shadow-lg transition-transform transform hover:scale-102`}
customTitle={item.label}
contentWrapperClassName="rounded-xl"
>
<div tabIndex={4} className="p-4 shadow-inner border border-gray-200 bg-gray-50 rounded-xl" onFocus={() => onFocus(item.id, item.exerciseId)}>
{item.content}
</div>
</Dropdown>
</SortableSection>
))}
</SortableContext>
</div>
)
}
{generating === "exercises" && <GenLoader module={currentModule} className="mt-4" />}
{currentModule === "level" && (
<>
{
questions.ids?.length === 0 && !levelGenerating?.some((g) => g?.startsWith("exercises") || g?.startsWith("writing") || g?.startsWith("speaking")) && generating !== "exercises"
&& background(<span className="flex justify-center">Generated exercises will appear here!</span>)}
{levelGenerating?.some((g) => g?.startsWith("exercises") || g?.startsWith("writing") || g?.startsWith("speaking")) && <GenLoader module={currentModule} className="mt-4" />}
</>)
}
</DndContext >
);
}
export default SectionExercises;

View File

@@ -0,0 +1,50 @@
import { InteractiveSpeakingExercise, SpeakingExercise } from "@/interfaces/exam";
import { speakingTask } from "@/stores/examEditor/sections";
export const createSpeakingExercise = (res: any) => {
const taskNumber = Number(res.generating.split("_")[1]);
const baseExercise = speakingTask(taskNumber);
return {
...baseExercise,
...getSpeakingTaskData(taskNumber, res.result[0])
} as SpeakingExercise | InteractiveSpeakingExercise;
};
const getSpeakingTaskData = (taskNumber: number, data: any) => {
switch (taskNumber) {
case 1:
return {
first_title: data.first_topic,
second_title: data.second_topic,
prompts: [
...data.prompts.map((item: any) => ({
text: item,
video_url: ""
}))
],
difficulty: data.difficulty,
sectionId: 1,
};
case 2:
return {
title: data.topic,
text: data.question,
prompts: data.prompts,
difficulty: data.difficulty,
sectionId: 2,
type: "speaking"
};
case 3:
return {
title: data.topic,
prompts: data.questions.map((item: any) => ({
text: item || "",
video_url: ""
})),
difficulty: data.difficulty,
sectionId: 3,
};
default:
return data;
}
};

View File

@@ -0,0 +1,16 @@
export default interface ExerciseItem {
id: string;
sectionId: number;
label: React.ReactNode;
content: React.ReactNode;
exerciseId?: string;
}
export function isExerciseItem(item: unknown): item is ExerciseItem {
return item !== undefined &&
item !== null &&
typeof (item as ExerciseItem).id === 'string' &&
typeof (item as ExerciseItem).sectionId === 'number' &&
(item as ExerciseItem).label !== undefined &&
(item as ExerciseItem).content !== undefined;
}

View File

@@ -0,0 +1,66 @@
import { Action } from "@/stores/examEditor/reducers";
import { ExamPart, Generating } from "@/stores/examEditor/types";
import { createSpeakingExercise } from "./speaking";
import { writingTask } from "@/stores/examEditor/sections";
import { WritingExercise } from "@/interfaces/exam";
const getResults = (results: any[], type: 'writing' | 'speaking') => {
return results.map((res) => {
if (type === 'writing') {
return {
...writingTask(res.generating === "writing_letter" ? 1 : 2),
prompt: res.result[0].prompt,
variant: res.generating === "writing_letter" ? "letter" : "essay"
} as WritingExercise;
}
return createSpeakingExercise(res);
});
};
const updates = (
results: any[],
sectionState: ExamPart,
sectionId: number,
currentModule: string,
levelGenerating: any[],
levelGenResults: any[],
type: 'writing' | 'speaking'
): Action[] => {
return [
{
type: "UPDATE_SECTION_STATE",
payload: {
sectionId,
module: "level",
update: {
exercises: [
...(sectionState as ExamPart).exercises,
...getResults(results, type)
]
}
}
},
{
type: "UPDATE_SECTION_SINGLE_FIELD",
payload: {
sectionId,
module: currentModule,
field: "levelGenerating",
value: levelGenerating?.filter(g =>
!results.flatMap(res => res.generating as Generating).includes(g)
)
}
},
{
type: "UPDATE_SECTION_SINGLE_FIELD",
payload: {
sectionId,
module: currentModule,
field: "levelGenResults",
value: levelGenResults.filter(res =>
!results.flatMap(res => res.generating as Generating).includes(res.generating)
)
}
}
] as Action[];
};

View File

@@ -0,0 +1,58 @@
import { WriteBlanksExercise } from "@/interfaces/exam";
import ExerciseLabel from "../../Shared/ExerciseLabel";
import WriteBlanksForm from "../../Exercises/WriteBlanksForm";
import WriteBlanksFill from "../../Exercises/Blanks/WriteBlankFill";
import WriteBlanks from "../../Exercises/WriteBlanks";
import ExerciseItem from "./types";
const writeBlanks = (exercise: WriteBlanksExercise, index: number, sectionId: number): ExerciseItem => {
const firstQuestionId = exercise.solutions[0].id;
const lastQuestionId = exercise.solutions[exercise.solutions.length - 1].id;
switch (exercise.variant) {
case 'form':
return {
id: index.toString(),
sectionId,
label: (
<ExerciseLabel
type='Write Blanks: Form'
firstId={firstQuestionId}
lastId={lastQuestionId}
prompt={exercise.prompt}
/>
),
content: <WriteBlanksForm exercise={exercise} sectionId={sectionId} />
};
case 'fill':
return {
id: index.toString(),
sectionId,
label: (
<ExerciseLabel
type='Write Blanks: Fill'
firstId={firstQuestionId}
lastId={lastQuestionId}
prompt={exercise.prompt}
/>
),
content: <WriteBlanksFill exercise={exercise} sectionId={sectionId} />
};
default:
return {
id: index.toString(),
sectionId,
label: (
<ExerciseLabel
type='Write Blanks: Questions'
firstId={firstQuestionId}
lastId={lastQuestionId}
prompt={exercise.prompt}
/>
),
content: <WriteBlanks exercise={exercise} sectionId={sectionId} />
};
}
};
export default writeBlanks;

View File

@@ -0,0 +1,102 @@
import React, { useCallback } from 'react';
import clsx from 'clsx';
import { toast } from 'react-toastify';
import ReadingContext from './SectionContext/reading';
import SectionExercises from './SectionExercises';
import useExamEditorStore from '@/stores/examEditor';
import { ModuleState } from '@/stores/examEditor/types';
import ListeningContext from './SectionContext/listening';
import SectionDropdown from '../Shared/SectionDropdown';
import LevelContext from './SectionContext/level';
import { Module } from '@/interfaces';
const SectionRenderer: React.FC = () => {
const { currentModule, dispatch } = useExamEditorStore();
const {
focusedSection,
expandedSections,
sections,
sectionLabels,
edit,
} = useExamEditorStore(state => state.modules[currentModule]);
const updateModule = useCallback((updates: Partial<ModuleState>) => {
dispatch({ type: 'UPDATE_MODULE', payload: { updates } });
}, [dispatch]);
const toggleSection = (sectionId: number) => {
if (edit.includes(sectionId)) {
toast.info(`Save or discard your changes first!`);
} else {
if (!expandedSections.includes(sectionId)) {
updateModule({ focusedSection: sectionId });
}
updateModule({
expandedSections:
expandedSections.includes(sectionId) ?
expandedSections.filter(index => index !== sectionId) :
[...expandedSections, sectionId]
})
}
};
const ContextMap: Record<string, React.ComponentType<{ sectionId: number; module: Module }>> = {
reading: ReadingContext,
listening: ListeningContext,
level: LevelContext,
};
const SectionContext = ContextMap[currentModule];
return (
<>
<div className='flex flex-row'>
<div className={clsx(
"p-4 rounded-xl w-full",
currentModule && `bg-ielts-${currentModule}/20`
)}>
{sections.map((state, sectionIndex) => {
const id = state.sectionId;
const label = sectionLabels.find((sl) => sl.id == id)?.label;
return (
<div key={id}
className={
clsx("rounded-xl shadow",
sectionIndex !== sections.length - 1 && "mb-4"
)}>
<SectionDropdown
toggleOpen={() => toggleSection(id)}
open={expandedSections.includes(id)}
title={label}
className={clsx(
"w-full py-4 px-8 text-lg font-semibold leading-6 text-white",
"shadow-lg transform transition-all duration-300 hover:scale-102 hover:rounded-lg",
expandedSections.includes(id) ? "rounded-t-lg" : "rounded-lg",
focusedSection !== id ?
`bg-gradient-to-r from-ielts-${currentModule}/30 to-ielts-${currentModule}/60 hover:from-ielts-${currentModule}/60 hover:to-ielts-${currentModule}` :
`bg-ielts-${currentModule}`
)}
>
{expandedSections.includes(id) && (
<div
className="p-6 bg-white rounded-b-xl shadow-inner border-b"
onFocus={() => updateModule({ focusedSection: id })}
tabIndex={id + 1}
>
{currentModule in ContextMap && <SectionContext sectionId={id} module={currentModule} />}
<SectionExercises sectionId={id} />
</div>
)}
</SectionDropdown>
</div>);
})}
</div>
</div>
</>
);
};
export default SectionRenderer;

View File

@@ -0,0 +1,19 @@
import { Module } from "@/interfaces";
import { GeneratedExercises, GeneratorState } from "../ExercisePicker/generatedExercises";
import { SectionState } from "@/stores/examEditor/types";
export interface SectionRendererProps {
module: Module;
sectionLabel: string;
states: SectionState[];
globalEdit: number[];
generatedExercises: GeneratedExercises | undefined;
generating: GeneratorState | undefined;
focusedSection: number;
setGeneratedExercises: React.Dispatch<React.SetStateAction<GeneratedExercises | undefined>>;
setGenerating: React.Dispatch<React.SetStateAction<GeneratorState | undefined>>;
setGlobalEdit: React.Dispatch<React.SetStateAction<number[]>>;
setSectionStates: React.Dispatch<React.SetStateAction<SectionState[]>>;
setFocusedSection: React.Dispatch<React.SetStateAction<number>>;
}

View File

@@ -0,0 +1,137 @@
import axios from "axios";
import { playSound } from "@/utils/sound";
import { toast } from "react-toastify";
import { Generating } from "@/stores/examEditor/types";
import useExamEditorStore from "@/stores/examEditor";
import { Module } from "@/interfaces";
interface GeneratorConfig {
method: 'GET' | 'POST';
queryParams?: Record<string, string | string[]>;
files?: Record<string, string>;
body?: Record<string, any>;
}
export function generate(
sectionId: number,
module: Module,
type: Generating,
config: GeneratorConfig,
mapData: (data: any) => Record<string, any>[],
levelSectionId?: number,
level: boolean = false
) {
const setGenerating = (sectionId: number, generating: Generating, level: boolean, remove?: boolean) => {
const state = useExamEditorStore.getState();
const dispatch = state.dispatch;
let generatingUpdate;
if (level) {
if (remove) {
generatingUpdate = state.modules["level"].sections.find((s) => s.sectionId === levelSectionId)!.levelGenerating.filter(g => g === generating)
}
else {
generatingUpdate = [...state.modules["level"].sections.find((s) => s.sectionId === levelSectionId)!.levelGenerating, generating];
}
} else {
generatingUpdate = generating;
}
dispatch({
type: "UPDATE_SECTION_SINGLE_FIELD",
payload: { sectionId, module: level ? "level" : module, field: level ? "levelGenerating" : "generating", value: generatingUpdate }
});
};
const setGeneratedResult = (sectionId: number, generating: Generating, result: Record<string, any>[] | undefined, level: boolean) => {
const state = useExamEditorStore.getState();
const dispatch = state.dispatch;
let genResults;
if (level) {
genResults = [...state.modules["level"].sections.find((s) => s.sectionId === levelSectionId)!.levelGenResults, { generating, result, module }];
} else {
genResults = { generating, result, module };
}
dispatch({
type: "UPDATE_SECTION_SINGLE_FIELD",
payload: { sectionId: level ? levelSectionId! : sectionId, module: level ? "level" : module, field: level ? "levelGenResults" : "genResult", value: genResults }
});
};
setGenerating(level ? levelSectionId! : sectionId, type, level);
function buildQueryString(params: Record<string, string | string[]>): string {
const searchParams = new URLSearchParams();
Object.entries(params).forEach(([key, value]) => {
if (Array.isArray(value)) {
value.forEach(v => searchParams.append(key, v));
} else {
searchParams.append(key, value);
}
});
return searchParams.toString();
}
const queryString = config.queryParams ? buildQueryString(config.queryParams) : '';
const url = `/api/exam/generate/${module}/${sectionId}${queryString ? `?${queryString}` : ''}`;
let body = null;
if (config.files && Object.keys(config.files).length > 0 && config.method === 'POST') {
const formData = new FormData();
const buildForm = async () => {
await Promise.all(
Object.entries(config.files ?? {}).map(async ([key, blobUrl]) => {
const response = await fetch(blobUrl);
const blob = await response.blob();
const file = new File([blob], key, { type: blob.type });
formData.append(key, file);
})
);
if (config.body) {
Object.entries(config.body).forEach(([key, value]) => {
formData.append(key, value as string);
});
}
return formData;
};
buildForm().then(form => {
body = form;
const request = axios.post(url, body, { headers: { 'Content-Type': 'multipart/form-data' } });
request
.then((result) => {
playSound("check");
setGeneratedResult(level ? levelSectionId! : sectionId, type, mapData(result.data), level);
})
.catch((error) => {
setGenerating(sectionId, undefined, level, true);
playSound("error");
toast.error("Something went wrong! Try to generate again.");
});
});
} else {
body = config.body;
const request = config.method === 'POST'
? axios.post(url, body, { headers: { 'Content-Type': 'application/json' } })
: axios.get(url);
request
.then((result) => {
playSound("check");
setGeneratedResult(level ? levelSectionId! : sectionId, type, mapData(result.data), level);
})
.catch((error) => {
setGenerating(sectionId, undefined, level, true);
playSound("error");
toast.error("Something went wrong! Try to generate again.");
});
}
}

View File

@@ -0,0 +1,63 @@
import { Module } from "@/interfaces";
import useExamEditorStore from "@/stores/examEditor";
import { Generating } from "@/stores/examEditor/types";
import clsx from "clsx";
import { useEffect, useState } from "react";
import { BsArrowRepeat } from "react-icons/bs";
import { GiBrain } from "react-icons/gi";
interface Props {
module: Module;
sectionId: number;
genType: Generating;
generateFnc: (sectionId: number) => void
className?: string;
level?: boolean;
disabled?: boolean;
}
const GenerateBtn: React.FC<Props> = ({ module, sectionId, genType, generateFnc, className, level = false, disabled = false }) => {
const section = useExamEditorStore((store) => store.modules[level ? "level" : module].sections.find((s) => s.sectionId == sectionId));
const [loading, setLoading] = useState(false);
const generating = section?.generating;
const genResult = section?.genResult;
const levelGenerating = section?.levelGenerating;
const levelGenResults = section?.levelGenResults;
useEffect(() => {
const gen = level ? levelGenerating?.find(g => g === genType) !== undefined : (generating !== undefined && generating === genType);
if (loading !== gen) {
setLoading(gen);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [generating, levelGenerating, levelGenResults, genResult])
if (section === undefined) return <></>;
return (
<button
key={`section-${sectionId}`}
className={clsx(
"flex items-center w-[140px] justify-center px-4 py-2 text-white rounded-xl transition-colors duration-300 text-lg disabled:cursor-not-allowed",
`bg-ielts-${module}/70 border border-ielts-${module} hover:bg-ielts-${module} disabled:bg-ielts-${module}/40`,
className
)}
disabled={loading || disabled}
onClick={(loading || disabled) ? () => { } : () => generateFnc(sectionId)}
>
{loading ? (
<div key={`section-${sectionId}`} className="flex items-center justify-center">
<BsArrowRepeat className="text-white animate-spin" size={25} />
</div>
) : (
<div key={`section-${sectionId}`} className="flex flex-row">
<GiBrain className="mr-2" size={24} />
<span>Generate</span>
</div>
)}
</button>
);
}
export default GenerateBtn;

View File

@@ -0,0 +1,129 @@
import React from 'react';
import { Module } from "@/interfaces";
import useExamEditorStore from "@/stores/examEditor";
import Dropdown from "./SettingsDropdown";
import { LevelSectionSettings } from "@/stores/examEditor/types";
import { LevelPart } from '@/interfaces/exam';
interface Props {
module: Module;
sectionId: number;
localSettings: LevelSectionSettings;
updateLocalAndScheduleGlobal: (updates: Partial<LevelSectionSettings>, schedule?: boolean) => void;
}
const SectionPicker: React.FC<Props> = ({
module,
sectionId,
localSettings,
updateLocalAndScheduleGlobal
}) => {
const { dispatch } = useExamEditorStore();
const [selectedValue, setSelectedValue] = React.useState<number | undefined>(undefined);
const sectionState = useExamEditorStore(state =>
state.modules["level"].sections.find((s) => s.sectionId === sectionId)
);
const state = sectionState?.state as LevelPart;
if (sectionState === undefined) return null;
const { readingSection, listeningSection } = sectionState;
const currentValue = selectedValue ?? (module === "reading" ? readingSection : listeningSection);
const options = module === "reading" ? [1, 2, 3] : [1, 2, 3, 4];
const openPicker = module === "reading" ? "isReadingPickerOpen" : "isListeningPickerOpen";
const handleSectionChange = (value: number) => {
const newValue = currentValue === value ? undefined : value;
setSelectedValue(newValue);
let update = {};
if (module === "listening") {
if (state.audio?.source) {
URL.revokeObjectURL(state.audio.source)
}
update = {
audio: undefined,
script: undefined,
}
}
dispatch({
type: "UPDATE_SECTION_STATE",
payload: {
sectionId,
module: "level",
update: {
...state,
...update
}
}
})
setTimeout(() => {
dispatch({
type: "UPDATE_SECTION_SINGLE_FIELD",
payload: {
sectionId,
module: "level",
field: module === "reading" ? "readingSection" : "listeningSection",
value: newValue
}
});
}, 500);
};
const getTitle = () => {
const section = module === "reading" ? "Passage" : "Section";
if (!currentValue) return `Choose a ${section}`;
return `${section} ${currentValue}`;
};
return (
<Dropdown
title={getTitle()}
module={module}
open={localSettings[openPicker]}
setIsOpen={(isOpen: boolean) =>
updateLocalAndScheduleGlobal({ [openPicker]: isOpen }, false)
}
contentWrapperClassName={`pt-6 px-4 bg-gray-200 rounded-b-lg shadow-md transition-all duration-300 ease-in-out border border-ielts-${module}`}
>
<div className="space-y-2 pt-3 pb-3 px-2 border border-gray-200 rounded-lg shadow-inner">
{options.map((num) => (
<label
key={num}
className={`
flex items-center space-x-3 font-semibold cursor-pointer p-2 rounded
transition-colors duration-200
${currentValue === num
? `bg-ielts-${module}/90 text-white`
: `hover:bg-ielts-${module}/70 text-gray-700`}
`}
onClick={(e) => {
e.preventDefault();
handleSectionChange(num);
}}
>
<input
type="checkbox"
checked={currentValue === num}
onChange={() => { }}
className={`
h-5 w-5 cursor-pointer
accent-ielts-${module}
`}
/>
<div className="flex items-center space-x-2">
<span>
{module === "reading" ? `Passage ${num}` : `Section ${num}`}
</span>
</div>
</label>
))}
</div>
</Dropdown>
);
};
export default SectionPicker;

View File

@@ -0,0 +1,35 @@
import Dropdown from "@/components/Dropdown";
import clsx from "clsx";
import { ReactNode } from "react";
interface Props {
module: string;
title: string;
open: boolean;
disabled?: boolean;
setIsOpen: (isOpen: boolean) => void;
children: ReactNode;
center?: boolean;
contentWrapperClassName?: string;
}
const SettingsDropdown: React.FC<Props> = ({ module, title, open, setIsOpen, children, contentWrapperClassName = '', disabled = false, center = false}) => {
return (
<Dropdown
title={title}
className={clsx(
"w-full font-semibold flex justify-between items-center p-4 bg-gradient-to-r border text-white shadow-md transition-all duration-300 disabled:cursor-not-allowed",
`bg-ielts-${module}/70 border border-ielts-${module} hover:bg-ielts-${module} disabled:bg-ielts-${module}/30`,
open ? "rounded-t-lg" : "rounded-lg"
)}
contentWrapperClassName={`pt-6 px-2 bg-white rounded-b-lg shadow-md transition-all duration-300 ease-in-out ${center ? "flex justify-center" : ""} ${contentWrapperClassName}`}
open={open}
setIsOpen={setIsOpen}
disabled={disabled}
>
{children}
</Dropdown>
);
}
export default SettingsDropdown;

View File

@@ -0,0 +1,125 @@
import { InteractiveSpeakingExercise, SpeakingExercise } from "@/interfaces/exam";
import { Avatar } from "../speaking";
import axios from "axios";
interface VideoResponse {
status: 'STARTED' | 'ERROR' | 'COMPLETED' | 'IN_PROGRESS';
result: string;
}
interface VideoGeneration {
index: number;
text: string;
videoId?: string;
url?: string;
}
export async function generateVideos(section: InteractiveSpeakingExercise | SpeakingExercise, focusedSection: number, selectedAvatar: Avatar | null, speakingAvatars: Avatar[]) {
const abortController = new AbortController();
let activePollingIds: string[] = [];
const avatarToUse = selectedAvatar || speakingAvatars[Math.floor(Math.random() * speakingAvatars.length)];
const pollVideoGeneration = async (videoId: string): Promise<string> => {
while (true) {
try {
const { data } = await axios.get<VideoResponse>(`api/exam/media/poll?videoId=${videoId}`, {
signal: abortController.signal
});
if (data.status === 'ERROR') {
abortController.abort();
throw new Error('Video generation failed');
}
if (data.status === 'COMPLETED') {
const videoResponse = await axios.get(data.result, {
responseType: 'blob',
signal: abortController.signal
});
const videoUrl = URL.createObjectURL(
new Blob([videoResponse.data], { type: 'video/mp4' })
);
return videoUrl;
}
await new Promise(resolve => setTimeout(resolve, 10000)); // 10 secs
} catch (error: any) {
if (error.name === 'AbortError' || axios.isCancel(error)) {
throw new Error('Operation aborted');
}
throw error;
}
}
};
const generateSingleVideo = async (text: string, index: number): Promise<VideoGeneration> => {
try {
const { data } = await axios.post<VideoResponse>('/api/exam/media/speaking',
{ text, avatar: avatarToUse.name },
{
headers: {
'Content-Type': 'application/json',
},
signal: abortController.signal
}
);
if (data.status === 'ERROR') {
abortController.abort();
throw new Error('Initial video generation failed');
}
activePollingIds.push(data.result);
const videoUrl = await pollVideoGeneration(data.result);
return { index, text, videoId: data.result, url: videoUrl };
} catch (error) {
abortController.abort();
throw error;
}
};
try {
let videosToGenerate: { text: string; index: number }[] = [];
switch (focusedSection) {
case 1: {
const interactiveSection = section as InteractiveSpeakingExercise;
videosToGenerate = interactiveSection.prompts.map((prompt, index) => ({
text: index === 0 ? prompt.text.replace("{avatar}", avatarToUse.name) : prompt.text,
index
}));
break;
}
case 2: {
const speakingSection = section as SpeakingExercise;
videosToGenerate = [{ text: `${speakingSection.text}. You have 1 minute to take notes.`, index: 0 }];
break;
}
case 3: {
const interactiveSection = section as InteractiveSpeakingExercise;
videosToGenerate = interactiveSection.prompts.map((prompt, index) => ({
text: prompt.text,
index
}));
break;
}
}
// Generate all videos concurrently
const results = await Promise.all(
videosToGenerate.map(({ text, index }) => generateSingleVideo(text, index))
);
// by order which they came in
return results.sort((a, b) => a.index - b.index);
} catch (error) {
// Clean up any ongoing requests
abortController.abort();
// Clean up any created URLs
activePollingIds.forEach(id => {
if (id) URL.revokeObjectURL(id);
});
throw error;
}
}

View File

@@ -0,0 +1,182 @@
import React, { ReactNode, useCallback, useEffect, useMemo, useState, useRef } from "react";
import { FaEye, FaFileUpload } from "react-icons/fa";
import clsx from "clsx";
import Select from "@/components/Low/Select";
import Input from "@/components/Low/Input";
import AutoExpandingTextArea from "@/components/Low/AutoExpandingTextarea";
import Option from '@/interfaces/option'
import Dropdown from "./Shared/SettingsDropdown";
import useSettingsState from "../Hooks/useSettingsState";
import { Module } from "@/interfaces";
import { SectionSettings } from "@/stores/examEditor/types";
import useExamEditorStore from "@/stores/examEditor";
interface SettingsEditorProps {
sectionId: number,
sectionLabel: string;
module: Module,
introPresets: Option[];
children?: ReactNode;
canPreview: boolean;
canSubmit: boolean;
submitModule: () => void;
preview: () => void;
}
const SettingsEditor: React.FC<SettingsEditorProps> = ({
sectionId,
sectionLabel,
module,
introPresets,
children,
preview,
submitModule,
canPreview,
canSubmit
}) => {
const { dispatch } = useExamEditorStore()
const examLabel = useExamEditorStore((state) => state.modules[module].examLabel) || '';
const type = useExamEditorStore((s) => s.modules[module].type);
const { localSettings, updateLocalAndScheduleGlobal } = useSettingsState<SectionSettings>(
module,
sectionId
);
const options = useMemo(() => [
{ value: 'None', label: 'None' },
...introPresets,
{ value: 'Custom', label: 'Custom' }
], [introPresets]);
const onCategoryChange = useCallback((text: string) => {
updateLocalAndScheduleGlobal({ category: text });
}, [updateLocalAndScheduleGlobal]);
const typeOptions = [
{ value: 'general', label: 'General' },
{ value: 'academic', label: 'Academic' }
];
const onTypeChange = useCallback((option: { value: string | null, label: string }) => {
dispatch({
type: 'UPDATE_MODULE',
payload: { module, updates: { type: option.value as "academic" | "general" | undefined } }
});
}, [dispatch, module]);
const onIntroOptionChange = useCallback((option: { value: string | null, label: string }) => {
let updates: Partial<SectionSettings> = { introOption: option };
switch (option.label) {
case 'None':
updates.currentIntro = undefined;
break;
case 'Custom':
updates.currentIntro = localSettings.customIntro;
break;
default:
const selectedPreset = introPresets.find(preset => preset.label === option.label);
if (selectedPreset) {
updates.currentIntro = selectedPreset.value!
.replace('{part}', sectionLabel)
.replace('{label}', examLabel);
}
}
updateLocalAndScheduleGlobal(updates);
}, [updateLocalAndScheduleGlobal, localSettings.customIntro, introPresets, sectionLabel, examLabel]);
const onCustomIntroChange = useCallback((text: string) => {
updateLocalAndScheduleGlobal({
introOption: { value: 'Custom', label: 'Custom' },
customIntro: text,
currentIntro: text
});
}, [updateLocalAndScheduleGlobal]);
return (
<div className={`flex flex-col gap-8 border bg-ielts-${module}/20 rounded-3xl p-8 w-1/3 h-fit`}>
<div className={`w-full flex justify-center text-ielts-${module} font-bold text-xl`}>{sectionLabel} Settings</div>
<div className="flex flex-col gap-4">
<Dropdown
title="Category"
module={module}
open={localSettings.isCategoryDropdownOpen}
setIsOpen={(isOpen: boolean) => updateLocalAndScheduleGlobal({ isCategoryDropdownOpen: isOpen }, false)}
>
<Input
key={`section-${sectionId}`}
type="text"
placeholder="Category"
name="category"
onChange={onCategoryChange}
roundness="full"
value={localSettings.category || ''}
/>
</Dropdown>
{["reading", "writing"].includes(module) && <Dropdown
title="Type"
module={module}
open={localSettings.isTypeDropdownOpen}
setIsOpen={(isOpen: boolean) => updateLocalAndScheduleGlobal({ isTypeDropdownOpen: isOpen }, false)}
>
<Select
options={typeOptions}
onChange={(o) => onTypeChange({ value: o!.value, label: o!.label })}
value={typeOptions.find(o => o.value === type)}
/>
</Dropdown>}
<Dropdown
title="Divider"
module={module}
open={localSettings.isIntroDropdownOpen}
setIsOpen={(isOpen: boolean) => updateLocalAndScheduleGlobal({ isIntroDropdownOpen: isOpen }, false)}
>
<div className="flex flex-col gap-3 w-full">
<Select
options={options}
onChange={(o) => onIntroOptionChange({ value: o!.value, label: o!.label })}
value={localSettings.introOption}
/>
{localSettings.introOption && localSettings.introOption.value !== "None" && (
<AutoExpandingTextArea
key={`section-${sectionId}`}
value={localSettings.currentIntro || ''}
onChange={onCustomIntroChange}
/>
)}
</div>
</Dropdown>
{children}
<div className="flex flex-row justify-between mt-4">
<button
className={clsx(
"flex items-center justify-center px-4 py-2 text-white rounded-xl transition-colors duration-300",
`bg-ielts-${module}/70 border border-ielts-${module} hover:bg-ielts-${module} disabled:bg-ielts-${module}/30`,
"disabled:cursor-not-allowed disabled:text-gray-200"
)}
onClick={submitModule}
disabled={!canSubmit}
>
<FaFileUpload className="mr-2" size={18} />
Submit Module as Exam
</button>
<button
className={clsx(
"flex items-center justify-center px-4 py-2 text-white rounded-xl transition-colors duration-300",
`bg-ielts-${module}/70 border border-ielts-${module} hover:bg-ielts-${module} disabled:bg-ielts-${module}/30`,
"disabled:cursor-not-allowed disabled:text-gray-200"
)}
onClick={preview}
disabled={!canPreview}
>
<FaEye className="mr-2" size={18} />
Preview Module
</button>
</div>
</div>
</div>
);
};
export default SettingsEditor;

View File

@@ -0,0 +1,405 @@
import { Exercise, InteractiveSpeakingExercise, LevelExam, LevelPart, SpeakingExercise } from "@/interfaces/exam";
import SettingsEditor from ".";
import Option from "@/interfaces/option";
import Dropdown from "@/components/Dropdown";
import clsx from "clsx";
import ExercisePicker from "../ExercisePicker";
import useExamEditorStore from "@/stores/examEditor";
import useSettingsState from "../Hooks/useSettingsState";
import { LevelSectionSettings } from "@/stores/examEditor/types";
import { toast } from "react-toastify";
import axios from "axios";
import { playSound } from "@/utils/sound";
import { useRouter } from "next/router";
import { usePersistentExamStore } from "@/stores/exam";
import openDetachedTab from "@/utils/popout";
import ListeningComponents from "./listening/components";
import ReadingComponents from "./reading/components";
import SpeakingComponents from "./speaking/components";
import SectionPicker from "./Shared/SectionPicker";
const LevelSettings: React.FC = () => {
const router = useRouter();
const {
setExam,
setExerciseIndex,
setPartIndex,
setQuestionIndex,
setBgColor,
} = usePersistentExamStore();
const { currentModule, title } = useExamEditorStore();
const {
focusedSection,
difficulty,
sections,
minTimer,
isPrivate,
} = useExamEditorStore(state => state.modules[currentModule]);
const { localSettings, updateLocalAndScheduleGlobal } = useSettingsState<LevelSectionSettings>(
currentModule,
focusedSection
);
const section = sections.find((section) => section.sectionId == focusedSection);
const focusedExercise = section?.focusedExercise;
if (section === undefined) return <></>;
const currentSection = section.state as LevelPart;
const readingSection = section.readingSection;
const listeningSection = section.listeningSection;
const canPreviewOrSubmit = sections.length > 0 && sections.some(s => {
const part = s.state as LevelPart;
return part.exercises.length > 0 && part.exercises.every((exercise) => {
if (exercise.type === 'speaking') {
return exercise.title !== '' &&
exercise.text !== '' &&
exercise.video_url !== '' &&
exercise.prompts.every(prompt => prompt !== '');
} else if (exercise.type === 'interactiveSpeaking') {
if ('first_title' in exercise && 'second_title' in exercise) {
return exercise.first_title !== '' &&
exercise.second_title !== '' &&
exercise.prompts.every(prompt => prompt.video_url !== '') &&
exercise.prompts.length > 2;
}
return exercise.title !== '' &&
exercise.prompts.every(prompt => prompt.video_url !== '');
}
return true;
});
});
const submitLevel = async () => {
if (title === "") {
toast.error("Enter a title for the exam!");
return;
}
const partsWithMissingAudio = sections.some(s => {
const part = s.state as LevelPart;
return part.audio && !part.audio.source;
});
if (partsWithMissingAudio) {
toast.error("There are parts with missing audio recordings. Either generate them or remove the listening sections.");
return;
}
try {
const audioFormData = new FormData();
const videoFormData = new FormData();
const audioMap = new Map<number, string>();
const videoMap = new Map<string, string>();
const partsWithAudio = sections.filter(s => (s.state as LevelPart).audio?.source);
await Promise.all(
partsWithAudio.map(async (section) => {
const levelPart = section.state as LevelPart;
const blobUrl = levelPart.audio!.source;
const response = await fetch(blobUrl);
const blob = await response.blob();
audioFormData.append('file', blob, 'audio.mp3');
audioMap.set(section.sectionId, blobUrl);
})
);
await Promise.all(
sections.flatMap(async (section) => {
const levelPart = section.state as LevelPart;
return Promise.all(
levelPart.exercises.map(async (exercise, exerciseIndex) => {
if (exercise.type === "speaking") {
if (exercise.video_url) {
const response = await fetch(exercise.video_url);
const blob = await response.blob();
videoFormData.append('file', blob, 'video.mp4');
videoMap.set(`${section.sectionId}-${exerciseIndex}`, exercise.video_url);
}
} else if (exercise.type === "interactiveSpeaking") {
await Promise.all(
exercise.prompts.map(async (prompt, promptIndex) => {
if (prompt.video_url) {
const response = await fetch(prompt.video_url);
const blob = await response.blob();
videoFormData.append('file', blob, 'video.mp4');
videoMap.set(`${section.sectionId}-${exerciseIndex}-${promptIndex}`, prompt.video_url);
}
})
);
}
})
);
})
);
const [audioUrls, videoUrls] = await Promise.all([
audioMap.size > 0
? axios.post('/api/storage', audioFormData, {
params: { directory: 'listening_recordings' },
headers: { 'Content-Type': 'multipart/form-data' }
}).then(response => response.data.urls)
: [],
videoMap.size > 0
? axios.post('/api/storage', videoFormData, {
params: { directory: 'speaking_videos' },
headers: { 'Content-Type': 'multipart/form-data' }
}).then(response => response.data.urls)
: []
]);
const exam: LevelExam = {
parts: sections.map((s) => {
const part = s.state as LevelPart;
const audioIndex = Array.from(audioMap.entries())
.findIndex(([id]) => id === s.sectionId);
const updatedExercises = part.exercises.map((exercise, exerciseIndex) => {
if (exercise.type === "speaking") {
const videoIndex = Array.from(videoMap.entries())
.findIndex(([key]) => key === `${s.sectionId}-${exerciseIndex}`);
return {
...exercise,
video_url: videoIndex !== -1 ? videoUrls[videoIndex] : exercise.video_url
};
} else if (exercise.type === "interactiveSpeaking") {
const updatedPrompts = exercise.prompts.map((prompt, promptIndex) => {
const videoIndex = Array.from(videoMap.entries())
.findIndex(([key]) => key === `${s.sectionId}-${exerciseIndex}-${promptIndex}`);
return {
...prompt,
video_url: videoIndex !== -1 ? videoUrls[videoIndex] : prompt.video_url
};
});
return {
...exercise,
prompts: updatedPrompts
};
}
return exercise;
});
return {
...part,
audio: part.audio ? {
...part.audio,
source: audioIndex !== -1 ? audioUrls[audioIndex] : part.audio.source
} : undefined,
exercises: updatedExercises,
intro: s.settings.currentIntro,
category: s.settings.category
};
}).filter(part => part.exercises.length > 0),
isDiagnostic: false,
minTimer,
module: "level",
id: title,
difficulty,
private: isPrivate,
};
const result = await axios.post('/api/exam/level', exam);
playSound("sent");
toast.success(`Submitted Exam ID: ${result.data.id}`);
Array.from(audioMap.values()).forEach(url => {
URL.revokeObjectURL(url);
});
Array.from(videoMap.values()).forEach(url => {
URL.revokeObjectURL(url);
});
} catch (error: any) {
console.error('Error submitting exam:', error);
toast.error(
"Something went wrong while submitting, please try again later."
);
}
};
const preview = () => {
setExam({
parts: sections.map((s) => {
const part = s.state as LevelPart;
return {
...part,
intro: s.settings.currentIntro,
category: s.settings.category
};
}),
minTimer,
module: "level",
id: title,
isDiagnostic: false,
variant: undefined,
difficulty,
private: isPrivate,
} as LevelExam);
setExerciseIndex(0);
setQuestionIndex(0);
setPartIndex(0);
openDetachedTab("popout?type=Exam&module=level", router)
}
const speakingExercise = focusedExercise === undefined ? undefined : currentSection.exercises.find((ex) => ex.id === focusedExercise.id) as SpeakingExercise | InteractiveSpeakingExercise;
return (
<SettingsEditor
sectionLabel={`Part ${focusedSection}`}
sectionId={focusedSection}
module="level"
introPresets={[]}
preview={preview}
canPreview={canPreviewOrSubmit}
canSubmit={canPreviewOrSubmit}
submitModule={submitLevel}
>
<div>
<Dropdown title="Add Level Exercises" className={
clsx(
"w-full font-semibold flex justify-between items-center p-4 bg-gradient-to-r border",
"bg-ielts-level/70 border-ielts-level hover:bg-ielts-level",
"text-white shadow-md transition-all duration-300",
localSettings.isLevelDropdownOpen ? "rounded-t-lg" : "rounded-lg"
)
}
contentWrapperClassName={"pt-4 px-2 bg-white rounded-b-lg shadow-md transition-all duration-300 ease-in-out"}
open={localSettings.isLevelDropdownOpen}
setIsOpen={(isOpen: boolean) => updateLocalAndScheduleGlobal({ isLevelDropdownOpen: isOpen }, false)}
>
<ExercisePicker
module="level"
sectionId={focusedSection}
/>
</Dropdown>
</div>
<div>
<Dropdown title="Add Reading Exercises" className={
clsx(
"w-full font-semibold flex justify-between items-center p-4 bg-gradient-to-r border",
"bg-ielts-reading/70 border-ielts-reading hover:bg-ielts-reading",
"text-white shadow-md transition-all duration-300",
localSettings.isReadingDropdownOpen ? "rounded-t-lg" : "rounded-lg"
)
}
contentWrapperClassName={"pt-4 px-2 bg-white rounded-b-lg shadow-md transition-all duration-300 ease-in-out"}
open={localSettings.isReadingDropdownOpen}
setIsOpen={(isOpen: boolean) => updateLocalAndScheduleGlobal({ isReadingDropdownOpen: isOpen }, false)}
>
<div className="space-y-2 px-2 pb-2">
<SectionPicker {...{ module: "reading", sectionId: focusedSection, localSettings, updateLocalAndScheduleGlobal }} />
<ReadingComponents
{...{ localSettings, updateLocalAndScheduleGlobal, currentSection, generatePassageDisabled: readingSection === undefined, levelId: readingSection, level: true }}
/>
</div>
</Dropdown>
</div>
<div>
<Dropdown title="Add Listening Exercises" className={
clsx(
"w-full font-semibold flex justify-between items-center p-4 bg-gradient-to-r border",
"bg-ielts-listening/70 border-ielts-listening hover:bg-ielts-listening",
"text-white shadow-md transition-all duration-300",
localSettings.isListeningDropdownOpen ? "rounded-t-lg" : "rounded-lg"
)
}
contentWrapperClassName={"pt-4 px-2 bg-white rounded-b-lg shadow-md transition-all duration-300 ease-in-out"}
open={localSettings.isListeningDropdownOpen}
setIsOpen={(isOpen: boolean) => updateLocalAndScheduleGlobal({ isListeningDropdownOpen: isOpen }, false)}
>
<div className="space-y-2 px-2 pb-2">
<SectionPicker {...{ module: "listening", sectionId: focusedSection, localSettings, updateLocalAndScheduleGlobal }} />
<ListeningComponents
{...{ localSettings, updateLocalAndScheduleGlobal, currentSection, audioContextDisabled: listeningSection === undefined, levelId: listeningSection, level: true }}
/>
</div>
</Dropdown>
</div>
<div>
<Dropdown title="Add Writing Exercises" className={
clsx(
"w-full font-semibold flex justify-between items-center p-4 bg-gradient-to-r border",
"bg-ielts-writing/70 border-ielts-writing hover:bg-ielts-writing",
"text-white shadow-md transition-all duration-300",
localSettings.isWritingDropdownOpen ? "rounded-t-lg" : "rounded-lg"
)
}
contentWrapperClassName={"pt-4 px-2 bg-white rounded-b-lg shadow-md transition-all duration-300 ease-in-out"}
open={localSettings.isWritingDropdownOpen}
setIsOpen={(isOpen: boolean) => updateLocalAndScheduleGlobal({ isWritingDropdownOpen: isOpen }, false)}
>
<ExercisePicker
module="writing"
sectionId={focusedSection}
levelSectionId={focusedSection}
level
/>
</Dropdown>
</div >
<div>
<Dropdown title="Add Speaking Exercises" className={
clsx(
"w-full font-semibold flex justify-between items-center p-4 bg-gradient-to-r border",
"bg-ielts-speaking/70 border-ielts-speaking hover:bg-ielts-speaking",
"text-white shadow-md transition-all duration-300",
localSettings.isSpeakingDropdownOpen ? "rounded-t-lg" : "rounded-lg"
)
}
contentWrapperClassName={"pt-4 px-2 bg-white rounded-b-lg shadow-md transition-all duration-300 ease-in-out"}
open={localSettings.isSpeakingDropdownOpen}
setIsOpen={(isOpen: boolean) => updateLocalAndScheduleGlobal({ isSpeakingDropdownOpen: isOpen }, false)}
>
<div className="space-y-2 px-2 pb-2">
<Dropdown title="Exercises" className={
clsx(
"w-full font-semibold flex justify-between items-center p-4 bg-gradient-to-r border",
"bg-ielts-speaking/70 border-ielts-speaking hover:bg-ielts-speaking",
"text-white shadow-md transition-all duration-300",
localSettings.isSpeakingExercisesOpen ? "rounded-t-lg" : "rounded-lg"
)
}
contentWrapperClassName={"pt-4 px-2 bg-white rounded-b-lg shadow-md transition-all duration-300 ease-in-out border border-ielts-speaking"}
open={localSettings.isSpeakingExercisesOpen}
setIsOpen={(isOpen: boolean) => updateLocalAndScheduleGlobal({ isSpeakingExercisesOpen: isOpen }, false)}
>
<ExercisePicker
module="speaking"
sectionId={focusedSection}
levelSectionId={focusedSection}
level
/>
</Dropdown>
{speakingExercise !== undefined &&
<Dropdown title={`Configure Speaking Exercise #${Number(focusedExercise!.questionId) + 1}`} className={
clsx(
"w-full font-semibold flex justify-between items-center p-4 bg-gradient-to-r border",
"bg-ielts-speaking/70 border-ielts-speaking hover:bg-ielts-speaking",
"text-white shadow-md transition-all duration-300",
localSettings.isConfigureExercisesOpen ? "rounded-t-lg" : "rounded-lg"
)
}
contentWrapperClassName={"pt-4 px-2 bg-white rounded-b-lg shadow-md transition-all duration-300 ease-in-out border border-ielts-speaking"}
open={localSettings.isConfigureExercisesOpen}
setIsOpen={(isOpen: boolean) => updateLocalAndScheduleGlobal({ isConfigureExercisesOpen: isOpen }, false)}
>
<div className="space-y-2 px-2 pb-2">
<SpeakingComponents
{...{ localSettings, updateLocalAndScheduleGlobal, section: speakingExercise, id: speakingExercise.id, sectionId: focusedSection }}
level
/>
</div>
</Dropdown>
}
</div>
</Dropdown>
</div>
</SettingsEditor >
);
};
export default LevelSettings;

View File

@@ -0,0 +1,148 @@
import Button from '@/components/Low/Button';
import Modal from '@/components/Modal';
import dynamic from 'next/dynamic';
import React, { useCallback, useState } from 'react';
import { MdAudioFile, MdCloudUpload, MdDelete } from 'react-icons/md';
const Waveform = dynamic(() => import("@/components/Waveform"), { ssr: false });
interface AudioUploadProps {
isOpen: boolean;
audioFile: string | undefined;
setIsOpen: React.Dispatch<React.SetStateAction<boolean>>;
onFileSelect: (file: File | null) => void;
transcribeAudio: () => void;
setAudioUrl: React.Dispatch<React.SetStateAction<string | undefined>>;
}
const AudioUpload: React.FC<AudioUploadProps> = ({ isOpen, audioFile, setIsOpen, onFileSelect, transcribeAudio, setAudioUrl }) => {
const [isDragging, setIsDragging] = useState(false);
const [error, setError] = useState<string | null>(null);
const handleDrag = useCallback((e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
}, []);
const handleDragIn = useCallback((e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
setIsDragging(true);
}, []);
const handleDragOut = useCallback((e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
setIsDragging(false);
}, []);
const validateFile = (file: File): boolean => {
if (!file.type.startsWith('audio/')) {
setError('Please upload an audio file');
return false;
}
setError(null);
return true;
};
const handleDrop = useCallback((e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
setIsDragging(false);
setError(null);
const file = e.dataTransfer.files?.[0];
if (file && validateFile(file)) {
onFileSelect(file);
}
}, [onFileSelect]);
const handleFileUpload = (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
if (file && validateFile(file)) {
onFileSelect(file);
}
};
const handleRemoveAudio = () => {
onFileSelect(null);
};
return (
<Modal isOpen={isOpen} onClose={() => setIsOpen(false)}>
<div className="w-full space-y-4">
{!audioFile && (
<div
className={`relative border-2 border-dashed rounded-lg p-8 text-center
${isDragging
? 'border-blue-500 bg-blue-50'
: 'border-gray-300 hover:border-gray-400'
}
transition-all duration-200 ease-in-out`}
onDragEnter={handleDragIn}
onDragLeave={handleDragOut}
onDragOver={handleDrag}
onDrop={handleDrop}
>
<input
type="file"
accept="audio/*"
onChange={handleFileUpload}
className="absolute inset-0 w-full h-full opacity-0 cursor-pointer"
title="Choose audio file"
/>
<div className="space-y-4">
<div className="flex justify-center">
{error ? (
<MdAudioFile className="w-16 h-16 text-red-500" />
) : (
<MdCloudUpload className="w-16 h-16 text-gray-400" />
)}
</div>
<div className="space-y-2">
<h3 className="text-lg font-medium text-gray-700">
{error ? error : 'Upload Audio File'}
</h3>
<p className="text-sm text-gray-500">
Drag and drop your audio file here, or click to select
</p>
</div>
</div>
</div>
)}
{audioFile && (
<div className="space-y-4">
<div className="flex justify-between items-center">
<h3 className="text-lg font-medium text-gray-700">Audio Upload</h3>
<button
onClick={handleRemoveAudio}
className="flex items-center gap-2 px-3 py-1.5 text-sm text-white bg-red-500 hover:bg-red-600 rounded-md transition-colors duration-200 w-36"
>
<MdDelete className="w-4 h-4" />
Remove Audio
</button>
</div>
<Waveform
variant='edit'
audio={audioFile}
waveColor="#ddd"
progressColor="#4a90e2"
setAudioUrl={setAudioUrl}
/>
<div className="flex w-full justify-between pt-8">
<Button color="purple" onClick={() => setIsOpen(false)} variant="outline" className="max-w-[200px] self-end w-full">
Cancel
</Button>
<Button color="purple" onClick={()=> { transcribeAudio(); setIsOpen(false);}} className="max-w-[200px] self-end w-full">
Upload
</Button>
</div>
</div>
)}
</div>
</Modal>
);
};
export default AudioUpload;

View File

@@ -0,0 +1,330 @@
import Dropdown from "../Shared/SettingsDropdown";
import ExercisePicker from "../../ExercisePicker";
import GenerateBtn from "../Shared/GenerateBtn";
import { useCallback, useState } from "react";
import { generate } from "../Shared/Generate";
import { LevelSectionSettings, ListeningSectionSettings } from "@/stores/examEditor/types";
import useExamEditorStore from "@/stores/examEditor";
import { LevelPart, ListeningPart, Script } from "@/interfaces/exam";
import Input from "@/components/Low/Input";
import axios from "axios";
import { toast } from "react-toastify";
import { playSound } from "@/utils/sound";
import { FaFileUpload } from "react-icons/fa";
import clsx from "clsx";
import AudioUpload from "./AudioUpload";
import { downloadBlob } from "@/utils/evaluation";
import { BsArrowRepeat } from "react-icons/bs";
interface Props {
localSettings: ListeningSectionSettings | LevelSectionSettings;
updateLocalAndScheduleGlobal: (updates: Partial<ListeningSectionSettings | LevelSectionSettings>, schedule?: boolean) => void;
currentSection: ListeningPart | LevelPart;
audioContextDisabled?: boolean;
levelId?: number;
level?: boolean;
}
const ListeningComponents: React.FC<Props> = ({ currentSection, localSettings, updateLocalAndScheduleGlobal, levelId, level = false, audioContextDisabled = false }) => {
const { currentModule, dispatch, modules } = useExamEditorStore();
const {
focusedSection,
difficulty,
} = useExamEditorStore(state => state.modules[currentModule]);
const [originalAudioUrl, setOriginalAudioUrl] = useState<string | undefined>();
const [audioUrl, setAudioUrl] = useState<string | undefined>();
const [isUploaderOpen, setIsUploaderOpen] = useState(false);
const [isUploading, setIsUploading] = useState(false);
const generateScript = useCallback(() => {
if (audioUrl) {
URL.revokeObjectURL(audioUrl);
setAudioUrl(undefined);
dispatch({
type: "UPDATE_SECTION_STATE",
payload: {
sectionId: focusedSection,
module: "listening",
update: {
audio: undefined
}
}
});
}
generate(
levelId ? levelId : focusedSection,
"listening",
"listeningScript",
{
method: 'GET',
queryParams: {
difficulty,
...(localSettings.listeningTopic && { topic: localSettings.listeningTopic })
}
},
(data: any) => [{
script: data.dialog
}],
level ? focusedSection : undefined,
level
);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [localSettings.listeningTopic, difficulty, focusedSection, levelId, level]);
const onTopicChange = useCallback((listeningTopic: string) => {
updateLocalAndScheduleGlobal({ listeningTopic });
}, [updateLocalAndScheduleGlobal]);
const generateAudio = useCallback(async (sectionId: number) => {
let body: any;
if ([1, 3].includes(levelId ? levelId : focusedSection)) {
body = { conversation: currentSection.script }
} else {
body = { monologue: currentSection.script }
}
try {
if (level) {
dispatch({
type: "UPDATE_SECTION_SINGLE_FIELD", payload: {
sectionId, module: "level", field: "levelGenerating", value:
[...modules["level"].sections.find((s) => s.sectionId === sectionId)!.levelGenerating, "audio"]
}
});
} else {
dispatch({ type: "UPDATE_SECTION_SINGLE_FIELD", payload: { sectionId, module: currentModule, field: "generating", value: "audio" } });
}
const response = await axios.post(
'/api/exam/media/listening',
body,
{
responseType: 'arraybuffer',
headers: {
'Accept': 'audio/mpeg'
},
}
);
const blob = new Blob([response.data], { type: 'audio/mpeg' });
const url = URL.createObjectURL(blob);
if (currentSection.audio?.source) {
URL.revokeObjectURL(currentSection.audio?.source)
}
dispatch({
type: "UPDATE_SECTION_STATE",
payload: {
sectionId,
module: level ? "level" : "listening",
update: {
audio: {
source: url,
repeatableTimes: 3
}
}
}
});
playSound("check");
toast.success('Audio generated successfully!');
} catch (error: any) {
toast.error('Failed to generate audio');
} finally {
if (level) {
dispatch({
type: "UPDATE_SECTION_SINGLE_FIELD", payload: {
sectionId, module: "level", field: "levelGenerating", value:
[...modules["level"].sections.find((s) => s.sectionId === sectionId)!.levelGenerating.filter(g => g !== "audio")]
}
});
} else {
dispatch({ type: "UPDATE_SECTION_SINGLE_FIELD", payload: { sectionId, module: currentModule, field: "generating", value: undefined } });
}
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [currentSection?.script, dispatch, level, levelId]);
const handleFileSelect = (file: File | null) => {
if (file) {
const url = URL.createObjectURL(file);
setOriginalAudioUrl(url);
setAudioUrl(url);
dispatch({
type: "UPDATE_SECTION_STATE",
payload: {
sectionId: focusedSection,
module: "listening",
update: {
audio: {
source: url,
repeatableTimes: 3
}
}
}
});
} else {
if (audioUrl) {
URL.revokeObjectURL(audioUrl);
URL.revokeObjectURL(originalAudioUrl!);
dispatch({
type: "UPDATE_SECTION_STATE",
payload: {
sectionId: focusedSection,
module: "listening",
update: { audio: undefined }
}
});
}
setAudioUrl(undefined);
setOriginalAudioUrl(undefined);
}
};
const transcribeAudio = async () => {
try {
setIsUploading(true);
dispatch({type: "UPDATE_SECTION_SINGLE_FIELD", payload: {module: "listening", sectionId: focusedSection, field: "scriptLoading", value: true}})
const formData = new FormData();
const audioBlob = await downloadBlob(audioUrl!);
const audioFile = new File([audioBlob], "audio");
formData.append("audio", audioFile);
const config = {
headers: {
"Content-Type": "multipart/form-data",
},
};
const response = await axios.post(`/api/transcribe`, formData, config);
dispatch({
type: "UPDATE_SECTION_STATE",
payload: {
sectionId: focusedSection,
module: "listening",
update: {
script: (response.data as any).dialog as Script,
audio: { source: audioUrl!, repeatableTimes: 3 }
}
}
});
} catch (error) {
toast.error("An unexpected error has occurred, try again later!");
} finally {
setIsUploading(false);
dispatch({type: "UPDATE_SECTION_SINGLE_FIELD", payload: {module: "listening", sectionId: focusedSection, field: "scriptLoading", value: false}})
}
};
return (
<>
<AudioUpload isOpen={isUploaderOpen} setIsOpen={setIsUploaderOpen} audioFile={originalAudioUrl} onFileSelect={handleFileSelect} transcribeAudio={transcribeAudio} setAudioUrl={setAudioUrl} />
<Dropdown
title="Audio Context"
module="listening"
open={localSettings.isAudioContextOpen}
disabled={audioContextDisabled}
setIsOpen={(isOpen: boolean) => updateLocalAndScheduleGlobal({ isAudioContextOpen: isOpen }, false)}
contentWrapperClassName={level ? `border border-ielts-listening` : ''}
>
<div className="flex flex-row gap-2 items-center px-2 pb-4">
<div className="flex flex-col flex-grow gap-4 px-2">
<label className="font-normal text-base text-mti-gray-dim">Topic (Optional)</label>
<Input
key={`section-${focusedSection}`}
type="text"
placeholder="Topic"
name="category"
onChange={onTopicChange}
roundness="full"
value={localSettings.listeningTopic}
/>
</div>
<div className="flex self-end h-16 mb-1">
<GenerateBtn
module="listening"
genType="listeningScript"
sectionId={focusedSection}
generateFnc={generateScript}
level={level}
disabled={isUploading}
/>
</div>
</div>
<div className="flex justify-center text-mti-gray-dim font-semibold">Or</div>
<div className="flex flex-col w-full gap-2 px-2 pb-4">
<div className="flex flex-row items-center text-mti-gray-dim justify-between w-full gap-4 py-2 pl-2">
<div className="flex-1 bg-gray-100 px-3.5 py-2.5 rounded-lg border border-gray-300">
Import your own audio file
</div>
<div className="flex self-end h-16 mb-1 flex-shrink-0">
<button
className={clsx(
"flex items-center w-[140px] justify-center px-4 py-2 text-white rounded-xl transition-colors duration-300 text-lg disabled:cursor-not-allowed",
"bg-ielts-listening/70 border border-ielts-listening hover:bg-ielts-listening disabled:bg-ielts-listening/40"
)}
onClick={() => setIsUploaderOpen(true)}
>
<div className="flex flex-row">
{isUploading ? (
<BsArrowRepeat className="mr-2 text-white animate-spin" size={25} />
) : (
<>
<FaFileUpload className="mr-2" size={24} />
<span>Upload</span>
</>
)}
</div>
</button>
</div>
</div>
</div >
</Dropdown >
<Dropdown
title="Add Exercises"
module="listening"
open={localSettings.isListeningTopicOpen}
setIsOpen={(isOpen: boolean) => updateLocalAndScheduleGlobal({ isListeningTopicOpen: isOpen }, false)}
disabled={currentSection === undefined || currentSection.script === undefined && currentSection.audio === undefined}
contentWrapperClassName={level ? `border border-ielts-listening` : ''}
>
<ExercisePicker
module="listening"
sectionId={levelId !== undefined ? levelId : focusedSection}
extraArgs={{ script: currentSection === undefined || currentSection.audio === undefined ? "" : currentSection.script }}
levelSectionId={focusedSection}
level={level}
/>
</Dropdown>
<Dropdown
title="Generate Audio"
module="listening"
open={localSettings.isAudioGenerationOpen}
setIsOpen={(isOpen: boolean) => updateLocalAndScheduleGlobal({ isAudioGenerationOpen: isOpen }, false)}
disabled={currentSection === undefined || currentSection.script === undefined || currentSection.exercises.length === 0 || audioUrl !== undefined}
contentWrapperClassName={level ? `border border-ielts-listening` : ''}
>
<div className="flex flex-row items-center text-mti-gray-dim justify-center mb-4 gap-4 p-2">
<span className="bg-gray-100 px-3.5 py-2.5 rounded-lg border border-gray-300">
Generate audio recording for this section
</span>
<GenerateBtn
module="listening"
genType="audio"
sectionId={levelId ? levelId : focusedSection}
generateFnc={generateAudio}
/>
</div>
</Dropdown>
</>
);
};
export default ListeningComponents;

View File

@@ -0,0 +1,227 @@
import Dropdown from "../Shared/SettingsDropdown";
import ExercisePicker from "../../ExercisePicker";
import SettingsEditor from "..";
import GenerateBtn from "../Shared/GenerateBtn";
import { useCallback, useState } from "react";
import { generate } from "../Shared/Generate";
import { Generating, LevelSectionSettings, ListeningSectionSettings } from "@/stores/examEditor/types";
import Option from "@/interfaces/option";
import useExamEditorStore from "@/stores/examEditor";
import useSettingsState from "../../Hooks/useSettingsState";
import { ListeningExam, ListeningPart } from "@/interfaces/exam";
import Input from "@/components/Low/Input";
import openDetachedTab from "@/utils/popout";
import { useRouter } from "next/router";
import axios from "axios";
import { usePersistentExamStore } from "@/stores/exam";
import { playSound } from "@/utils/sound";
import { toast } from "react-toastify";
import ListeningComponents from "./components";
const ListeningSettings: React.FC = () => {
const router = useRouter();
const { currentModule, title } = useExamEditorStore();
const {
focusedSection,
difficulty,
sections,
minTimer,
isPrivate,
instructionsState
} = useExamEditorStore(state => state.modules[currentModule]);
const {
setExam,
setExerciseIndex,
setPartIndex,
setQuestionIndex,
setBgColor,
} = usePersistentExamStore();
const { localSettings, updateLocalAndScheduleGlobal } = useSettingsState<ListeningSectionSettings>(
currentModule,
focusedSection
);
const currentSection = sections.find((section) => section.sectionId == focusedSection)?.state as ListeningPart;
const defaultPresets: Option[] = [
{
label: "Preset: Listening Section 1",
value: "Welcome to {part} of the {label}. You will hear a conversation between two people in an everyday social context. This may include topics such as making arrangements or bookings, inquiring about services, or handling basic transactions."
},
{
label: "Preset: Listening Section 2",
value: "Welcome to {part} of the {label}. You will hear a monologue set in an everyday social context. This may include a speech about local facilities, arrangements for social occasions, or general announcements."
},
{
label: "Preset: Listening Section 3",
value: "Welcome to {part} of the {label}. You will hear a conversation between up to four people in an educational or training context. This may include discussions about assignments, research projects, or course requirements."
},
{
label: "Preset: Listening Section 4",
value: "Welcome to {part} of the {label}. You will hear an academic lecture or talk on a specific subject."
}
];
const submitListening = async () => {
if (title === "") {
toast.error("Enter a title for the exam!");
return;
}
try {
const sectionsWithAudio = sections.filter(s => (s.state as ListeningPart).audio?.source);
if (instructionsState.chosenOption.value === "Custom" && !instructionsState.currentInstructionsURL.startsWith("blob:")) {
toast.error("Generate the custom instructions audio first!");
return;
}
if (sectionsWithAudio.length > 0) {
let instructionsURL = instructionsState.currentInstructionsURL;
if (instructionsState.chosenOption.value === "Custom") {
const instructionsFormData = new FormData();
const instructionsResponse = await fetch(instructionsState.currentInstructionsURL);
const instructionsBlob = await instructionsResponse.blob();
instructionsFormData.append('file', instructionsBlob, 'audio.mp3');
const instructionsUploadResponse = await axios.post('/api/storage', instructionsFormData, {
params: {
directory: 'listening_instructions'
},
headers: {
'Content-Type': 'multipart/form-data'
}
});
instructionsURL = instructionsUploadResponse.data.urls[0];
}
const formData = new FormData();
const sectionMap = new Map<number, string>();
await Promise.all(
sectionsWithAudio.map(async (section) => {
const listeningPart = section.state as ListeningPart;
const blobUrl = listeningPart.audio!.source;
const response = await fetch(blobUrl);
const blob = await response.blob();
formData.append('file', blob, 'audio.mp3');
sectionMap.set(section.sectionId, blobUrl);
})
);
const response = await axios.post('/api/storage', formData, {
params: {
directory: 'listening_recordings'
},
headers: {
'Content-Type': 'multipart/form-data'
}
});
const { urls } = response.data;
const exam: ListeningExam = {
parts: sectionsWithAudio.map((s) => {
const part = s.state as ListeningPart;
const index = Array.from(sectionMap.entries())
.findIndex(([id]) => id === s.sectionId);
return {
...part,
audio: part.audio ? {
...part.audio,
source: index !== -1 ? urls[index] : part.audio.source
} : undefined,
intro: s.settings.currentIntro,
category: s.settings.category
};
}),
isDiagnostic: false,
minTimer,
module: "listening",
id: title,
variant: sections.length === 4 ? "full" : "partial",
difficulty,
private: isPrivate,
instructions: instructionsURL
};
const result = await axios.post('/api/exam/listening', exam);
playSound("sent");
toast.success(`Submitted Exam ID: ${result.data.id}`);
} else {
toast.error('No audio sections found in the exam! Please either import them or generate them.');
}
} catch (error: any) {
console.error('Error submitting exam:', error);
toast.error(
"Something went wrong while submitting, please try again later."
);
}
};
const preview = () => {
if (instructionsState.chosenOption.value === "Custom" && !instructionsState.currentInstructionsURL.startsWith("blob:")) {
toast.error("Generate the custom instructions audio first!");
return;
}
setExam({
parts: sections.map((s) => {
const exercise = s.state as ListeningPart;
return {
...exercise,
intro: s.settings.currentIntro,
category: s.settings.category
};
}),
minTimer,
module: "listening",
id: title,
isDiagnostic: false,
variant: sections.length === 4 ? "full" : "partial",
difficulty,
private: isPrivate,
instructions: instructionsState.currentInstructionsURL
} as ListeningExam);
setExerciseIndex(0);
setQuestionIndex(0);
setPartIndex(0);
setBgColor("bg-white");
openDetachedTab("popout?type=Exam&module=listening", router)
}
const canPreview = sections.some(
(s) => (s.state as ListeningPart).exercises && (s.state as ListeningPart).exercises.length > 0
);
const canSubmit = sections.every(
(s) => (s.state as ListeningPart).exercises &&
(s.state as ListeningPart).exercises.length > 0 &&
(s.state as ListeningPart).audio !== undefined
);
return (
<SettingsEditor
sectionLabel={`Section ${focusedSection}`}
sectionId={focusedSection}
module="listening"
introPresets={[defaultPresets[focusedSection - 1]]}
canPreview={canPreview}
canSubmit={canSubmit}
preview={preview}
submitModule={submitListening}
>
<ListeningComponents
{...{ localSettings, updateLocalAndScheduleGlobal, currentSection }}
/>
</SettingsEditor>
);
};
export default ListeningSettings;

View File

@@ -0,0 +1,107 @@
import React, { useCallback } from "react";
import Dropdown from "../Shared/SettingsDropdown";
import Input from "@/components/Low/Input";
import ExercisePicker from "../../ExercisePicker";
import { generate } from "../Shared/Generate";
import GenerateBtn from "../Shared/GenerateBtn";
import { LevelPart, ReadingPart } from "@/interfaces/exam";
import { LevelSectionSettings, ReadingSectionSettings } from "@/stores/examEditor/types";
import useExamEditorStore from "@/stores/examEditor";
interface Props {
localSettings: ReadingSectionSettings | LevelSectionSettings;
updateLocalAndScheduleGlobal: (updates: Partial<ReadingSectionSettings | LevelSectionSettings>, schedule?: boolean) => void;
currentSection: ReadingPart | LevelPart;
generatePassageDisabled?: boolean;
levelId?: number;
level?: boolean;
}
const ReadingComponents: React.FC<Props> = ({localSettings, updateLocalAndScheduleGlobal, currentSection, levelId, level = false, generatePassageDisabled = false}) => {
const { currentModule } = useExamEditorStore();
const {
focusedSection,
difficulty,
} = useExamEditorStore(state => state.modules[currentModule]);
const generatePassage = useCallback(() => {
generate(
levelId ? levelId : focusedSection,
"reading",
"passage",
{
method: 'GET',
queryParams: {
difficulty,
...(localSettings.readingTopic && { topic: localSettings.readingTopic })
}
},
(data: any) => [{
title: data.title,
text: data.text
}],
level ? focusedSection : undefined,
level
);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [localSettings.readingTopic, difficulty, focusedSection, levelId]);
const onTopicChange = useCallback((readingTopic: string) => {
updateLocalAndScheduleGlobal({ readingTopic });
}, [updateLocalAndScheduleGlobal]);
return (
<>
<Dropdown
title="Generate Passage"
module="reading"
open={localSettings.isPassageOpen}
setIsOpen={(isOpen: boolean) => updateLocalAndScheduleGlobal({ isPassageOpen: isOpen }, false)}
contentWrapperClassName={level ? `border border-ielts-reading`: ''}
disabled={generatePassageDisabled}
>
<div className="flex flex-row gap-2 items-center px-2 pb-4">
<div className="flex flex-col flex-grow gap-4 px-2">
<label className="font-normal text-base text-mti-gray-dim">Topic (Optional)</label>
<Input
key={`section-${focusedSection}`}
type="text"
placeholder="Topic"
name="category"
onChange={onTopicChange}
roundness="full"
value={localSettings.readingTopic}
/>
</div>
<div className="flex self-end h-16 mb-1">
<GenerateBtn
module="reading"
genType="passage"
sectionId={focusedSection}
generateFnc={generatePassage}
level={level}
/>
</div>
</div>
</Dropdown>
<Dropdown
title="Add Exercises"
module="reading"
open={localSettings.isReadingTopicOpean}
setIsOpen={(isOpen: boolean) => updateLocalAndScheduleGlobal({ isReadingTopicOpean: isOpen })}
contentWrapperClassName={level ? `border border-ielts-reading`: ''}
disabled={currentSection === undefined || currentSection.text === undefined || currentSection.text.content === "" || currentSection.text.title === ""}
>
<ExercisePicker
module="reading"
sectionId={levelId !== undefined ? levelId : focusedSection}
extraArgs={{ text: currentSection === undefined || currentSection.text === undefined ? "" : currentSection.text.content }}
levelSectionId={focusedSection}
level={level}
/>
</Dropdown>
</>
);
};
export default ReadingComponents;

View File

@@ -0,0 +1,143 @@
import React from "react";
import SettingsEditor from "..";
import Option from "@/interfaces/option";
import useSettingsState from "../../Hooks/useSettingsState";
import { ReadingExam, ReadingPart } from "@/interfaces/exam";
import { ReadingSectionSettings } from "@/stores/examEditor/types";
import useExamEditorStore from "@/stores/examEditor";
import openDetachedTab from "@/utils/popout";
import { useRouter } from "next/router";
import { usePersistentExamStore } from "@/stores/exam";
import axios from "axios";
import { playSound } from "@/utils/sound";
import { toast } from "react-toastify";
import ReadingComponents from "./components";
const ReadingSettings: React.FC = () => {
const router = useRouter();
const {
setExam,
setExerciseIndex,
setPartIndex,
setQuestionIndex,
setBgColor,
} = usePersistentExamStore();
const { currentModule, title } = useExamEditorStore();
const {
focusedSection,
difficulty,
sections,
minTimer,
isPrivate,
type,
} = useExamEditorStore(state => state.modules[currentModule]);
const { localSettings, updateLocalAndScheduleGlobal } = useSettingsState<ReadingSectionSettings>(
currentModule,
focusedSection
);
const currentSection = sections.find((section) => section.sectionId == focusedSection)?.state as ReadingPart;
const defaultPresets: Option[] = [
{
label: "Preset: Reading Passage 1",
value: "Welcome to {part} of the {label}. You will read texts relating to everyday topics and situations. These may include advertisements, brochures, manuals, or official documents. Answer questions that test your ability to locate specific information and understand main ideas."
},
{
label: "Preset: Reading Passage 2",
value: "Welcome to {part} of the {label}. You will read texts dealing with general interest topics that may include news articles, company policies, or workplace documents. Answer questions testing your understanding of main ideas, specific details, and the author's views."
},
{
label: "Preset: Reading Passage 3",
value: "Welcome to {part} of the {label}. You will read longer academic texts that may include journal articles, academic essays, or research papers. Answer questions testing your ability to understand complex arguments, identify key points, and follow the development of ideas."
}
];
const canPreviewOrSubmit = sections.some(
(s) => (s.state as ReadingPart).exercises && (s.state as ReadingPart).exercises.length > 0
);
const submitReading = () => {
if (title === "") {
toast.error("Enter a title for the exam!");
return;
}
const exam: ReadingExam = {
parts: sections.map((s) => {
const exercise = s.state as ReadingPart;
return {
...exercise,
intro: localSettings.currentIntro,
category: localSettings.category
};
}),
isDiagnostic: false,
minTimer,
module: "reading",
id: title,
variant: sections.length === 3 ? "full" : "partial",
difficulty,
private: isPrivate,
type: type!
};
axios.post(`/api/exam/reading`, exam)
.then((result) => {
playSound("sent");
toast.success(`Submitted Exam ID: ${result.data.id}`);
})
.catch((error) => {
console.log(error);
toast.error(error.response.data.error || "Something went wrong while submitting, please try again later.");
})
}
const preview = () => {
setExam({
parts: sections.map((s) => {
const exercises = s.state as ReadingPart;
return {
...exercises,
intro: s.settings.currentIntro,
category: s.settings.category
};
}),
minTimer,
module: "reading",
id: title,
isDiagnostic: false,
variant: undefined,
difficulty,
private: isPrivate,
type: type!
} as ReadingExam);
setExerciseIndex(0);
setQuestionIndex(0);
setPartIndex(0);
setBgColor("bg-white");
openDetachedTab("popout?type=Exam&module=reading", router)
}
return (
<SettingsEditor
sectionLabel={`Passage ${focusedSection}`}
sectionId={focusedSection}
module="reading"
introPresets={[defaultPresets[focusedSection - 1]]}
preview={preview}
canPreview={canPreviewOrSubmit}
canSubmit={canPreviewOrSubmit}
submitModule={submitReading}
>
<ReadingComponents
{...{ localSettings, updateLocalAndScheduleGlobal, currentSection }}
/>
</SettingsEditor>
);
};
export default ReadingSettings;

View File

@@ -0,0 +1,371 @@
import useExamEditorStore from "@/stores/examEditor";
import { LevelSectionSettings, SpeakingSectionSettings } from "@/stores/examEditor/types";
import { useCallback, useEffect, useState } from "react";
import { generate } from "../Shared/Generate";
import Dropdown from "../Shared/SettingsDropdown";
import Input from "@/components/Low/Input";
import GenerateBtn from "../Shared/GenerateBtn";
import clsx from "clsx";
import { FaFemale, FaMale } from "react-icons/fa";
import { Difficulty, InteractiveSpeakingExercise, LevelPart, SpeakingExercise } from "@/interfaces/exam";
import { toast } from "react-toastify";
import { generateVideos } from "../Shared/generateVideos";
import { Module } from "@/interfaces";
import useCanGenerate from "./useCanGenerate";
import ReactSelect, { components } from "react-select";
import { capitalize } from "lodash";
import Option from "@/interfaces/option";
import { MdSignalCellularAlt } from "react-icons/md";
export interface Avatar {
name: string;
gender: string;
}
interface Props {
localSettings: SpeakingSectionSettings | LevelSectionSettings;
updateLocalAndScheduleGlobal: (updates: Partial<SpeakingSectionSettings | LevelSectionSettings>, schedule?: boolean) => void;
section: SpeakingExercise | InteractiveSpeakingExercise | LevelPart;
level?: boolean;
module?: Module;
id?: string;
sectionId?: number;
}
const SpeakingComponents: React.FC<Props> = ({ localSettings, updateLocalAndScheduleGlobal, section, level = false, module = "speaking", id, sectionId }) => {
const { currentModule, speakingAvatars, dispatch, modules } = useExamEditorStore();
const { focusedSection, difficulty, sections } = useExamEditorStore((store) => store.modules[level ? "level" : currentModule])
const state = sections.find((s) => s.sectionId === sectionId);
const [selectedAvatar, setSelectedAvatar] = useState<Avatar | null>(null);
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"];
const difficultyOptions: Option[] = DIFFICULTIES.map(level => ({
label: level,
value: level
}));
const [specificDiff, setSpecificDiff] = useState(randomDiff);
const generateScript = useCallback((scriptSectionId: number) => {
const queryParams: {
difficulty: string[];
first_topic?: string;
second_topic?: string;
topic?: string;
} = { difficulty };
if (scriptSectionId === 1) {
if (localSettings.speakingTopic) {
queryParams['first_topic'] = localSettings.speakingTopic;
}
if (localSettings.speakingSecondTopic) {
queryParams['second_topic'] = localSettings.speakingSecondTopic;
}
} else {
if (localSettings.speakingTopic) {
queryParams['topic'] = localSettings.speakingTopic;
}
}
generate(
level ? section.sectionId! : focusedSection,
"speaking",
`${id ? `${id}-` : ''}speakingScript`,
{
method: 'GET',
queryParams
},
(data: any) => {
switch (level ? section.sectionId! : focusedSection) {
case 1:
return [{
prompts: data.questions,
first_topic: data.first_topic,
second_topic: data.second_topic,
difficulty: specificDiff.length == 2 ? specificDiff : difficulty,
}];
case 2:
return [{
topic: data.topic,
question: data.question,
prompts: data.prompts,
suffix: data.suffix,
difficulty: specificDiff.length == 2 ? specificDiff : difficulty,
}];
case 3:
return [{
title: data.topic,
prompts: data.questions,
difficulty: specificDiff.length == 2 ? specificDiff : difficulty,
}];
default:
return [data];
}
},
sectionId,
level
);
}, [difficulty, level, section.sectionId, focusedSection, id, sectionId, localSettings.speakingTopic, localSettings.speakingSecondTopic, specificDiff]);
const onTopicChange = useCallback((speakingTopic: string) => {
updateLocalAndScheduleGlobal({ speakingTopic });
}, [updateLocalAndScheduleGlobal]);
const onSecondTopicChange = useCallback((speakingSecondTopic: string) => {
updateLocalAndScheduleGlobal({ speakingSecondTopic });
}, [updateLocalAndScheduleGlobal]);
const canGenerate = useCanGenerate({
section,
sections,
id,
focusedSection
});
useEffect(() => {
if (!canGenerate) {
updateLocalAndScheduleGlobal({ isGenerateVideoOpen: false }, false);
}
}, [canGenerate, updateLocalAndScheduleGlobal]);
const generateVideoCallback = useCallback((sectionId: number) => {
if (level) {
dispatch({ type: "UPDATE_SECTION_SINGLE_FIELD", payload: { sectionId, module: "level", field: "levelGenerating", value: [...state!.levelGenerating, `${id ? `${id}-` : ''}video`] } })
} else {
dispatch({ type: "UPDATE_SECTION_SINGLE_FIELD", payload: { sectionId: focusedSection, module: "speaking", field: "generating", value: "video" } })
}
generateVideos(
section as InteractiveSpeakingExercise | SpeakingExercise,
level ? section.sectionId! : focusedSection,
selectedAvatar,
speakingAvatars
).then((results) => {
switch (level ? section.sectionId! : focusedSection) {
case 1:
case 3: {
const interactiveSection = section as InteractiveSpeakingExercise;
const updatedPrompts = interactiveSection.prompts.map((prompt, index) => ({
...prompt,
video_url: results[index].url || ''
}));
if (level) {
dispatch({
type: "UPDATE_SECTION_SINGLE_FIELD", payload: {
sectionId, field: "levelGenResults", value: [...state!.levelGenResults,
{ generating: `${id ? `${id}-` : ''}video`, result: [{ prompts: updatedPrompts }] }], module: "level"
}
})
} else {
dispatch({
type: "UPDATE_SECTION_SINGLE_FIELD", payload: {
sectionId: focusedSection, module: "speaking", field: "genResult", value:
{ generating: "video", result: [{ prompts: updatedPrompts }], module: module }
}
})
}
break;
}
case 2: {
if (results[0]?.url) {
if (level) {
dispatch({
type: "UPDATE_SECTION_SINGLE_FIELD", payload: {
sectionId, field: "levelGenResults", value: [...state!.levelGenResults,
{ generating: `${id ? `${id}-` : ''}video`, result: [{ video_url: results[0].url }] }], module: "level"
}
})
} else {
dispatch({
type: "UPDATE_SECTION_SINGLE_FIELD", payload: {
sectionId: focusedSection, module, field: "genResult", value:
{ generating: 'video', result: [{ video_url: results[0].url }], module: "speaking" }
}
})
}
}
break;
}
}
}).catch((error) => {
toast.error("Failed to generate the video, try again later!")
});
}, [level, section, focusedSection, selectedAvatar, speakingAvatars, dispatch, module, state, id]);
const secId = level ? section.sectionId! : focusedSection;
return (
<>
<Dropdown
title="Generate Script"
module="speaking"
open={localSettings.isSpeakingTopicOpen}
setIsOpen={(isOpen: boolean) => updateLocalAndScheduleGlobal({ isSpeakingTopicOpen: isOpen }, false)}
contentWrapperClassName={level ? `border border-ielts-speaking` : ''}
>
<div className="gap-4 px-2 pb-4 flex flex-col w-full">
<div className="flex flex-col flex-grow gap-4 px-2">
<label className="font-normal text-base text-mti-gray-dim">{`${secId === 1 ? "First Topic" : "Topic"}`} (Optional)</label>
<Input
key={`section-${secId}`}
type="text"
placeholder="Topic"
name="category"
onChange={onTopicChange}
roundness="xl"
value={localSettings.speakingTopic}
thin
/>
</div>
{secId === 1 &&
<div className="flex flex-col flex-grow gap-4 px-2">
<label className="font-normal text-base text-mti-gray-dim">Second Topic (Optional)</label>
<Input
key={`section-${secId}`}
type="text"
placeholder="Topic"
name="category"
onChange={onSecondTopicChange}
roundness="xl"
value={localSettings.speakingSecondTopic}
thin
/>
</div>
}
<div className="flex flex-col gap-2 px-2">
<label className="block font-normal text-base text-mti-gray-dim mb-2">Difficulty (Optional)</label>
<ReactSelect
options={difficultyOptions}
value={difficultyOptions.find(opt => opt.value === specificDiff)}
onChange={(value) => setSpecificDiff(value!.value as Difficulty)}
menuPortalTarget={document?.body}
components={{
IndicatorSeparator: null,
ValueContainer: ({ children, ...props }) => (
<components.ValueContainer {...props}>
<div className="flex flex-row gap-2 items-center pl-4">
<MdSignalCellularAlt size={14} className="text-gray-600" />
{children}
</div>
</components.ValueContainer>
)
}}
styles={{
menuPortal: (base) => ({ ...base, zIndex: 9999 }),
control: (styles) => ({
...styles,
minHeight: '50px',
border: '1px solid #e5e7eb',
borderRadius: '0.5rem',
boxShadow: 'none',
backgroundColor: 'white',
cursor: 'pointer',
'&:hover': {
border: '1px solid #e5e7eb',
}
}),
valueContainer: (styles) => ({
...styles,
padding: '0 8px',
display: 'flex',
alignItems: 'center'
}),
input: (styles) => ({
...styles,
margin: '0',
padding: '0'
}),
dropdownIndicator: (styles) => ({
...styles,
padding: '8px'
}),
option: (styles, state) => ({
...styles,
backgroundColor: state.isFocused ? "#D5D9F0" : state.isSelected ? "#7872BF" : "white",
color: state.isFocused ? "black" : styles.color,
}),
}}
className="text-sm"
/>
</div>
<div className="flex h-16 mb-1 justify-center mt-4">
<GenerateBtn
module="speaking"
genType={`${id ? `${id}-` : ''}speakingScript`}
sectionId={focusedSection}
generateFnc={generateScript}
level={level}
/>
</div>
</div>
</Dropdown>
<Dropdown
title="Generate Video"
module="speaking"
open={localSettings.isGenerateVideoOpen}
disabled={!canGenerate}
setIsOpen={(isOpen: boolean) => updateLocalAndScheduleGlobal({ isGenerateVideoOpen: isOpen }, false)}
contentWrapperClassName={level ? `border border-ielts-speaking` : ''}
>
<div className={clsx("flex items-center justify-between gap-4 px-2 pb-4")}>
<div className="relative flex-1 max-w-xs">
<select
value={selectedAvatar ? `${selectedAvatar.name}-${selectedAvatar.gender}` : ""}
onChange={(e) => {
if (e.target.value === "") {
setSelectedAvatar(null);
} else {
const [name, gender] = e.target.value.split("-");
const avatar = speakingAvatars.find(a => a.name === name && a.gender === gender);
if (avatar) setSelectedAvatar(avatar);
}
}}
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="">Select an avatar (Optional)</option>
{speakingAvatars.map((avatar) => (
<option
key={`${avatar.name}-${avatar.gender}`}
value={`${avatar.name}-${avatar.gender}`}
>
{avatar.name}
</option>
))}
</select>
<div className="absolute right-2.5 top-2.5 pointer-events-none">
{selectedAvatar && (
selectedAvatar.gender === 'male' ? (
<FaMale className="w-5 h-5 text-blue-500" />
) : (
<FaFemale className="w-5 h-5 text-pink-500" />
)
)}
</div>
</div>
<GenerateBtn
module="speaking"
genType={`${id ? `${id}-` : ''}video`}
sectionId={focusedSection}
generateFnc={generateVideoCallback}
level={level}
/>
</div>
</Dropdown>
</>
);
};
export default SpeakingComponents;

View File

@@ -0,0 +1,261 @@
import useExamEditorStore from "@/stores/examEditor";
import useSettingsState from "../../Hooks/useSettingsState";
import { SpeakingSectionSettings } from "@/stores/examEditor/types";
import Option from "@/interfaces/option";
import SettingsEditor from "..";
import { InteractiveSpeakingExercise, SpeakingExam, SpeakingExercise } from "@/interfaces/exam";
import { toast } from "react-toastify";
import { usePersistentExamStore } from "@/stores/exam";
import { useRouter } from "next/router";
import openDetachedTab from "@/utils/popout";
import axios from "axios";
import { playSound } from "@/utils/sound";
import SpeakingComponents from "./components";
export interface Avatar {
name: string;
gender: string;
}
const SpeakingSettings: React.FC = () => {
const router = useRouter();
const {
setExam,
setExerciseIndex,
setQuestionIndex,
setBgColor,
} = usePersistentExamStore();
const { title, currentModule } = useExamEditorStore();
const { focusedSection, difficulty, sections, minTimer, isPrivate } = useExamEditorStore((store) => store.modules[currentModule])
const section = sections.find((section) => section.sectionId == focusedSection)?.state;
const { localSettings, updateLocalAndScheduleGlobal } = useSettingsState<SpeakingSectionSettings>(
currentModule,
focusedSection,
);
if (section === undefined) return <></>;
const currentSection = section as SpeakingExercise | InteractiveSpeakingExercise;
const defaultPresets: Option[] = [
{
label: "Preset: Speaking Part 1",
value: "Welcome to {part} of the {label}. You will engage in a conversation about yourself and familiar topics such as your home, family, work, studies, and interests. General questions will be asked."
},
{
label: "Preset: Speaking Part 2",
value: "Welcome to {part} of the {label}. You will be given a topic card describing a particular person, object, event, or experience."
},
{
label: "Preset: Speaking Part 3",
value: "Welcome to {part} of the {label}. You will engage in an in-depth discussion about abstract ideas and issues. The examiner will ask questions that require you to explain, analyze, and speculate about various aspects of the topic."
}
];
const canPreviewOrSubmit = (() => {
return sections.every((s) => {
const section = s.state as SpeakingExercise | InteractiveSpeakingExercise;
switch (section.type) {
case 'speaking':
return section.title !== '' &&
section.text !== '' &&
section.video_url !== '' &&
section.prompts.every(prompt => prompt !== '');
case 'interactiveSpeaking':
if ('first_title' in section && 'second_title' in section) {
return section.first_title !== '' &&
section.second_title !== '' &&
section.prompts.every(prompt => prompt.video_url !== '') &&
section.prompts.length > 2;
}
return section.title !== '' &&
section.prompts.every(prompt => prompt.video_url !== '');
default:
return false;
}
});
})();
const submitSpeaking = async () => {
if (title === "") {
toast.error("Enter a title for the exam!");
return;
}
try {
const formData = new FormData();
const urlMap = new Map<string, string>();
const sectionsWithVideos = sections.filter(s => {
const exercise = s.state as SpeakingExercise | InteractiveSpeakingExercise;
if (exercise.type === "speaking") {
return exercise.video_url !== "";
}
if (exercise.type === "interactiveSpeaking") {
return exercise.prompts?.some(prompt => prompt.video_url !== "");
}
return false;
});
if (sectionsWithVideos.length === 0) {
toast.error('No video sections found in the exam! Please record or import videos.');
return;
}
await Promise.all(
sectionsWithVideos.map(async (section) => {
const exercise = section.state as SpeakingExercise | InteractiveSpeakingExercise;
if (exercise.type === "speaking") {
const response = await fetch(exercise.video_url);
const blob = await response.blob();
formData.append('file', blob, 'video.mp4');
urlMap.set(`${section.sectionId}`, exercise.video_url);
} else {
await Promise.all(
exercise.prompts.map(async (prompt, promptIndex) => {
if (prompt.video_url) {
const response = await fetch(prompt.video_url);
const blob = await response.blob();
formData.append('file', blob, 'video.mp4');
urlMap.set(`${section.sectionId}-${promptIndex}`, prompt.video_url);
}
})
);
}
})
);
const response = await axios.post('/api/storage', formData, {
params: {
directory: 'speaking_videos'
},
headers: {
'Content-Type': 'multipart/form-data'
}
});
const { urls } = response.data;
const exam: SpeakingExam = {
exercises: sections.map((s) => {
const exercise = s.state as SpeakingExercise | InteractiveSpeakingExercise;
if (exercise.type === "speaking") {
const videoIndex = Array.from(urlMap.entries())
.findIndex(([key]) => key === `${s.sectionId}`);
return {
...exercise,
video_url: videoIndex !== -1 ? urls[videoIndex] : exercise.video_url,
intro: s.settings.currentIntro,
category: s.settings.category
};
} else {
const updatedPrompts = exercise.prompts.map((prompt, promptIndex) => {
const videoIndex = Array.from(urlMap.entries())
.findIndex(([key]) => key === `${s.sectionId}-${promptIndex}`);
return {
...prompt,
video_url: videoIndex !== -1 ? urls[videoIndex] : prompt.video_url
};
});
return {
...exercise,
prompts: updatedPrompts,
intro: s.settings.currentIntro,
category: s.settings.category
};
}
}),
minTimer,
module: "speaking",
id: title,
isDiagnostic: false,
variant: undefined,
difficulty,
instructorGender: "varied",
private: isPrivate,
};
const result = await axios.post('/api/exam/speaking', exam);
playSound("sent");
toast.success(`Submitted Exam ID: ${result.data.id}`);
Array.from(urlMap.values()).forEach(url => {
URL.revokeObjectURL(url);
});
} catch (error: any) {
toast.error(
"Something went wrong while submitting, please try again later."
);
}
};
const preview = () => {
setExam({
exercises: sections
.filter((s) => {
const exercise = s.state as SpeakingExercise | InteractiveSpeakingExercise;
if (exercise.type === "speaking") {
return exercise.video_url !== "";
}
if (exercise.type === "interactiveSpeaking") {
return exercise.prompts?.every(prompt => prompt.video_url !== "");
}
return false;
})
.map((s) => {
const exercise = s.state as SpeakingExercise | InteractiveSpeakingExercise;
return {
...exercise,
intro: s.settings.currentIntro,
category: s.settings.category
};
}),
minTimer,
module: "speaking",
id: title,
isDiagnostic: false,
variant: undefined,
difficulty,
private: isPrivate,
} as SpeakingExam);
setExerciseIndex(0);
setQuestionIndex(0);
setBgColor("bg-white");
openDetachedTab("popout?type=Exam&module=speaking", router)
}
return (
<SettingsEditor
sectionLabel={`Speaking ${focusedSection}`}
sectionId={focusedSection}
module="speaking"
introPresets={[defaultPresets[focusedSection - 1]]}
preview={preview}
canPreview={canPreviewOrSubmit}
canSubmit={canPreviewOrSubmit}
submitModule={submitSpeaking}
>
<SpeakingComponents
{...{ localSettings, updateLocalAndScheduleGlobal, section: currentSection }}
/>
</SettingsEditor>
);
};
export default SpeakingSettings;

View File

@@ -0,0 +1,62 @@
import { useCallback, useEffect, useState } from 'react';
import { InteractiveSpeakingExercise, LevelPart, SpeakingExercise } from "@/interfaces/exam";
import { Section } from '@/stores/examEditor/types';
interface CheckGenerateProps {
section: Section | null;
sections: Array<{ sectionId: number; state: Section }>;
id?: string;
focusedSection: number;
}
const useCanGenerate = ({ section, sections, id, focusedSection }: CheckGenerateProps) => {
const checkCanGenerate = useCallback(() => {
if (!section) return false;
const exercise = id
? (sections.find(s => s.sectionId === 1)?.state as LevelPart)
?.exercises?.find(ex => ex.id === id) ?? section
: section;
const sectionId = id ? (exercise as SpeakingExercise | InteractiveSpeakingExercise).sectionId : focusedSection;
switch (sectionId) {
case 1: {
const currentSection = exercise as InteractiveSpeakingExercise;
return currentSection.first_title &&
currentSection.second_title &&
currentSection.prompts?.length > 2 &&
currentSection.prompts.every(prompt => prompt.text)
;
}
case 2: {
const currentSection = exercise as SpeakingExercise;
return currentSection.title &&
currentSection.text &&
currentSection.prompts?.length > 0 &&
currentSection.prompts.every(prompt => prompt)
;
}
case 3: {
const currentSection = exercise as InteractiveSpeakingExercise;
return currentSection.title &&
currentSection.prompts?.length > 0 &&
currentSection.prompts.every(prompt => prompt.text)
;
}
default:
return false;
}
}, [section, sections, id, focusedSection]);
const [canGenerate, setCanGenerate] = useState(checkCanGenerate());
useEffect(() => {
setCanGenerate(checkCanGenerate());
}, [checkCanGenerate, section]);
return canGenerate;
};
export default useCanGenerate;

View File

@@ -0,0 +1,304 @@
import React, { useCallback, useRef, useState } from "react";
import Dropdown from "../Shared/SettingsDropdown";
import Input from "@/components/Low/Input";
import { generate } from "../Shared/Generate";
import GenerateBtn from "../Shared/GenerateBtn";
import { LevelSectionSettings, WritingSectionSettings } from "@/stores/examEditor/types";
import useExamEditorStore from "@/stores/examEditor";
import { Difficulty, WritingExercise } from "@/interfaces/exam";
import clsx from "clsx";
import { FaFileUpload } from "react-icons/fa";
import ReactSelect, { components } from "react-select";
import Option from "@/interfaces/option"
import { MdSignalCellularAlt } from "react-icons/md";
import { capitalize } from "lodash";
interface Props {
localSettings: WritingSectionSettings | LevelSectionSettings;
updateLocalAndScheduleGlobal: (updates: Partial<WritingSectionSettings | LevelSectionSettings>, schedule?: boolean) => void;
currentSection?: WritingExercise;
level?: boolean;
}
const WritingComponents: React.FC<Props> = ({ localSettings, updateLocalAndScheduleGlobal, level }) => {
const { currentModule, dispatch } = useExamEditorStore();
const {
difficulty,
focusedSection,
type,
academic_url
} = useExamEditorStore((store) => store.modules["writing"]);
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"];
const difficultyOptions: Option[] = DIFFICULTIES.map(level => ({
label: level,
value: level
}));
const [specificDiff, setSpecificDiff] = useState(randomDiff);
const generatePassage = useCallback((sectionId: number) => {
if (type === "academic" && academic_url !== undefined && sectionId == 1) {
generate(
sectionId,
currentModule,
"writing",
{
method: 'POST',
queryParams: {
difficulty: specificDiff.length == 2 ? [specificDiff] : difficulty,
type: type!
},
files: {
file: academic_url!,
}
},
(data: any) => [{
prompt: data.question,
difficulty: data.difficulty
}]
)
} else {
generate(
sectionId,
currentModule,
"writing",
{
method: 'GET',
queryParams: {
difficulty: specificDiff.length == 2 ? [specificDiff] : difficulty,
type: type!,
...(localSettings.writingTopic && { topic: localSettings.writingTopic })
}
},
(data: any) => [{
prompt: data.question,
difficulty: data.difficulty
}]
);
}
}, [type, academic_url, currentModule, specificDiff, difficulty, localSettings.writingTopic]);
const onTopicChange = useCallback((writingTopic: string) => {
updateLocalAndScheduleGlobal({ writingTopic });
}, [updateLocalAndScheduleGlobal]);
const fileInputRef = useRef<HTMLInputElement>(null);
const triggerFileInput = () => {
fileInputRef.current?.click();
};
const handleFileUpload = (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
if (file) {
const blobUrl = URL.createObjectURL(file);
if (academic_url !== undefined) {
URL.revokeObjectURL(academic_url);
}
dispatch({ type: "UPDATE_MODULE", payload: { updates: { academic_url: blobUrl } } });
}
};
return (
<>
{type === "academic" && focusedSection === 1 && <Dropdown
title="Upload Image"
module={"writing"}
open={localSettings.isImageUploadOpen}
setIsOpen={(isOpen: boolean) => updateLocalAndScheduleGlobal({ isImageUploadOpen: isOpen }, false)}
contentWrapperClassName={level ? `border border-ielts-writing` : ''}
>
<div className="flex w-full flex-row gap-2 items-center px-2 pb-4">
<div className="flex w-full flex-col gap-4">
<div className="flex flex-col gap-2">
<label className="block font-normal text-base text-mti-gray-dim mb-2">Difficulty (Optional)</label>
<ReactSelect
options={difficultyOptions}
value={difficultyOptions.find(opt => opt.value === specificDiff)}
onChange={(value) => setSpecificDiff(value!.value as Difficulty)}
menuPortalTarget={document?.body}
components={{
IndicatorSeparator: null,
ValueContainer: ({ children, ...props }) => (
<components.ValueContainer {...props}>
<div className="flex flex-row gap-2 items-center pl-4">
<MdSignalCellularAlt size={14} className="text-gray-600" />
{children}
</div>
</components.ValueContainer>
)
}}
styles={{
menuPortal: (base) => ({ ...base, zIndex: 9999 }),
control: (styles) => ({
...styles,
minHeight: '50px',
border: '1px solid #e5e7eb',
borderRadius: '0.5rem',
boxShadow: 'none',
backgroundColor: 'white',
cursor: 'pointer',
'&:hover': {
border: '1px solid #e5e7eb',
}
}),
valueContainer: (styles) => ({
...styles,
padding: '0 8px',
display: 'flex',
alignItems: 'center'
}),
input: (styles) => ({
...styles,
margin: '0',
padding: '0'
}),
dropdownIndicator: (styles) => ({
...styles,
padding: '8px'
}),
option: (styles, state) => ({
...styles,
backgroundColor: state.isFocused ? "#D5D9F0" : state.isSelected ? "#7872BF" : "white",
color: state.isFocused ? "black" : styles.color,
}),
}}
className="text-sm"
/>
</div>
<div className="flex flex-row justify-between gap-4">
<span className="bg-gray-100 px-3.5 py-2.5 rounded-lg border border-gray-300 text-mti-gray-dim flex-grow">
Upload a graph, chart or diagram
</span>
<input
type="file"
accept="image/png, image/jpeg"
onChange={handleFileUpload}
style={{ display: 'none' }}
ref={fileInputRef}
/>
<button
key={`section-${focusedSection}`}
className={clsx(
"flex items-center w-[140px] px-4 py-2 text-white rounded-xl transition-colors duration-300 text-lg disabled:cursor-not-allowed",
`bg-ielts-writing/70 border border-ielts-writing hover:bg-ielts-writing disabled:bg-ielts-writing/40`,
)}
onClick={triggerFileInput}
>
<div className="flex flex-row">
<FaFileUpload className="mr-2" size={24} />
<span>Upload</span>
</div>
</button>
</div>
</div>
</div>
</Dropdown>}
{
(type !== "academic" || (type === "academic" && focusedSection == 2)) && <Dropdown
title="Generate Instructions"
module={"writing"}
open={localSettings.isWritingTopicOpen}
setIsOpen={(isOpen: boolean) => updateLocalAndScheduleGlobal({ isWritingTopicOpen: isOpen }, false)}
contentWrapperClassName={level ? `border border-ielts-writing` : ''}
>
<div className="px-2 pb-4 flex flex-col w-full">
<div className="flex flex-col gap-4">
<div className="flex flex-col gap-2">
<label className="block font-normal text-base text-mti-gray-dim mb-2">Topic (Optional)</label>
<div className="flex gap-2 min-w-0">
<Input
key={`section-${focusedSection}`}
type="text"
placeholder="Topic"
name="category"
onChange={onTopicChange}
roundness="xl"
value={localSettings.writingTopic}
thin
/>
</div>
</div>
<div className="flex flex-col gap-2">
<label className="block font-normal text-base text-mti-gray-dim mb-2">Difficulty (Optional)</label>
<ReactSelect
options={difficultyOptions}
value={difficultyOptions.find(opt => opt.value === specificDiff)}
onChange={(value) => setSpecificDiff(value!.value as Difficulty)}
menuPortalTarget={document?.body}
components={{
IndicatorSeparator: null,
ValueContainer: ({ children, ...props }) => (
<components.ValueContainer {...props}>
<div className="flex flex-row gap-2 items-center pl-4">
<MdSignalCellularAlt size={14} className="text-gray-600" />
{children}
</div>
</components.ValueContainer>
)
}}
styles={{
menuPortal: (base) => ({ ...base, zIndex: 9999 }),
control: (styles) => ({
...styles,
minHeight: '50px',
border: '1px solid #e5e7eb',
borderRadius: '0.5rem',
boxShadow: 'none',
backgroundColor: 'white',
cursor: 'pointer',
'&:hover': {
border: '1px solid #e5e7eb',
}
}),
valueContainer: (styles) => ({
...styles,
padding: '0 8px',
display: 'flex',
alignItems: 'center'
}),
input: (styles) => ({
...styles,
margin: '0',
padding: '0'
}),
dropdownIndicator: (styles) => ({
...styles,
padding: '8px'
}),
option: (styles, state) => ({
...styles,
backgroundColor: state.isFocused ? "#D5D9F0" : state.isSelected ? "#7872BF" : "white",
color: state.isFocused ? "black" : styles.color,
}),
}}
className="text-sm"
/>
</div>
<div className="flex w-full h-full justify-center items-center mt-2">
<GenerateBtn
genType="writing"
module={"writing"}
sectionId={focusedSection}
generateFnc={generatePassage}
/>
</div>
</div>
</div>
</Dropdown>
}
</>
);
};
export default WritingComponents;

View File

@@ -0,0 +1,169 @@
import React, { useCallback, useEffect, useState } from "react";
import SettingsEditor from "..";
import Option from "@/interfaces/option";
import useSettingsState from "../../Hooks/useSettingsState";
import { WritingSectionSettings } from "@/stores/examEditor/types";
import useExamEditorStore from "@/stores/examEditor";
import { useRouter } from "next/router";
import { usePersistentExamStore } from "@/stores/exam";
import openDetachedTab from "@/utils/popout";
import { WritingExam, WritingExercise } from "@/interfaces/exam";
import axios from "axios";
import { playSound } from "@/utils/sound";
import { toast } from "react-toastify";
import WritingComponents from "./components";
const WritingSettings: React.FC = () => {
const router = useRouter();
const [canPreviewOrSubmit, setCanPreviewOrSubmit] = useState(false);
const { currentModule, title } = useExamEditorStore();
const {
minTimer,
difficulty,
isPrivate,
sections,
focusedSection,
type,
academic_url
} = useExamEditorStore((store) => store.modules["writing"]);
const states = sections.flatMap((s) => s.state) as WritingExercise[];
const {
setExam,
setExerciseIndex,
} = usePersistentExamStore();
const { localSettings, updateLocalAndScheduleGlobal } = useSettingsState<WritingSectionSettings>(
currentModule,
focusedSection,
);
const defaultPresets: Option[] = [
{
label: "Preset: Writing Task 1",
value: "Welcome to {part} of the {label}. You will write a letter of at least 150 words in response to a given situation. Your letter may be personal, semi-formal, or formal. You have 20 minutes for this task."
},
{
label: "Preset: Writing Task 2",
value: "Welcome to {part} of the {label}. You will write a semi-formal/neutral essay of at least 250 words on a topic of general interest. Discuss the given opinion, argument, or problem. Organize your ideas clearly and support them with relevant examples. You have 40 minutes for this task."
}
];
useEffect(() => {
setCanPreviewOrSubmit(states.some((s) => s.prompt !== ""))
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [states.some((s) => s.prompt !== "")])
const openTab = () => {
setExam({
exercises: sections.map((s, index) => {
const exercise = s.state as WritingExercise;
if (type === "academic" && index == 0 && academic_url) {
exercise["attachment"] = {
url: academic_url,
description: "Visual Information"
}
}
return {
...exercise,
intro: s.settings.currentIntro,
category: s.settings.category
};
}),
minTimer,
module: "writing",
id: title,
isDiagnostic: false,
variant: undefined,
difficulty,
private: isPrivate,
type: type!
});
setExerciseIndex(0);
openDetachedTab("popout?type=Exam&module=writing", router)
}
const submitWriting = async () => {
if (title === "") {
toast.error("Enter a title for the exam!");
return;
}
try {
let firebase_url: string | undefined = undefined;
if (type === "academic" && academic_url) {
const formData = new FormData();
const fetchedBlob = await fetch(academic_url);
const blob = await fetchedBlob.blob();
formData.append('file', blob);
const response = await axios.post('/api/storage', formData, {
params: {
directory: 'writing_attachments'
},
headers: {
'Content-Type': 'multipart/form-data'
}
});
firebase_url = response.data.urls[0];
}
const exam: WritingExam = {
exercises: sections.map((s, index) => {
const exercise = s.state as WritingExercise;
if (index == 0 && firebase_url) {
exercise["attachment"] = {
url: firebase_url,
description: "Visual Information"
}
}
return {
...exercise,
intro: localSettings.currentIntro,
category: localSettings.category
};
}),
minTimer,
module: "writing",
id: title,
isDiagnostic: false,
variant: undefined,
difficulty,
private: isPrivate,
type: type!
};
const result = await axios.post(`/api/exam/writing`, exam)
playSound("sent");
toast.success(`Submitted Exam ID: ${result.data.id}`);
} catch (error: any) {
console.error('Error submitting exam:', error);
toast.error(
"Something went wrong while submitting, please try again later."
);
}
}
return (
<SettingsEditor
sectionLabel={`Task ${focusedSection}`}
sectionId={focusedSection}
module="writing"
introPresets={[defaultPresets[focusedSection - 1]]}
preview={openTab}
canPreview={canPreviewOrSubmit}
canSubmit={canPreviewOrSubmit}
submitModule={submitWriting}
>
<WritingComponents
{...{ localSettings, updateLocalAndScheduleGlobal }}
/>
</SettingsEditor>
);
};
export default WritingSettings;

View File

@@ -0,0 +1,44 @@
import dynamic from 'next/dynamic';
import React, { useState } from 'react';
interface AudioFile {
id: string;
file: File;
}
const Waveform = dynamic(() => import("@/components/Waveform"), {ssr: false});
const MultipleAudioUploader = () => {
const [audioFiles, setAudioFiles] = useState<AudioFile[]>([]);
const handleFileUpload = (event: React.ChangeEvent<HTMLInputElement>) => {
const files = Array.from(event.target.files || []);
const newAudioFiles = files.map((file) => ({
id: URL.createObjectURL(file),
file,
}));
setAudioFiles((prev) => [...prev, ...newAudioFiles]);
};
return (
<div>
<input type="file" multiple accept="audio/*" onChange={handleFileUpload} />
<div className="audio-list">
{audioFiles.map((audio) => (
<div key={audio.id} className="audio-item">
<h3>{audio.file.name}</h3>
<Waveform
variant='edit'
audio={audio.id}
waveColor="#ddd"
progressColor="#4a90e2"
/>
</div>
))}
</div>
</div>
);
};
export default MultipleAudioUploader;

View File

@@ -0,0 +1,118 @@
import { useState, useEffect } from 'react';
import { MdClose, MdDelete } from 'react-icons/md';
import clsx from 'clsx';
interface ConfirmDeleteBtnProps {
onDelete: () => void;
size?: 'sm' | 'md' | 'lg';
position?: 'top-right' | 'top-left' | 'bottom-right' | 'bottom-left';
className?: string;
}
const ConfirmDeleteBtn: React.FC<ConfirmDeleteBtnProps> = ({
onDelete,
size = 'sm',
position = 'top-right',
className
}) => {
const [showConfirm, setShowConfirm] = useState(false);
useEffect(() => {
const handleClickOutside = () => setShowConfirm(false);
if (showConfirm) {
document.addEventListener('click', handleClickOutside);
return () => document.removeEventListener('click', handleClickOutside);
}
}, [showConfirm]);
const handleClick = (e: React.MouseEvent) => {
e.stopPropagation();
setShowConfirm(true);
};
const handleConfirm = (e: React.MouseEvent) => {
e.stopPropagation();
onDelete();
setShowConfirm(false);
};
const handleCancel = (e: React.MouseEvent) => {
e.stopPropagation();
setShowConfirm(false);
};
const sizeClasses = {
sm: 'p-0.5',
md: 'p-1',
lg: 'p-1.5'
};
const iconSizes = {
sm: 14,
md: 16,
lg: 18
};
const positionClasses = {
'top-right': '-right-1 -top-1',
'top-left': '-left-1 -top-1',
'bottom-right': '-right-1 -bottom-1',
'bottom-left': '-left-1 -bottom-1'
};
return (
<div
className={clsx(
"absolute",
positionClasses[position],
"z-10",
className
)}
onClick={e => e.stopPropagation()}
>
{!showConfirm && (
<button
onClick={handleClick}
className={clsx(
sizeClasses[size],
"rounded-full",
"bg-white/90 shadow-sm",
"text-gray-400 hover:text-red-600",
"transition-all duration-150",
"opacity-0 group-hover:opacity-100",
"hover:scale-110"
)}
title="Remove"
>
<MdClose size={iconSizes[size]} />
</button>
)}
{showConfirm && (
<div className={clsx(
"flex items-center gap-1",
"bg-white rounded-lg shadow-lg",
sizeClasses[size]
)}>
<button
onClick={handleConfirm}
className="p-1 rounded-md bg-red-50 text-red-600 hover:bg-red-100 transition-colors"
title="Confirm remove"
>
<MdDelete size={iconSizes[size]} />
</button>
<button
onClick={handleCancel}
className="p-1 rounded-md bg-gray-50 text-gray-600 hover:bg-gray-100 transition-colors"
title="Cancel"
>
<MdClose size={iconSizes[size]} />
</button>
</div>
)}
</div>
);
};
export default ConfirmDeleteBtn;

View File

@@ -0,0 +1,28 @@
interface Props {
type: string;
firstId: string;
lastId: string;
prompt: string;
}
const previewLabel = (text: string) => {
return <>
&quot;{text !== undefined ? text.replaceAll('\\n', ' ').split(' ').slice(0, 15).join(' ') : ""}...&quot;
</>
}
const label = (type: string, firstId: string, lastId: string) => {
return `${type} #${firstId} ${firstId === lastId ? '' : `- #${lastId}`}`;
}
const ExerciseLabel: React.FC<Props> = ({type, firstId, lastId, prompt}) => {
return (
<div className="flex w-full justify-between items-center mr-4">
<span className="font-semibold">{label(type, firstId, lastId)}</span>
<div className="text-sm font-light italic">{previewLabel(prompt)}</div>
</div>
);
}
export default ExerciseLabel;

View File

@@ -0,0 +1,161 @@
import { Module } from "@/interfaces";
import clsx from "clsx";
import { ReactNode, useEffect, useState } from "react";
import { MdDelete, MdEdit, MdEditOff, MdRefresh, MdSave, MdSignalCellularAlt } from "react-icons/md";
import { HiOutlineClipboardCheck, HiOutlineClipboardList } from "react-icons/hi";
import { Difficulty } from "@/interfaces/exam";
import Option from "@/interfaces/option";
import ReactSelect, { components } from "react-select";
interface Props {
title: string;
description: string;
editing: boolean;
difficulty?: Difficulty;
saveDifficulty?: (diff: Difficulty) => void;
module?: Module;
handleSave: () => void;
handleDiscard: () => void;
handleDelete?: () => void;
handlePractice?: () => void;
handleEdit?: () => void;
isEvaluationEnabled?: boolean;
children?: ReactNode;
}
const Header: React.FC<Props> = ({
title, description, editing, difficulty, saveDifficulty, isEvaluationEnabled, handleSave, handleDiscard, handleDelete, handleEdit, handlePractice, children, module
}) => {
const DIFFICULTIES: Difficulty[] = ["A1", "A2", "B1", "B2", "C1", "C2"];
const difficultyOptions: Option[] = DIFFICULTIES.map(level => ({
label: level,
value: level
}));
return (
<div className="flex flex-col md:flex-row items-start md:items-center mb-6 text-sm">
<div>
<h1 className="text-2xl font-bold text-gray-800">{title}</h1>
<p className="text-gray-600 mt-1">{description}</p>
</div>
<div className="flex w-[50%] ml-auto justify-end flex-wrap gap-2">
<div className="flex flex-wrap gap-2 justify-end">
<button
onClick={handleSave}
disabled={!editing}
className={
clsx("px-3 py-2 rounded-lg flex items-center gap-1 transition-all duration-200 min-w-[90px] justify-center",
editing ? 'bg-green-500 text-white hover:bg-green-600' : 'bg-gray-100 text-gray-400 cursor-not-allowed'
)}
>
<MdSave size={18} />
Save
</button>
<button
onClick={handleDiscard}
disabled={!editing}
className={clsx(
"px-3 py-2 rounded-lg flex items-center gap-1 transition-all duration-200 min-w-[90px] justify-center",
editing ? 'bg-gray-500 text-white hover:bg-gray-600' : 'bg-gray-100 text-gray-400 cursor-not-allowed'
)}
>
<MdRefresh size={18} />
Discard
</button>
{handleEdit && (
<button
onClick={handleEdit}
className={`px-3 py-2 bg-ielts-${module}/80 text-white hover:bg-ielts-${module} rounded-lg transition-all duration-200 flex items-center gap-1 min-w-[90px] justify-center`}
>
{editing ? <MdEditOff size={18} /> : <MdEdit size={18} />}
Edit
</button>
)}
{handlePractice &&
<button
onClick={handlePractice}
className={clsx(
"px-3 py-2 rounded-lg flex items-center gap-1 transition-all duration-200 min-w-[90px] justify-center",
isEvaluationEnabled
? 'bg-amber-500 text-white hover:bg-amber-600'
: 'bg-gray-200 text-gray-600 hover:bg-gray-300'
)}
>
{isEvaluationEnabled ? <HiOutlineClipboardCheck size={18} /> : <HiOutlineClipboardList size={18} />}
{isEvaluationEnabled ? 'Graded' : 'Practice'}
</button>
}
{handleDelete && (
<button
onClick={handleDelete}
className="px-3 py-2 bg-white border border-red-500 text-red-500 hover:bg-red-50 rounded-lg transition-all duration-200 flex items-center gap-1 min-w-[90px] justify-center"
>
<MdDelete size={18} />
Delete
</button>
)}
{difficulty !== undefined && (
<div className="w-[92px]">
<ReactSelect
options={difficultyOptions}
value={difficultyOptions.find(opt => opt.value === difficulty)}
onChange={(value) => saveDifficulty!(value!.value as Difficulty)}
menuPortalTarget={document?.body}
components={{
IndicatorSeparator: null,
ValueContainer: ({ children, ...props }) => (
<components.ValueContainer {...props}>
<div className="flex flex-row gap-2 items-center pl-0.5">
<MdSignalCellularAlt size={14} className="text-gray-600" />
{children}
</div>
</components.ValueContainer>
)
}}
styles={{
menuPortal: (base) => ({ ...base, zIndex: 9999 }),
control: (styles) => ({
...styles,
minHeight: '40px',
border: '1px solid #e5e7eb',
borderRadius: '0.5rem',
boxShadow: 'none',
backgroundColor: '#f3f4f6',
cursor: 'pointer',
'&:hover': {
border: '1px solid #e5e7eb',
}
}),
valueContainer: (styles) => ({
...styles,
padding: '0 8px',
display: 'flex',
alignItems: 'center'
}),
input: (styles) => ({
...styles,
margin: '0',
padding: '0'
}),
dropdownIndicator: (styles) => ({
...styles,
padding: '8px'
}),
option: (styles, state) => ({
...styles,
backgroundColor: state.isFocused ? "#D5D9F0" : state.isSelected ? "#7872BF" : "white",
color: state.isFocused ? "black" : styles.color,
}),
}}
className="text-sm"
/>
</div>
)}
{children}
</div>
</div>
</div>
);
}
export default Header;

View File

@@ -0,0 +1,49 @@
import { useState } from "react";
import Dropdown from "@/components/Dropdown";
import clsx from "clsx";
interface Props {
title: string;
content: string;
open?: boolean;
setIsOpen?: React.Dispatch<React.SetStateAction<boolean>>;
}
const Passage: React.FC<Props> = ({ title, content, open: externalOpen, setIsOpen: externalSetIsOpen }) => {
const [internalOpen, setInternalOpen] = useState(false);
const isOpen = externalOpen ?? internalOpen;
const setIsOpen = externalSetIsOpen ?? setInternalOpen;
const paragraphs = content.split('\n\n');
return (
<Dropdown
title={title}
className={clsx(
"bg-white p-6 w-full items-center",
isOpen ? "rounded-t-lg border-b border-gray-200" : "rounded-lg shadow-lg"
)}
titleClassName="text-2xl font-semibold text-gray-800"
contentWrapperClassName="p-6 bg-white rounded-b-lg shadow-md transition-all duration-300 ease-in-out"
open={isOpen}
setIsOpen={setIsOpen}
>
<div>
{paragraphs.map((paragraph, index) => (
<p
key={index}
className={clsx(
"text-justify",
index < paragraphs.length - 1 ? 'mb-4' : 'mb-6'
)}
>
{paragraph.trim()}
</p>
))}
</div>
</Dropdown>
);
}
export default Passage;

View File

@@ -0,0 +1,84 @@
import React, { useState, ReactNode, useRef, useEffect } from 'react';
import { animated, useSpring } from '@react-spring/web';
interface DropdownProps {
title: ReactNode;
open: boolean;
toggleOpen: () => void;
className: string;
children: ReactNode;
}
// Would be way too messy to add the center the title in the other Dropdown
const SectionDropdown: React.FC<DropdownProps> = ({
title,
open,
className,
children,
toggleOpen
}) => {
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 + 0);
} else {
// Fallback for browsers that don't support borderBoxSize
const height = entry.contentRect.height;
setContentHeight(height + 0);
}
}
});
resizeObserver.observe(contentRef.current);
}
return () => {
if (resizeObserver) {
resizeObserver.disconnect();
}
};
}, []);
const springProps = useSpring({
height: open ? contentHeight : 0,
opacity: open ? 1 : 0,
config: { tension: 300, friction: 30 }
});
return (
<div>
<button
onClick={() => toggleOpen()}
className={className}
>
<div className='flex flex-row w-full items-center'>
<p className='flex-grow'>{title}</p>
<svg
className={`w-4 h-4 transform transition-transform ${open ? '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}>
{children}
</div>
</animated.div>
</div>
);
};
export default SectionDropdown;

View File

@@ -0,0 +1,36 @@
import { useSortable } from "@dnd-kit/sortable";
import { FaArrowsAlt } from "react-icons/fa";
import { CSS } from '@dnd-kit/utilities';
const SortableSection: React.FC<{
id: string;
children: React.ReactNode;
}> = ({ id, children }) => {
const {
attributes,
listeners,
setNodeRef,
transform,
transition,
isDragging,
} = useSortable({ id });
const style = {
transform: CSS.Transform.toString(transform),
transition,
opacity: isDragging ? 0.5 : 1,
};
return (
<div ref={setNodeRef} style={style} className="flex items-center mb-2 bg-white p-4 rounded shadow">
<div {...attributes} {...listeners} className="cursor-move mr-2">
<div className="cursor-move mr-3 p-2 rounded bg-gray-200 text-gray-500 hover:bg-gray-300">
<FaArrowsAlt size={16} />
</div>
</div>
<div className="flex-grow">{children}</div>
</div>
);
};
export default SortableSection;

View File

@@ -0,0 +1,297 @@
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
import Button from "@/components/Low/Button";
import Modal from "@/components/Modal";
import useExamEditorStore from "@/stores/examEditor";
import { PRESETS, isValidPresetID } from "./presets";
import AudioPlayer from "@/components/Low/AudioPlayer";
import Select from "@/components/Low/Select";
import AutoExpandingTextArea from "@/components/Low/AutoExpandingTextarea";
import { ListeningInstructionsState } from "@/stores/examEditor/types";
import { debounce } from "lodash";
import axios from "axios";
import { toast } from "react-toastify";
import { playSound } from "@/utils/sound";
import { BsArrowRepeat } from "react-icons/bs";
import { GiBrain } from "react-icons/gi";
const ListeningInstructions: React.FC = () => {
const { dispatch } = useExamEditorStore();
const [loading, setLoading] = useState(false);
const { instructionsState: globalInstructions, sections } = useExamEditorStore(s => s.modules["listening"]);
const [localInstructions, setLocalInstructions] = useState<ListeningInstructionsState>(globalInstructions);
const pendingUpdatesRef = useRef<Partial<ListeningInstructionsState>>({});
useEffect(() => {
if (globalInstructions) {
setLocalInstructions(globalInstructions);
}
}, [globalInstructions]);
const debouncedUpdateGlobal = useMemo(() => {
return debounce(() => {
if (Object.keys(pendingUpdatesRef.current).length > 0) {
dispatch({
type: "UPDATE_MODULE",
payload: {
module: "listening",
updates: {
instructionsState: {
...globalInstructions,
...pendingUpdatesRef.current
}
}
}
});
pendingUpdatesRef.current = {};
}
}, 1000);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [dispatch, globalInstructions]);
const updateInstructionsAndSchedule = useCallback((
updates: Partial<ListeningInstructionsState> | ((prev: ListeningInstructionsState) => Partial<ListeningInstructionsState>),
schedule: boolean = true
) => {
const newUpdates = typeof updates === 'function' ? updates(localInstructions) : updates;
setLocalInstructions(prev => ({
...prev,
...newUpdates
}));
if (schedule) {
pendingUpdatesRef.current = {
...pendingUpdatesRef.current,
...newUpdates
};
debouncedUpdateGlobal();
} else {
dispatch({
type: "UPDATE_MODULE",
payload: {
module: "listening",
updates: {
instructionsState: {
...globalInstructions,
...newUpdates
}
}
}
});
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [dispatch, debouncedUpdateGlobal]);
const setIsOpen = useCallback((isOpen: boolean) => {
updateInstructionsAndSchedule({ isInstructionsOpen: isOpen }, false);
}, [updateInstructionsAndSchedule]);
const onOptionChange = useCallback((option: { value: string, label: string }) => {
const sectionIds = sections.map(s => s.sectionId);
const presetID = [...sectionIds].sort((a, b) => a - b).join('_');
const preset = isValidPresetID(presetID) ? PRESETS[presetID] : null;
updateInstructionsAndSchedule(prev => {
const updates: Partial<ListeningInstructionsState> = {
chosenOption: option
};
if (option.value === "Automatic" && preset) {
updates.currentInstructions = preset.text;
updates.currentInstructionsURL = preset.url;
} else if (option.value === "Custom") {
updates.currentInstructions = prev.customInstructions || "";
updates.currentInstructionsURL = "";
}
return updates;
}, false);
}, [sections, updateInstructionsAndSchedule]);
const onCustomInstructionChange = useCallback((text: string) => {
updateInstructionsAndSchedule({
chosenOption: { value: 'Custom', label: 'Custom' },
customInstructions: text,
currentInstructions: text
});
}, [updateInstructionsAndSchedule]);
useEffect(() => {
const sectionIds = sections.map(s => s.sectionId);
const presetID = [...sectionIds].sort((a, b) => a - b).join('_');
if (isValidPresetID(presetID)) {
const preset = PRESETS[presetID];
updateInstructionsAndSchedule(prev => {
const updates: Partial<ListeningInstructionsState> = {
presetInstructions: preset.text,
presetInstructionsURL: preset.url,
};
if (prev.chosenOption?.value === "Automatic") {
updates.currentInstructions = preset.text;
updates.currentInstructionsURL = preset.url;
}
return updates;
}, false);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [sections.length]);
useEffect(() => {
return () => {
if (Object.keys(pendingUpdatesRef.current).length > 0) {
dispatch({
type: "UPDATE_MODULE",
payload: {
module: "listening",
updates: {
instructionsState: {
...globalInstructions,
...pendingUpdatesRef.current
}
}
}
});
}
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [dispatch]);
const options = [
{ value: 'Automatic', label: 'Automatic' },
{ value: 'Custom', label: 'Custom' }
];
const generateInstructionsMP3 = useCallback(async () => {
if (!localInstructions.currentInstructions) {
toast.error('Please enter instructions text first');
return;
}
setLoading(true);
try {
const response = await axios.post(
'/api/exam/media/instructions',
{
text: localInstructions.currentInstructions
},
{
responseType: 'arraybuffer',
headers: {
'Accept': 'audio/mpeg'
}
}
);
if (localInstructions.currentInstructionsURL?.startsWith('blob:')) {
URL.revokeObjectURL(localInstructions.currentInstructionsURL);
}
const blob = new Blob([response.data], { type: 'audio/mpeg' });
const url = URL.createObjectURL(blob);
updateInstructionsAndSchedule({
customInstructionsURL: url,
currentInstructionsURL: url
}, false);
playSound("check");
toast.success('Audio generated successfully!');
} catch (error: any) {
toast.error('Failed to generate audio');
} finally {
setLoading(false);
}
}, [localInstructions.currentInstructions, localInstructions.currentInstructionsURL, updateInstructionsAndSchedule]);
return (
<>
<Modal
isOpen={localInstructions.isInstructionsOpen}
onClose={() => setIsOpen(false)}
>
<div className="flex flex-col gap-6 p-6 w-full">
<div className="flex flex-col gap-2">
<h2 className="text-xl font-semibold text-gray-800">Listening Instructions</h2>
<p className="text-sm text-gray-500">Choose instruction type or customize your own</p>
</div>
<div className="flex flex-col gap-4">
<div className="flex flex-col gap-1.5">
<label className="text-sm font-medium text-gray-700">Instruction Type</label>
<Select
options={options}
onChange={(o) => onOptionChange({ value: o!.value || "", label: o!.label })}
value={localInstructions.chosenOption}
className="w-full"
/>
</div>
<div className="flex flex-col gap-2">
<div className="flex flex-col flex-grow gap-1.5">
<label className="text-sm font-medium text-gray-700">Instructions Text</label>
<div className="flex flex-row gap-2">
<div className="flex flex-grow bg-gray-50 rounded-lg p-4 border border-gray-200 items-center">
<AutoExpandingTextArea
value={localInstructions.currentInstructions || ''}
onChange={onCustomInstructionChange}
className="bg-transparent resize-none w-full focus:outline-none"
placeholder="Enter custom instructions here..."
/>
</div>
{localInstructions.chosenOption?.value === 'Custom' && (
<div className="flex items-center">
<Button
onClick={generateInstructionsMP3}
disabled={loading}
customColor="bg-ielts-listening/70 hover:bg-ielts-listening border-ielts-listening"
className="text-white rounded-md"
>
{loading ? (
<div className="flex items-center justify-center">
<BsArrowRepeat className="text-white animate-spin" size={25} />
</div>
) : (
<div className="flex flex-row items-center">
<GiBrain className="mr-2" size={24} />
<span>Generate</span>
</div>
)}
</Button>
</div>
)}
</div>
</div>
</div>
{(localInstructions.chosenOption?.value === 'Automatic' ||
(localInstructions.chosenOption?.value === 'Custom' && localInstructions.currentInstructionsURL.startsWith("blob:")) && localInstructions.currentInstructionsURL !== '') && (
<div className="flex flex-col gap-1.5">
<label className="text-sm font-medium text-gray-700">Audio Preview</label>
<div className="bg-gray-50 rounded-lg p-4 border border-gray-200">
<AudioPlayer
src={localInstructions.currentInstructionsURL ?? ''}
color="listening"
/>
</div>
</div>
)}
</div>
</div>
</Modal>
<Button
onClick={() => setIsOpen(true)}
customColor="bg-ielts-listening/70 hover:bg-ielts-listening border-ielts-listening"
className="text-white self-end"
>
Audio Instructions
</Button>
</>
);
};
export default ListeningInstructions;

Some files were not shown because too many files have changed in this diff Show More