Compare commits

...

838 Commits

Author SHA1 Message Date
Tiago Ribeiro
2bfb94d01b Merged in develop (pull request #162)
Implemented limit 5 sessions per User
2025-03-05 08:22:28 +00:00
Francisco Lima
df84aaadf4 Merged in limit5SessionsUser (pull request #161)
Implemented limit 5 sessions per User

Approved-by: Tiago Ribeiro
2025-03-05 08:17:05 +00:00
José Lima
2789660e8a Implemented limit 5 sessions per User 2025-03-05 04:42:54 +00:00
Tiago Ribeiro
a78e6eb64f Merged in develop (pull request #160)
Develop
2025-03-04 23:24:59 +00:00
Francisco Lima
6c7d189957 Merged in fixStudentPerformanceFreeze (pull request #159)
FixStudentPerformanceFreeze

Approved-by: Tiago Ribeiro
2025-03-04 23:24:17 +00:00
José Lima
31f2a21a76 reverted unnecessary changes 2025-03-04 23:17:20 +00:00
José Lima
c49b1c8070 Fix student performance freeze and search users in create entities
TODO: pagination in student performance freeze
2025-03-04 23:12:26 +00:00
Tiago Ribeiro
d78654a30f Merged in develop (pull request #158)
Develop
2025-03-04 10:02:57 +00:00
João Correia
655e019bf6 Merged in approval-workflows (pull request #157)
add approved field to exam

Approved-by: Tiago Ribeiro
2025-03-04 01:44:04 +00:00
Tiago Ribeiro
d7a8f496c0 Merged develop into approval-workflows 2025-03-04 01:43:32 +00:00
Joao Correia
5e363e9951 Merge branch 'approval-workflows' of bitbucket.org:ecropdev/ielts-ui into approval-workflows 2025-03-04 00:34:17 +00:00
Joao Correia
3370f3c648 add approved field to exam 2025-03-04 00:33:09 +00:00
João Correia
d77336374d Merged in approval-workflows (pull request #156)
Approval workflows

Approved-by: Tiago Ribeiro
2025-03-03 11:17:40 +00:00
Tiago Ribeiro
e765dea106 Merged develop into approval-workflows 2025-03-03 11:17:17 +00:00
Joao Correia
75fb9490e0 some more slight improvements to exam changes logs 2025-03-02 14:27:17 +00:00
Joao Correia
3ef7998193 order workflows table in descent startDate 2025-03-02 00:21:30 +00:00
Joao Correia
32cd8495d6 add imutable ids to some exam arrays to detect and log changes between two exams. 2025-03-02 00:10:57 +00:00
Joao Correia
4e3cfec9e8 change to a single checkbox filter for all modules 2025-02-27 10:29:35 +00:00
Joao Correia
ba8cc342b1 add filters to show only exams with or without approval 2025-02-26 19:15:20 +00:00
Joao Correia
dd8f821e35 only show workflows where user is assigned to at least one step. 2025-02-26 17:21:37 +00:00
Joao Correia
a4ef2222e2 Keep exam confidential even after approval workflow is completed 2025-02-26 16:51:57 +00:00
Joao Correia
93d9e49358 Merge branch 'develop' into approval-workflows 2025-02-26 16:42:09 +00:00
Francisco Lima
5d0a3acbee Merged in bugfixes-generationdesignchanges (pull request #155)
bugsfixed and design changes for generation 13'' screen

Approved-by: Tiago Ribeiro
2025-02-24 13:38:54 +00:00
José Lima
340ff5a30a bugsfixed and design changes for generation 13'' screen 2025-02-23 18:47:57 +00:00
João Correia
37908423eb Merged in approval-workflows (pull request #154)
Approval Workflows

Approved-by: Tiago Ribeiro
2025-02-20 14:30:24 +00:00
Joao Correia
b388ee399f small refactor 2025-02-20 12:12:00 +00:00
Joao Correia
4ac11df6ae fix examId being cleared when editing approval workflow 2025-02-20 11:27:44 +00:00
Joao Correia
14e2702aca add error message and stop loading if something went wrong while loading exam in approval workflow 2025-02-20 10:40:31 +00:00
Tiago Ribeiro
fec3b51553 Created two new permissions 2025-02-17 10:32:57 +00:00
Tiago Ribeiro
53d6b0dd51 Merged in develop (pull request #153)
Prod Update - 12/02/2025
2025-02-12 09:13:08 +00:00
João Correia
d8386bdd8e Merged in approval-workflows (pull request #152)
Approval workflows

Approved-by: Tiago Ribeiro
2025-02-11 12:09:17 +00:00
Joao Correia
df2f83e496 make access confidential when user submits exam with approval process. make access private upon approval workflow completed. 2025-02-10 13:25:11 +00:00
Joao Correia
e214d8b598 improve edited exam changes again 2025-02-10 11:30:24 +00:00
Joao Correia
c14f16c97a improve edited exam changes printing format 2025-02-09 21:12:29 +00:00
Joao Correia
ca2cf739ee improve edited exam changes printing format 2025-02-09 20:56:55 +00:00
João Correia
d432fb4bc4 Merged in approval-workflows (pull request #151)
Approval workflows

Approved-by: Tiago Ribeiro
2025-02-09 18:44:44 +00:00
Joao Correia
d5bffc9bad Add pagination to approval workflows table and change module styling to match project colors. 2025-02-09 18:10:59 +00:00
Joao Correia
75b4643918 Add button to submit exam without approval process 2025-02-09 17:37:19 +00:00
Joao Correia
9ae6b8e894 make sure admin id is passed to step component if the admin is not assigned to the workflow but approved a step. 2025-02-09 16:55:50 +00:00
Joao Correia
6f6c5a4209 make first step approved by default 2025-02-09 15:44:37 +00:00
Tiago Ribeiro
769b1b39d3 Added the permission 2025-02-09 11:35:52 +00:00
Francisco Lima
4bb12c7f01 Merged in addedAccess-bugfixes (pull request #150)
AddedAccess bugfixes

Approved-by: Tiago Ribeiro
2025-02-09 11:16:24 +00:00
Francisco Lima
a80a342ae2 Merged develop into addedAccess-bugfixes 2025-02-09 04:32:42 +00:00
José Lima
e5e60fcce9 fixed some issues related to build 2025-02-09 04:29:32 +00:00
José Lima
b175d8797e added access variable to exams soo we can distinguish private, public and confidential exams and also bugfixes and improvements 2025-02-09 04:28:34 +00:00
João Correia
f06349e350 Merged in approval-workflows (pull request #149)
filter workflows user can see based on entities

Approved-by: Tiago Ribeiro
2025-02-08 19:35:46 +00:00
Tiago Ribeiro
34caf9986c Merged develop into approval-workflows 2025-02-08 19:35:31 +00:00
Joao Correia
3a3d3d014d filter workflows user can see based on entities 2025-02-08 19:23:42 +00:00
João Correia
c49c303f20 Merged in approval-workflows (pull request #148)
temporary fix for same exam instance being used for all entities and implement approval process skip edge cases

Approved-by: Tiago Ribeiro
2025-02-08 18:03:43 +00:00
Joao Correia
cbe353c2c5 - start only one of the configured workflows (randomly at the moment) for the exam Author.
- skip approval process for admins
2025-02-08 15:26:16 +00:00
Tiago Ribeiro
991adede96 Merged in develop (pull request #147)
Develop
2025-02-07 17:54:57 +00:00
Tiago Ribeiro
f95bce6fa2 Did some fixes related to master corporates 2025-02-07 16:19:47 +00:00
João Correia
1dd6cead9e Merged in workflow-permissions (pull request #146)
Workflow permissions

Approved-by: Tiago Ribeiro
2025-02-07 15:43:34 +00:00
Joao Correia
5a72ebaea1 Merge branch 'develop' of bitbucket.org:ecropdev/ielts-ui into workflow-permissions 2025-02-07 13:06:59 +00:00
Joao Correia
00d2a7c2ad forgot permissions on [id] view 2025-02-07 12:57:26 +00:00
João Correia
a6e122e82d Merged in approval-workflows (pull request #145)
Fix bug where workflows were being created again after exam edit - and other improvements.

Approved-by: Tiago Ribeiro
2025-02-07 07:41:25 +00:00
Joao Correia
bf2aa29b98 implement workflow permissions 2025-02-06 23:26:21 +00:00
Joao Correia
cf12a4ed4f implement logging of exam edits on workflow's current step 2025-02-06 19:12:18 +00:00
Tiago Ribeiro
8711802b97 Merged develop into approval-workflows 2025-02-06 18:50:07 +00:00
Tiago Ribeiro
36afde8aa4 Created the new permissions as an example 2025-02-06 18:48:31 +00:00
Joao Correia
752881df41 - Fix bug where workflows were being created again after exam update
- Moved createWorkflows function into an helper file instead of a post request.
- Moved the workflow creation logic into the post of exam creation instead of a seperate post in each exam module
2025-02-06 13:16:32 +00:00
Tiago Ribeiro
63604b68e2 Added the permission to update the privacy of an exam 2025-02-06 12:12:34 +00:00
Tiago Ribeiro
d74aa39076 Merge branches 'main' and 'develop' of bitbucket.org:ecropdev/ielts-ui into develop 2025-02-06 11:52:16 +00:00
Joao Correia
c3849518fb amend last commit 2025-02-05 19:14:18 +00:00
Joao Correia
7fb5e1a62b fix typo and bug on exam edit. It was throwing an exception if it found an id with the same owners, but should throw when the owners are different. It was also throwing an error if owners was not set in exam. 2025-02-05 18:55:31 +00:00
Joao Correia
4b405297f2 Merge branch 'approval-workflows' into develop 2025-02-05 17:26:58 +00:00
Joao Correia
f0849b9b42 - fix assignees bug after editing active workflow
- only allow corporate+ to configure workflows
- give admins and devs permissions to approve and reject steps even when they are not assigned to them.
- small fixes
2025-02-05 16:50:09 +00:00
Joao Correia
845a5aa9dc fix stale state behaviour 2025-02-05 13:34:47 +00:00
Joao Correia
d48c7b0d03 implement clone in new builder and fix typo 2025-02-05 13:03:42 +00:00
Joao Correia
6692c201e4 instanciate all workflows configured for an exam author based on different entities. 2025-02-05 12:37:53 +00:00
Joao Correia
f4c7961caa implement edit active workflow and do not allow editing on already completed steps 2025-02-05 00:43:49 +00:00
Joao Correia
b215885dc6 - Make isDiagnostic false when all steps of the exam workflow have been approved.
- Implement Load Exam and Edit Exam buttons
2025-02-04 23:22:56 +00:00
Joao Correia
de15eb5ee1 implement initialization of approval workflows on exam creation. 2025-02-04 22:04:58 +00:00
Joao Correia
d3385caaf8 use custom hook to render approval workflows list instead of reloading full page. 2025-02-03 12:52:03 +00:00
Joao Correia
19f2193414 use custom hook to rerender workflow instead of reloading full page. 2025-02-03 12:31:21 +00:00
Joao Correia
d59b654ac2 do not allow empty steps in workflows 2025-02-03 11:34:56 +00:00
Francisco Lima
29b6a02118 Merged in layout-bug-fixes (pull request #144)
Remove unused imports and changed and improved layout design and responsiveness in some components and fixed some bugs.

Approved-by: Tiago Ribeiro
2025-02-03 09:35:25 +00:00
Francisco Lima
b77476dc9a Merged develop into layout-bug-fixes 2025-02-03 00:07:05 +00:00
José Marques Lima
5a685ebe80 Remove unused imports and changed and improved layout design and responsiveness in some components and fixed some bugs. 2025-02-02 23:58:23 +00:00
Joao Correia
835a9bee03 - Filter available form intakers so that no form intaker can be in two workflows at once.
- add getApprovalWorkflowByIntaker to prepare workflow start after exam creation.
- fix builder bug with step keys
- ignore edit view for now because it will only be available for active workflows and not configured workflows.
2025-02-02 22:40:05 +00:00
Joao Correia
16545d2075 refactor workflows api 2025-02-02 11:11:52 +00:00
Joao Correia
b684262759 Fix id handling on update 2025-02-01 23:14:17 +00:00
Joao Correia
ac539332e6 major change on how workflow builder works. It now fetches in edit mode all the currently configured workflows 2025-02-01 22:36:42 +00:00
Francisco Lima
ed87c8b163 Merged in refactor-getserverprops (pull request #143)
Fix Finish page with scores in exams

Approved-by: Tiago Ribeiro
2025-01-31 22:40:29 +00:00
José Marques Lima
e33ab315ad Merge branch 'develop' into refactor-getserverprops 2025-01-31 22:37:36 +00:00
José Marques Lima
1feef5c419 Fix Finish page 2025-01-31 22:34:43 +00:00
Joao Correia
a0229cd971 implement rejection of steps 2025-01-31 20:56:40 +00:00
Joao Correia
662e3b0266 - implement approval of steps
- remove currentStep field from step
- implement save comments on step
- fix _id issue when saving to mongo
2025-01-31 17:01:20 +00:00
Tiago Ribeiro
b9aec7261f Updated the maxPoolSize 2025-01-31 12:26:43 +00:00
Tiago Ribeiro
54a9f6869a Reduced the maxPoolSize 2025-01-31 12:05:39 +00:00
Joao Correia
9de4cba8e8 refactor fetched users into single array and replace Image tag with img 2025-01-31 10:37:14 +00:00
Francisco Lima
7d750dc584 Merged in refactor-getserverprops (pull request #142)
Refactor most getServerProps to make independent requests in parallel and projected the data only to return the necessary fields and changed some functions

Approved-by: Tiago Ribeiro
2025-01-30 20:02:27 +00:00
José Marques Lima
98ba0bfc04 Refactor most getServerProps to fetch independent request in parallel and projected the data only to return the necessary fields and changed some functions 2025-01-30 18:25:42 +00:00
Joao Correia
f89b42c41c remove currentStep from step type 2025-01-30 12:06:13 +00:00
Joao Correia
c968044160 switch to mongo's id handling 2025-01-30 11:50:28 +00:00
Joao Correia
5d727fc528 implement delete workflow 2025-01-30 11:07:13 +00:00
Joao Correia
bdc5ff7797 - edit workflow back-end implementation
- clone workflow back-end implementation
- added loading and redirecting to form submissions
- fixed form intake in progress bug
- fixed rendering bug
2025-01-29 20:49:19 +00:00
Joao Correia
011c6e9e30 Start implementing with back-end. Create workflows completed and fetching workflows on server side as well, to show them in the table. 2025-01-29 17:50:03 +00:00
Joao Correia
42a8ec2f8a small fixes 2025-01-29 15:35:59 +00:00
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
Joao Correia
a40ae04aa3 Add workflow table name filter 2025-01-27 12:50:18 +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
Joao Correia
8db47a3962 Filter out empty select inputs on form submission 2025-01-26 14:07:25 +00:00
Joao Correia
ab81a1753d - Implement cloning of workflow
- Entity change will now only clear the assignees instead of the whole workflow
- Fix bug where side panel was showing all workflow assignees instead of just selected step assignees
2025-01-26 04:31:36 +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
Joao Correia
73610dc273 implement edit workflow 2025-01-25 19:45:39 +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
Joao Correia
ac072b0a5a small fixes and animate side panel content 2025-01-25 15:47:33 +00:00
Joao Correia
2c0153e055 Fix animations 2025-01-25 15:10:52 +00:00
Joao Correia
2eff08bf86 Add approval by to step details panel and add text size prop to UserWithProfilePicture component 2025-01-25 04:23:28 +00:00
Joao Correia
f71a7182dd - Refactor of workflow and steps types to differentiate between editView and normalView.
- Added side panel with steps details
2025-01-25 03:44:50 +00:00
Joao Correia
1f7639a30e - initial selected step
- assignees id to name on table view
2025-01-24 17:09:37 +00:00
Joao Correia
41d09eaad8 Make data dynamic in workflow view. Add requester and startDate to workflows. 2025-01-24 14:14:07 +00:00
Joao Correia
f6b0c96b3b Finish Approval Workflow builder for the most part. TODO: implement permissions 2025-01-24 00:33:45 +00:00
Joao Correia
dcd25465fd on workflow builder, only render steps if name and entity are set. reset workflow on entity reset. 2025-01-23 22:56:45 +00:00
Joao Correia
c921d54d50 code refactoring 2025-01-23 22:12:25 +00:00
Joao Correia
a4f60455b5 Render previous select input options when switching between workflows in builder 2025-01-23 17:08:32 +00:00
Joao Correia
a0936cb1a4 Prevent same input on selects from the same step.
Change behaviour of initial step to allow multiple assignees
2025-01-23 15:10:14 +00:00
Joao Correia
aa76c2b54b Work on workflow builder:
- Made number of approvers dynamic with many select inputs as needed
- Tracking approval select input changes with step.assignees
- Fetching teachers and corporates from backend
- Responsive styling when rendering several select inputs
2025-01-23 02:48:25 +00:00
Joao Correia
4e81c08adb fix bug in last commit where all entities would show up on select filter instead of user entities 2025-01-22 18:07:09 +00:00
Joao Correia
4895f00184 Add entityId to workflow. Allow filter workflows based on entityId. Restrict creation of workflows based on user entities. 2025-01-22 16:39:18 +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
Joao Correia
8f8d5e5640 Work on workflows table 2025-01-22 00:30:14 +00:00
Joao Correia
73e2e95449 work on non editable approval workflow steps view 2025-01-21 20:42:03 +00:00
Joao Correia
48187fc7f2 fix step numbering bug from previous commit and prepare non editable workflow view 2025-01-21 00:10:14 +00:00
Joao Correia
01222b3a13 dynamic list of new workflows in workflow builder and some code refactoring 2025-01-20 23:32:32 +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
Joao Correia
39a397d262 Add progress vertical bars to pipeline steps 2025-01-20 11:44:29 +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
Joao Correia
50d2841349 Implement reordering of steps 2025-01-19 22:26:02 +00:00
Joao Correia
f485c782f3 Start implementing workflow step form behaviour 2025-01-19 19:23:56 +00:00
Joao Correia
c2c9b3374c Start work on Approval Workflow id page 2025-01-17 20:17:51 +00:00
Joao Correia
66d23b4140 Add local test data, implement [id].tsx for approval workflows 2025-01-17 18:44:52 +00:00
Joao Correia
580e319fb9 start implementation of approval workflows. Add nav, page, empty table, and status filtering 2025-01-17 15:20:46 +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
Joao Ramos
2d0cb8eefb Added level part display on excel and a pseudo sorting 2024-09-07 00:14:56 +01:00
Joao Ramos
58448a391f Fixed infinite loop on the dashboards 2024-09-06 23:48:18 +01:00
Tiago Ribeiro
f6550e6a36 Merge branch 'develop' of bitbucket.org:ecropdev/ielts-ui into develop 2024-09-06 17:07:45 +01:00
Tiago Ribeiro
cfe297cc38 Continued improving this 2024-09-06 17:06:49 +01:00
Tiago Ribeiro
4530e4079f Did the same to all of the dashboards 2024-09-06 15:35:26 +01:00
Tiago Ribeiro
de35e1a8b7 Updated the MasterCorporate with the improved queries 2024-09-06 14:57:23 +01:00
Tiago Ribeiro
a6bf53e84c Disabled the review buttons 2024-09-06 14:45:44 +01:00
carlos.mesquita
d7ffdc3031 Merged in feature/level-file-upload (pull request #93)
Small bug fix

Approved-by: Tiago Ribeiro
2024-09-06 10:24:12 +00:00
carlos.mesquita
98f2527fed Merged develop into feature/level-file-upload 2024-09-06 10:23:32 +00:00
Carlos Mesquita
d8bf10eaea Small bug fix 2024-09-06 11:21:35 +01:00
Tiago Ribeiro
f9216637df Extracted the function 2024-09-06 10:39:16 +01:00
Tiago Ribeiro
08945bfbdd Forgot to add this one 2024-09-06 10:13:30 +01:00
Tiago Ribeiro
b92a4285c9 Removed the user itself 2024-09-06 10:12:19 +01:00
Tiago Ribeiro
271ca7069e Updated it so it also appears to teachers 2024-09-06 10:10:50 +01:00
carlos.mesquita
08ed8fcb32 Merged in feature/level-file-upload (pull request #92)
ENCOA-182, ENCOA-185, ENCOA-177, ENCOA-168, ENCOA-186, ENCOA-176, ENCOA-189, ENCOA-167

Approved-by: Tiago Ribeiro
2024-09-06 08:53:29 +00:00
João Ramos
17f678a3ac Merged in ENCOA-131_MasterStatistical (pull request #91)
ENCOA-131 MasterStatistical

Approved-by: Tiago Ribeiro
2024-09-06 08:53:07 +00:00
Carlos Mesquita
6bd9816edd Merge remote-tracking branch 'origin/develop' into feature/level-file-upload 2024-09-06 09:41:01 +01:00
Carlos Mesquita
77ac15c2bb ENCOA-182, ENCOA-185, ENCOA-177, ENCOA-168, ENCOA-186, ENCOA-176, ENCOA-189, ENCOA-167 2024-09-06 09:39:38 +01:00
Tiago Ribeiro
55cc9765e2 Updated the backend so the users list only returns the correct ones 2024-09-06 09:33:30 +01:00
Joao Ramos
e433a150a9 Added level export to excel 2024-09-05 23:30:03 +01:00
Joao Ramos
a61ad2cc7e Added an export feature for the master statisticl screen 2024-09-05 22:42:46 +01:00
Tiago Ribeiro
680f4cfa95 Made it so it will show always the corporate 2024-09-05 19:55:19 +01:00
Tiago Ribeiro
311824e8b7 ENCOA-175: When a Corporate Create another Corporate, the created corporate should have access to the same students and teachers as the creator 2024-09-05 19:24:28 +01:00
Tiago Ribeiro
2fb73cc3a3 ENCOA-166: Increased the probability of this being fixed even more 2024-09-05 18:54:36 +01:00
Joao Ramos
70de97766e Added search to table 2024-09-05 18:25:39 +01:00
Tiago Ribeiro
c7ff11d0fc ENCOA-172: Updated it so if an assignment has already been started, they can't start it again 2024-09-05 17:33:15 +01:00
Tiago Ribeiro
e312af36bb Added the assignment to the Session Card 2024-09-05 17:25:22 +01:00
Tiago Ribeiro
ac980023b5 Negated a simple change 2024-09-05 16:59:39 +01:00
Tiago Ribeiro
3b43803b7e ENCOA-180: Prevented users from checking their results if not released yet 2024-09-05 16:54:45 +01:00
Tiago Ribeiro
c8be2f1255 Prevented sessions from appearing when an assignment is done 2024-09-05 16:52:16 +01:00
Tiago Ribeiro
b6b5f3a9f1 Solved a problem related to not showing corporates if they are not in the correct group 2024-09-05 12:02:46 +01:00
Tiago Ribeiro
6ce81b300a Removed a part of the make_user 2024-09-05 11:35:53 +01:00
Tiago Ribeiro
3d3c4448ae Removed the group for when it's a corporate creating another corporate 2024-09-04 18:50:35 +01:00
Tiago Ribeiro
cb2c1641f5 Made the corporate name being the creator's one 2024-09-04 17:40:26 +01:00
Tiago Ribeiro
7bcd0f863f ENCOA-133: Teachers within a Group should be able to view the Group assignments 2024-09-04 15:26:42 +01:00
Tiago Ribeiro
2d95cbd3dc ENCOA-137: Top right side Next Button on the exams 2024-09-04 15:10:17 +01:00
Tiago Ribeiro
49aac93618 Updated the look of the assignments 2024-09-04 14:50:47 +01:00
Tiago Ribeiro
28ad7944e0 Solved a bug with a page crashing 2024-09-04 14:34:18 +01:00
Tiago Ribeiro
d4553501b8 ENCOA-164: Corporate created by another Corporate Should be linked to the Master Corporate of the Creator Accoubnt 2024-09-04 11:59:14 +01:00
Tiago Ribeiro
4654c21d92 ENCOA-154: Increase the number of questions per page in all modules (Priority in Level Test) 2024-09-04 11:41:48 +01:00
Tiago Ribeiro
becc91d8ea Some general improvements 2024-09-04 11:17:54 +01:00
Tiago Ribeiro
06cb4485f4 ENCOA-163: Groups should only display first 5 names on the table 2024-09-04 09:56:12 +01:00
Tiago Ribeiro
9b22fb259c ENCOA-159: Sidebar should be minimized as default 2024-09-04 09:31:03 +01:00
carlos.mesquita
f2137efaa0 Merged in feature/level-file-upload (pull request #90)
ENCOA-149, ENCOA-150, ENCOA-152, ENCOA-153, ENCOA-155, ENCOA-156, ENCOA-157, ENCOA-158, ENCOA-161

Approved-by: Tiago Ribeiro
2024-09-04 08:10:40 +00:00
João Ramos
00834fec7b Merged in features-03-Sep-24 (pull request #89)
ENCOA-146 + Bug fixing

Approved-by: Tiago Ribeiro
2024-09-04 08:10:12 +00:00
carlos.mesquita
ef84052909 Merged develop into feature/level-file-upload 2024-09-04 01:34:36 +00:00
Carlos Mesquita
6ea80dd0da Merge branch 'feature/level-file-upload' of https://bitbucket.org/ecropdev/ielts-ui into feature/level-file-upload 2024-09-04 02:27:31 +01:00
Carlos Mesquita
5168e70edc ENCOA-149, ENCOA-150, ENCOA-152, ENCOA-153, ENCOA-155, ENCOA-156, ENCOA-157, ENCOA-158, ENCOA-161 -> Updated the mc buttons, no longer shows a context only div on parts that have context, removed line numbers on lines between paragraphs, applied bold and underline to 'not correct' in underline prompts, added another pop up to confirm submission. 2024-09-04 02:26:22 +01:00
João Ramos
a7719dbb55 Merged develop into features-03-Sep-24 2024-09-03 22:25:16 +00:00
Joao Ramos
25e6cb36a9 Improvements on start button 2024-09-03 23:23:18 +01:00
Joao Ramos
a7c1ea0409 Imporvements on started management of assignment 2024-09-03 22:56:43 +01:00
Joao Ramos
8aed075553 Disabled no longer makes sense for an assignment on the student 2024-09-03 22:42:12 +01:00
carlos.mesquita
3fc581aaac Merged in feature/level-file-upload (pull request #88)
ENCOA-147: Uploading a batch of multiple users is now unrestricted by firebase auth request quotas

Approved-by: Tiago Ribeiro
2024-09-03 21:11:33 +00:00
Tiago Ribeiro
020f689af6 Merged develop into feature/level-file-upload 2024-09-03 20:36:55 +00:00
Joao Ramos
04c9ff24ea Changed the criteria for the filter on student assignment 2024-09-03 21:15:59 +01:00
Joao Ramos
105c03fa09 Fied an issue with the future with autostart 2024-09-03 21:15:06 +01:00
Joao Ramos
547e0fc530 Fixed an issue with the default values 2024-09-03 21:14:20 +01:00
Joao Ramos
bf7793e103 Minor fix regarding future assignments 2024-09-03 20:51:11 +01:00
Carlos Mesquita
60554d8e16 ENCOA-147: Uploading a batch of multiple users is now unrestricted by firebase auth request quotas 2024-09-03 20:13:04 +01:00
Joao Ramos
5d26af511c Initial improvements on assets for assignments validation 2024-09-03 20:10:32 +01:00
Joao Ramos
12104e797a Fixed an issue preventing the user from seeing data regarding the assignment 2024-09-03 20:02:30 +01:00
Joao Ramos
d307c61cd7 Added a feature to automatically start an exam 2024-09-03 19:23:45 +01:00
Joao Ramos
6774b2d0b6 Fixed label 2024-09-03 19:12:32 +01:00
Tiago Ribeiro
fa53382c08 Hidden the level score when not released 2024-09-03 17:43:33 +01:00
Tiago Ribeiro
67929655f4 Solved some race conditions related to the previous commit 2024-09-03 17:00:42 +01:00
João Ramos
e8c47941d0 Merged in ENCOA-139_ReleaseBug (pull request #87)
ENCOA-139 Release Improvements
2024-09-03 15:47:17 +00:00
carlos.mesquita
82d0a0556f Merged in feature/level-file-upload (pull request #85)
Feature/level file upload
2024-09-03 15:46:39 +00:00
Tiago Ribeiro
7cd18b07bb ENCOA-130: Add owners to exams 2024-09-03 16:31:29 +01:00
Joao Ramos
aca8ad2d14 Added an option to release automatically 2024-09-02 23:56:51 +01:00
Joao Ramos
4bb80919ad Fixd an error accessing a property that might not be defined, others were already using ? 2024-09-02 23:50:41 +01:00
Joao Ramos
5ed851878a Reenabled information display after Exam 2024-09-02 23:48:56 +01:00
Carlos Mesquita
763452e3cc Merge remote-tracking branch 'origin/develop' into feature/level-file-upload 2024-09-02 22:21:40 +01:00
Carlos Mesquita
063a73691a Exhaustive-deps warning 2024-09-02 22:20:02 +01:00
Carlos Mesquita
caddf87231 Previous Level exams were being broken by the part divider changes, fixed it. 2024-09-02 22:18:33 +01:00
Tiago Ribeiro
0f38e01283 let the users wait 2024-09-02 21:31:26 +01:00
Tiago Ribeiro
640b6f0e4d Updated the user card to allow master corporate to edit without setting a price 2024-09-02 19:56:12 +01:00
Tiago Ribeiro
f43d562405 Solved a problem with the corporate when making user 2024-09-02 18:12:02 +01:00
Tiago Ribeiro
39752cbb1d Wrong number in the corporate amount 2024-09-02 17:48:55 +01:00
Tiago Ribeiro
229d93c03e Fixed a problem 2024-09-02 17:18:53 +01:00
Tiago Ribeiro
b0ab8a8fce Stuff 2024-09-02 16:09:44 +01:00
Tiago Ribeiro
90cb705ad2 Corrected an oopsie related to the amount of level exams shown 2024-09-02 15:38:37 +01:00
Tiago Ribeiro
65fa6e64e6 Removed unnecessary code 2024-09-02 15:32:15 +01:00
Tiago Ribeiro
7c0e7ef53e Solved a bug related to the Batch Create User 2024-09-02 14:28:38 +01:00
Tiago Ribeiro
b6c3754b40 Allow users to edit others 2024-09-02 12:30:23 +01:00
Tiago Ribeiro
4e3c947d2a Solved a bug related to the creation of users 2024-09-02 11:59:12 +01:00
Tiago Ribeiro
abcb1afd48 ENCOA-120: Prevented flicker when leaving page 2024-09-02 11:21:38 +01:00
Tiago Ribeiro
0b88d6bcd1 Improved a tiny bit of the performance with Student Dashboard 2024-08-30 11:56:35 +01:00
Tiago Ribeiro
fef5bf44de Removed some console.logs 2024-08-30 11:25:21 +01:00
carlos.mesquita
2c43d48bbd Merged in feature/training-content (pull request #84)
Patched a bug where the user would be locked out of continuing if he closed the unawnsered modal

Approved-by: Tiago Ribeiro
2024-08-30 10:07:58 +00:00
Carlos Mesquita
4865b47393 ENCOA-138 Changed the 'Confirm Submission' title and the 'Submit' button to red and increased the font size of the modal 2024-08-30 10:17:24 +01:00
Carlos Mesquita
3892fe1a67 Merge remote-tracking branch 'origin/develop' into feature/training-content 2024-08-30 09:25:38 +01:00
Tiago Ribeiro
39710aaea1 Improved the overall stability and speed of the app 2024-08-29 23:21:20 +01:00
Tiago Ribeiro
b57e11bec4 ENCOA-129: When Creating a Single user Corporate OR Master Corporate it should have the field Department to be configured 2024-08-29 15:18:55 +01:00
Tiago Ribeiro
fdc8f98b21 ENCOA-132: Create user is not linking to the Corporate Account 2024-08-29 15:06:32 +01:00
Tiago Ribeiro
2b71f2467c ENCOA-126: Corporate should not be allowed to edit is own name 2024-08-29 13:18:11 +01:00
Tiago Ribeiro
cd1caf0f53 ENCOA-123: Improved the options of users for the Group Creator 2024-08-29 13:00:39 +01:00
Tiago Ribeiro
3b77d3fc0b ENCOA-136: Profile Pictures of all accounts should be by Default Encoach Logo 2024-08-29 12:51:10 +01:00
Tiago Ribeiro
73525f1dc0 ENCOA-127: Change the name of the field Position to Department 2024-08-29 12:47:05 +01:00
Tiago Ribeiro
c256231cfc ENCOA-120: Prevent Assignment Creator from disappearing 2024-08-29 12:43:53 +01:00
Tiago Ribeiro
2fb41f7462 ENCOA-128: Display the position of the Corporate in the topbar 2024-08-29 12:31:34 +01:00
Tiago Ribeiro
aa96b13ec2 ENCOA-121 & ENCOA-122: Fixed a bug where updating the account information of a Corporate or Master Corporate was not working 2024-08-29 12:28:30 +01:00
Tiago Ribeiro
f9429d1056 ENCOA-125: Allow Master Corporate to change the company name of a Corporate 2024-08-29 12:19:44 +01:00
Tiago Ribeiro
af9462398a Increase the size and boldness of the timer 2024-08-28 14:58:07 +01:00
Tiago Ribeiro
6fd0b7aef3 Hotfix for a crash 2024-08-28 14:53:09 +01:00
Tiago Ribeiro
bc47f9c001 Updated the react-icons package 2024-08-28 14:25:29 +01:00
Carlos Mesquita
4ea3a844ed Merge remote-tracking branch 'origin/develop' into feature/training-content 2024-08-28 13:32:21 +01:00
Carlos Mesquita
ea8a3625ef Patched a bug where the user would be locked out of continuing if he closed the unawnsered modal 2024-08-28 13:28:21 +01:00
Tiago Ribeiro
3eb2f432fa Allowed assigners to release and start the assignment 2024-08-28 13:07:04 +01:00
Tiago Ribeiro
5d10e6564d Only shows results for an assignments that has been released 2024-08-28 12:27:26 +01:00
Tiago Ribeiro
e518323d99 Updated the generation to allow for private exams 2024-08-28 10:46:02 +01:00
Tiago Ribeiro
dbf262598f Added the ability to set an exam as private 2024-08-28 10:26:45 +01:00
Tiago Ribeiro
951ca5736e Added the ability for some exams to be private and not chosen randomly 2024-08-28 10:17:01 +01:00
João Ramos
7960e7d8fb Merged in bug-fixing-280824 (pull request #83)
Bug fixing 280824

Approved-by: Tiago Ribeiro
2024-08-28 08:57:52 +00:00
Tiago Ribeiro
99039f8bf3 Corrected a problem related to getting the corporate of a user 2024-08-28 09:57:00 +01:00
Joao Ramos
3c7df4e33c Added default value in case on the user not being found in the DB 2024-08-28 07:39:51 +01:00
Joao Ramos
614a7a2a29 Improved asset download criteria 2024-08-28 07:38:38 +01:00
João Ramos
ec67f91263 Merged in bug-fixing-270824 (pull request #82)
Fixed an use case where the export of Excel failed if no students had answered it

Approved-by: Tiago Ribeiro
2024-08-27 22:20:52 +00:00
Joao Ramos
aa4e13a18d Fixed an use case where the export of Excel failed if no students had answered it 2024-08-27 23:11:40 +01:00
carlos.mesquita
23e26617e2 Merged in feature/training-content (pull request #81)
Feature/training content

Approved-by: Tiago Ribeiro
2024-08-27 21:43:03 +00:00
Carlos Mesquita
ef32226c6c Previous commit solved completion but messed up question modal, patched that and added the condition to show the submission modal when all questions are awnswered 2024-08-27 22:10:31 +01:00
Carlos Mesquita
c9174f37ef Forgot to toggle strict mode again 2024-08-27 17:13:19 +01:00
Carlos Mesquita
c99dbab4b6 Merge remote-tracking branch 'origin/develop' into feature/training-content 2024-08-27 17:10:57 +01:00
Carlos Mesquita
eb985014be Merge branch 'feature/training-content' of https://bitbucket.org/ecropdev/ielts-ui into feature/training-content 2024-08-27 17:09:33 +01:00
Carlos Mesquita
845bccbe2a ENCOA-107, ENCOA-115 Fixed completion percentage, brought back the line numbers, 'Level Exam' was replaced by 'Placement Test', Next/Back instructions with quotes, 'Submit' on last level question 2024-08-27 17:08:33 +01:00
Tiago Ribeiro
3ec886c31d ENCOA-109: Keep the same balance after deleting a user 2024-08-27 16:57:41 +01:00
Tiago Ribeiro
fa3929d5e9 Allow Master Corporate to pay for their subscription 2024-08-27 16:35:34 +01:00
Tiago Ribeiro
b7940087b5 ENCOA-109: Made the modal disappear when a user is created 2024-08-27 11:14:29 +01:00
Tiago Ribeiro
7fb0ed884c Merge branch 'develop' of bitbucket.org:ecropdev/ielts-ui into develop 2024-08-27 11:04:22 +01:00
carlos.mesquita
d93852e230 Merged in feature/training-content (pull request #80)
Feature/training content

Approved-by: Tiago Ribeiro
2024-08-27 10:03:53 +00:00
Tiago Ribeiro
d04ea33616 Merged develop into feature/training-content 2024-08-27 10:03:44 +00:00
Tiago Ribeiro
eb38464aca ENCOA-112: Improve Answer Display 2024-08-27 11:02:39 +01:00
Tiago Ribeiro
cd85c71aec ENCOA-114: In Exam List and Group List provide Fuzzy Search Filter 2024-08-27 10:53:50 +01:00
Carlos Mesquita
c464375414 Merge remote-tracking branch 'origin/develop' into feature/training-content 2024-08-27 09:44:22 +01:00
Tiago Ribeiro
82233c7d53 ENCOA-111: Change Modules Assignment Display 2024-08-27 09:42:14 +01:00
Carlos Mesquita
cc5be99b0f Bug fix on fillblanks calculateScore 2024-08-27 09:40:20 +01:00
Tiago Ribeiro
65dc3e92d0 ENCOA-110: Reorder excel upload columns 2024-08-27 09:38:47 +01:00
Tiago Ribeiro
addd117834 There was a problem with the order of the modals 2024-08-27 09:35:37 +01:00
Carlos Mesquita
72b498eb85 Fixed a bug in fill blanks where the input would be reset by the re-rendering of lines 2024-08-27 09:20:00 +01:00
Tiago Ribeiro
0aba6355ed ENCOA-90: Updated the instances of Level test to use the Grading System 2024-08-27 00:18:01 +01:00
Tiago Ribeiro
a0b8521f0a ENCOA-90: Creating the ability for a corporate/master corporate to edit their grading system 2024-08-26 22:35:00 +01:00
Carlos Mesquita
eb7c539a0e exaustive-deps warning 2024-08-26 18:28:37 +01:00
Carlos Mesquita
22928ce283 ENCOA-94: Fixes the bug, refactored useStats to be useFilterRecordsByUser in order to not duplicate code and also refactored records.tsx and training.tsx to use a component which abstracts the user Selection for both stats and training. 2024-08-26 18:26:29 +01:00
carlos.mesquita
4a1a52bd61 Merged in feature/level-file-upload (pull request #78)
Shuffles fixed

Approved-by: Tiago Ribeiro
2024-08-25 17:36:17 +00:00
João Ramos
af00f49adc Merged in features-21-08-24 (pull request #79)
ENCOA: 86 + 101 + 102

Approved-by: Tiago Ribeiro
2024-08-25 17:35:59 +00:00
Joao Ramos
8d37e60f5d Merge branch 'develop' into features-21-08-24 2024-08-24 18:56:05 +01:00
Joao Ramos
74a53f55fd Added option to start exam which serves as an alternative to start date for the exam 2024-08-24 17:38:57 +01:00
Joao Ramos
101605ad88 Updated Master Statistical with user grade count 2024-08-24 11:06:44 +01:00
Joao Ramos
cf1b47fbd2 Major updates on Master Statistical 2024-08-24 10:53:11 +01:00
Carlos Mesquita
f9174c13c1 Merge branch 'feature/level-file-upload' of https://bitbucket.org/ecropdev/ielts-ui into feature/level-file-upload 2024-08-24 01:03:37 +01:00
Tiago Ribeiro
032d20b4b2 ENCOA-96: License Distribuition system from Master Corporate to Corporate 2024-08-24 01:02:34 +01:00
Carlos Mesquita
2146ef1a92 Found the bug 2024-08-24 00:54:55 +01:00
carlos.mesquita
4928267036 Merged develop into feature/level-file-upload 2024-08-23 20:19:13 +00:00
Carlos Mesquita
f0f38b335f Part and MC question grid jump to, has a bug on next going to refactor the whole thing 2024-08-23 21:17:32 +01:00
Tiago Ribeiro
3e21538d02 ENCOA-98: Change the template on the Excel import Function 2024-08-23 16:59:08 +01:00
Tiago Ribeiro
33fd6ddf8f Removed the ability to change the user from the list 2024-08-23 12:10:44 +01:00
Tiago Ribeiro
1bb5405894 ENCOA-88: Create individual accounts 2024-08-23 12:02:35 +01:00
Joao Ramos
44adc142f6 Updated Master Statistical 2024-08-23 00:56:18 +01:00
Joao Ramos
4379716e9b ENCOA-86: Stats page now filters assignments PDF download based on released 2024-08-22 23:16:16 +01:00
Carlos Mesquita
b4b078c8c9 Missed some console.logs 2024-08-22 22:19:32 +01:00
Carlos Mesquita
6dbc2f5ed2 Merge branch 'develop' of https://bitbucket.org/ecropdev/ielts-ui into feature/level-file-upload 2024-08-22 22:05:53 +01:00
Carlos Mesquita
9a51096a94 Merge branch 'feature/level-file-upload' of https://bitbucket.org/ecropdev/ielts-ui into feature/level-file-upload 2024-08-22 22:04:11 +01:00
Carlos Mesquita
1315e0b280 Shuffles fixed 2024-08-22 22:02:37 +01:00
Tiago Ribeiro
4505ea5ff8 ENCOA-100: Changed the login/register photo 2024-08-22 16:49:27 +01:00
Tiago Ribeiro
192324b891 ENCOA-99: Added a Student ID field to Students 2024-08-22 16:27:03 +01:00
Tiago Ribeiro
326d305a69 ENCOA-97: Now it allows the user to write the name of the country in english 2024-08-22 16:15:25 +01:00
Tiago Ribeiro
cfcff3cf3b ENCOA-93 2024-08-22 15:56:52 +01:00
Tiago Ribeiro
202632ff58 ENCOA-89, ENCOA-91, ENCOA-92, ENCOA-95
All changes related to permissions towards types of users
2024-08-22 12:27:15 +01:00
Joao Ramos
7116892f9a ENCOA-86: Added default value for new assignment 2024-08-21 22:24:46 +01:00
Joao Ramos
c6f40f625b ENCOA-86: Fixed broken labels on release 2024-08-21 22:20:38 +01:00
Joao Ramos
556f642112 ENCOA-86: Added an option to release exam results 2024-08-21 22:19:15 +01:00
João Ramos
22611121c6 Merged in ENCOA-83_MasterStatistical (pull request #76)
ENCOA-83 MasterStatistical

Approved-by: Tiago Ribeiro
2024-08-21 16:23:21 +00:00
Tiago Ribeiro
720597e916 Merged develop into ENCOA-83_MasterStatistical 2024-08-21 16:18:37 +00:00
carlos.mesquita
e74ded676e Merged in feature/level-file-upload (pull request #77)
Forgot to check if exam had shuffled enabled

Approved-by: Tiago Ribeiro
2024-08-21 16:18:23 +00:00
carlos.mesquita
ee60eedd0d Merged develop into feature/level-file-upload 2024-08-21 12:03:57 +00:00
Carlos Mesquita
c37a1becbf Forgot to check if exam had shuffled enabled 2024-08-21 13:02:30 +01:00
João Ramos
b9cca483ec Merged develop into ENCOA-83_MasterStatistical 2024-08-20 23:39:57 +00:00
Joao Ramos
c758bdaf9e Merge branch 'ENCOA-83_MasterStatistical' of https://bitbucket.org/ecropdev/ielts-ui into ENCOA-83_MasterStatistical 2024-08-21 00:39:22 +01:00
Joao Ramos
5ada588b16 Reverted minor change 2024-08-21 00:38:26 +01:00
Joao Ramos
eec1bb0c30 Added final touches 2024-08-21 00:37:33 +01:00
Joao Ramos
65f8368708 Initial draft of level test report 2024-08-20 23:40:54 +01:00
Tiago Ribeiro
806e621c5b Updated the record for students 2024-08-20 22:43:34 +01:00
carlos.mesquita
ce35b23714 Merged in feature/level-file-upload (pull request #75)
Part intro's, modals between parts and some fixes

Approved-by: Tiago Ribeiro
2024-08-20 21:18:22 +00:00
Carlos Mesquita
2cd025b118 Patched part divider, forgot to check if the exam had field intro 2024-08-20 20:14:42 +01:00
Carlos Mesquita
2e699d7e25 Forgot to save TextComponent, and cleanup some warnings 2024-08-20 20:02:59 +01:00
Carlos Mesquita
30da295c60 Merge branch 'develop' of https://bitbucket.org/ecropdev/ielts-ui into feature/level-file-upload 2024-08-20 19:37:06 +01:00
Carlos Mesquita
a82a399d52 Merge branch 'feature/level-file-upload' of https://bitbucket.org/ecropdev/ielts-ui into feature/level-file-upload 2024-08-20 18:53:25 +01:00
Carlos Mesquita
505df31d6b Part intro's, modals between parts and some fixes 2024-08-20 18:52:38 +01:00
Tiago Ribeiro
a4d8ba72af Disallowed the name to be equal to Corporate 2024-08-20 17:41:06 +01:00
Tiago Ribeiro
2bfd0cb502 More stuff related to the groups 2024-08-20 17:38:48 +01:00
Tiago Ribeiro
5ee071028c Removed a WIP page 2024-08-20 17:14:54 +01:00
Tiago Ribeiro
23b9452a3a Another bug solved 2024-08-20 17:12:10 +01:00
Tiago Ribeiro
0ce3a16d3a Resolved another bug related to master corporate groups 2024-08-20 16:29:55 +01:00
Tiago Ribeiro
4315a7b17c Corrected a bug related to groups of a master corporate 2024-08-20 14:37:33 +01:00
Tiago Ribeiro
247f192a0a Added a scroll to the selection 2024-08-20 12:43:34 +01:00
João Ramos
9c944ae3d2 Merged in ENCOA-83_MasterStatistical (pull request #74)
ENCOA-83 MasterStatistical

Approved-by: Tiago Ribeiro
2024-08-20 10:14:19 +00:00
Tiago Ribeiro
a390aa429d Merged develop into ENCOA-83_MasterStatistical 2024-08-20 10:13:42 +00:00
Joao Ramos
3367384791 Merge branch 'ENCOA-83_MasterStatistical' of https://bitbucket.org/ecropdev/ielts-ui into ENCOA-83_MasterStatistical 2024-08-20 11:12:05 +01:00
Joao Ramos
158324a705 Added second excel for master corporate export 2024-08-20 11:10:51 +01:00
Tiago Ribeiro
f9286d1793 Updated the Dockerfile after having upgraded NextJS 2024-08-20 10:47:56 +01:00
João Ramos
2e376c37dd Merged in ENCOA-83_MasterStatistical (pull request #73)
ENCOA-83 MasterStatistical

Approved-by: Tiago Ribeiro
2024-08-20 09:33:42 +00:00
Tiago Ribeiro
5bda9ed227 Merge branch 'develop' into ENCOA-83_MasterStatistical 2024-08-20 10:32:19 +01:00
Tiago Ribeiro
97b533bd3a Took care of some warnings 2024-08-20 10:07:18 +01:00
Tiago Ribeiro
75a45108a2 Upgraded @types/react and @types/react-dom 2024-08-20 09:52:22 +01:00
Tiago Ribeiro
bfc0def20f Updated the exam list to be visible 2024-08-20 09:35:13 +01:00
Joao Ramos
9db33e6a51 Fixed date usage 2024-08-20 09:17:48 +01:00
Joao Ramos
ba5d926659 Merge branch 'develop' into ENCOA-83_MasterStatistical 2024-08-20 01:16:46 +01:00
Joao Ramos
1cd4dfc397 Added download option to master Corporate and teacher 2024-08-20 01:15:43 +01:00
Joao Ramos
bf5dd62b35 Updated Excel document 2024-08-20 01:01:50 +01:00
Tiago Ribeiro
4e583d11b6 Improved the bug on the teachers 2024-08-20 00:10:26 +01:00
Tiago Ribeiro
688505b4eb Updated the permissions access 2024-08-20 00:01:59 +01:00
carlos.mesquita
81b8ceb2b3 Merged in feature/level-file-upload (pull request #72)
Fixed question numbers for fillBlanks exercises, reverted multipleChoice underline prompt, added part label to module title, and changed some styles
2024-08-19 22:48:26 +00:00
carlos.mesquita
d93d36c392 Merged develop into feature/level-file-upload 2024-08-19 22:47:48 +00:00
Carlos Mesquita
3299acee36 Forgot to add a key in Level 2024-08-19 23:46:51 +01:00
Carlos Mesquita
abddead402 Fixed question numbers for fillBlanks exercises, reverted multipleChoice underline prompt, added part label to module title, and changed some styles 2024-08-19 23:43:08 +01:00
Joao Ramos
2d69fdac3c Added missing updated lockfile 2024-08-19 23:39:38 +01:00
Joao Ramos
506ff2503e Merge branch 'develop' into ENCOA-83_MasterStatistical 2024-08-19 23:38:46 +01:00
Tiago Ribeiro
5d191730d2 Added the total of assignments to the Master Corporate 2024-08-19 23:04:13 +01:00
Tiago Ribeiro
346b131388 Improved the sorting and filtering for the Student Performance page 2024-08-19 19:46:43 +01:00
carlos.mesquita
aba49e385f Merged in feature/level-file-upload (pull request #71)
Previously commented a required line
2024-08-19 16:25:52 +00:00
Carlos Mesquita
5789688eab Previously commented a required line 2024-08-19 17:24:10 +01:00
Tiago Ribeiro
f7da11bc69 Updated the way groups work there 2024-08-19 17:03:33 +01:00
carlos.mesquita
10802f6bb5 Merged in feature/level-file-upload (pull request #70)
Feature/level file upload
2024-08-19 15:55:10 +00:00
Carlos Mesquita
37e356572b Merge branch 'develop' of https://bitbucket.org/ecropdev/ielts-ui into feature/level-file-upload 2024-08-19 16:53:39 +01:00
Carlos Mesquita
8669ef462d Commented all related to shuffle 2024-08-19 16:42:14 +01:00
Tiago Ribeiro
df1c0bad4d Created a student performance page 2024-08-19 16:41:35 +01:00
Carlos Mesquita
bcb1a0f914 If someone else wants to join in on the fun be my guest 2024-08-19 01:24:55 +01:00
Joao Ramos
bf1bdd935c Improvements on excel rendering 2024-08-18 21:25:18 +01:00
Carlos Mesquita
edc9d4de2a Fill Blanks changes 2024-08-18 08:07:16 +01:00
Tiago Ribeiro
229275aaee Created a groups page for students and teachers 2024-08-17 20:18:28 +01:00
Tiago Ribeiro
f0ff6ac691 ENCOA-87: Allow MasterCorporate & Corporate to change the type of students and teachers 2024-08-17 19:15:20 +01:00
Tiago Ribeiro
878c7c2ef0 Updated the Groups List to allow teachers to view their corporate's students 2024-08-16 11:50:27 +01:00
Tiago Ribeiro
0a28c2bd41 Added a "last login" to the users 2024-08-15 23:55:08 +01:00
Tiago Ribeiro
38e48c90bb Updated the label for Admin 2024-08-15 23:37:34 +01:00
Tiago Ribeiro
c6f35d7750 Enable the option to not have only full exams 2024-08-15 19:25:39 +01:00
Tiago Ribeiro
85f684dff5 Updated the user balance to be based not only on the amount of codes 2024-08-15 19:24:08 +01:00
Tiago Ribeiro
d94a9bb88a Quick little fix 2024-08-15 18:39:17 +01:00
Joao Ramos
1950d5f15d Added initial Excel changes 2024-08-15 14:56:14 +01:00
Joao Ramos
e84cc8ddd8 Cleaned up some code 2024-08-15 13:39:42 +01:00
Joao Ramos
cf2fd06d39 Added Icon ofr consolidate highest student 2024-08-15 10:38:56 +01:00
Joao Ramos
b6015b6433 Added any to error to prevent an error 2024-08-15 10:35:08 +01:00
Joao Ramos
fea58a7b40 Added initial implementation for master statistical 2024-08-15 10:34:31 +01:00
João Ramos
13284eab75 Merged in ENCOA-82_Permissions (pull request #69)
Updated permissions to have a key to group them
2024-08-14 19:38:30 +00:00
Tiago Ribeiro
dd4e3a4694 Merged develop into ENCOA-82_Permissions 2024-08-14 19:38:16 +00:00
Tiago Ribeiro
eb55e65d91 Solved some issues related to the BatchCreateUser 2024-08-14 20:36:47 +01:00
Joao Ramos
cb75ba6056 Updated permissions to have a key to group them 2024-08-14 18:44:35 +01:00
João Ramos
859d9283a7 Merged in ENCOA-77_GenerationTitle (pull request #64)
Added title to the exam generate

Approved-by: Tiago Ribeiro
2024-08-13 21:40:04 +00:00
Tiago Ribeiro
1a3437b333 Merged develop into ENCOA-77_GenerationTitle 2024-08-13 21:38:56 +00:00
João Ramos
bbbf17daa0 Merged in ENCOA-79_PaymentRecordsFilters (pull request #61)
ENCOA-79 PaymentRecordsFilters

Approved-by: Tiago Ribeiro
2024-08-13 21:38:38 +00:00
Joao Ramos
ae79aef132 Merge branch 'develop' into ENCOA-79_PaymentRecordsFilters 2024-08-13 21:32:33 +01:00
Joao Ramos
c3e71b4389 Merge branch 'develop' into ENCOA-77_GenerationTitle 2024-08-13 21:30:50 +01:00
Tiago Ribeiro
2784117862 Updated the MasterCorporate and Corporate pages to allow to have Assignments 2024-08-13 10:02:40 +01:00
Tiago Ribeiro
8162567e12 Updated the Batch Create User to also have an expiry date 2024-08-12 19:49:18 +01:00
Tiago Ribeiro
58300e32ff Solved an issue where, for developers, because of the amount of permissions, the cookie was too big, so I separated the permissions logic into a hook 2024-08-12 19:35:11 +01:00
carlos.mesquita
cb489bf0ca Merged in feature/training-content (pull request #67)
Tooltips for assessment criteria on Writing and Speaking

Approved-by: Tiago Ribeiro
2024-08-12 14:13:28 +00:00
Tiago Ribeiro
91bc91e725 Merged develop into feature/training-content 2024-08-12 14:12:55 +00:00
João Ramos
ce086a8b22 Merged in ENCOA-81_CodeListPermissions (pull request #68)
ENCOA-81: Fixed issue with the users that each role could create

Approved-by: Tiago Ribeiro
2024-08-12 14:12:25 +00:00
Joao Ramos
6e71ee7cb0 ENCOA-81: Fixed issue with the users that each role could create 2024-08-08 09:17:33 +01:00
Carlos Mesquita
21e58e3b9c Tooltips for assessment criteria on Writing and Speaking 2024-08-07 13:04:42 +01:00
João Ramos
b885dd46b5 Merged in ENCOA-80_Permissions (pull request #66)
ENCOA-80 Permissions

Approved-by: Tiago Ribeiro
2024-08-07 09:30:04 +00:00
Joao Ramos
0fc2df1070 Added permission to codes 2024-08-07 10:03:17 +01:00
Joao Ramos
cf91f1812d Added group permissions 2024-08-07 09:36:08 +01:00
Tiago Ribeiro
3289f27cd5 Merged in settings-import-users (pull request #65)
Settings import users
2024-08-07 06:48:37 +00:00
Tiago Ribeiro
80939d16a5 Merged develop into settings-import-users 2024-08-07 06:47:59 +00:00
carlos.mesquita
11b5490af4 Merged in feature/training-content (pull request #62)
Feature/training content

Approved-by: Tiago Ribeiro
2024-08-06 18:46:43 +00:00
João Ramos
a31070d4a3 Merged in ENCOA-78_ExamList (pull request #63)
Added more columns to exam list

Approved-by: Tiago Ribeiro
2024-08-06 18:46:20 +00:00
Joao Ramos
95c3f89911 Added title to the exam generate 2024-08-06 19:24:43 +01:00
Joao Ramos
2a58e0d33f Added more columns to exam list 2024-08-06 18:40:49 +01:00
mohammedzeglam-pg
afe59f5a3a add group creation. 2024-08-06 18:03:00 +02:00
Carlos Mesquita
7fd56357e0 UI changes to Solutions (Writing, Speaking and Interactive Speaking) as requested 2024-08-06 17:02:17 +01:00
Carlos Mesquita
a4a40b9145 Bug fixes to training, added a spinner to record while it loads, made changes to speaking as requested 2024-08-05 21:44:14 +01:00
Joao Ramos
48faee07f6 Added missing fussy search fileds and support for number 2024-08-05 19:32:53 +01:00
Joao Ramos
f0d7d7644b Added date filter 2024-08-05 19:26:50 +01:00
Carlos Mesquita
309dfba583 Fixed the training content view 2024-08-05 10:41:11 +01:00
carlos.mesquita
cf64a91651 Merged in feature/training-content (pull request #60)
Patched the module badges on the training view
2024-08-03 14:23:05 +00:00
Carlos Mesquita
0f47a8af70 Merge branch 'feature/training-content' of https://bitbucket.org/ecropdev/ielts-ui into feature/training-content 2024-08-03 14:33:55 +01:00
Carlos Mesquita
d0310f7c2b Patched the module badges on the training view 2024-08-03 14:32:54 +01:00
carlos.mesquita
f6a0a391b9 Merged in feature/training-content (pull request #59)
Dropdown on training view
2024-08-03 10:13:44 +00:00
Tiago Ribeiro
8dd4dad096 Merged develop into feature/training-content 2024-08-03 10:13:37 +00:00
Carlos Mesquita
96baa2a6e0 Dropdown on training view 2024-08-03 11:10:01 +01:00
carlos.mesquita
8dd557a29b Merged in feature/training-content (pull request #58)
Feature/training content

Approved-by: Tiago Ribeiro
2024-08-03 09:49:00 +00:00
mzerone
4e30eda06f make user verified by default. 2024-08-03 01:37:41 +02:00
Carlos Mesquita
12bb124d91 Merge branch 'develop' of https://bitbucket.org/ecropdev/ielts-ui into feature/training-content 2024-08-02 15:01:05 +01:00
Carlos Mesquita
a71e6632d6 Hooked training content to backend, did refactors to training components and record.tsx 2024-08-01 20:40:31 +01:00
mzerone
36f518afca add create user in settings. 2024-08-01 00:41:35 +02:00
Carlos Mesquita
a534126c61 training.tsx still a bit messy, all that is left is to retrieve data from firestore /training and /walkthrough and render it 2024-07-31 20:44:46 +01:00
Tiago Ribeiro
752a46b247 Updated the LevelGeneration to be enabled by default 2024-07-31 19:15:36 +01:00
Tiago Ribeiro
663b1aae4f Updated the yarn.lock 2024-07-31 10:42:47 +01:00
Tiago Ribeiro
9b37b60be0 Improved the table 2024-07-30 23:22:30 +01:00
Tiago Ribeiro
4347d0cabb Merge branch 'develop' into feature/update-permission-ui 2024-07-30 23:20:16 +01:00
Tiago Ribeiro
0403773b8e Changed the IDs to now be words and allows the assignment to be like chosen 2024-07-30 23:18:50 +01:00
mzerone
8d99a6b03c ui updates for permissions. 2024-07-29 16:16:14 +02:00
Tiago Ribeiro
02320b9484 Tiny issue 2024-07-28 11:22:46 +01:00
Tiago Ribeiro
fb077fd8cc Updated the multiple choice 2024-07-27 17:43:26 +01:00
Tiago Ribeiro
b5a305485f Finalized the reading generation 2024-07-27 14:55:48 +01:00
Tiago Ribeiro
8f5b27e9ce Updated the FillBlanks to the new format 2024-07-27 14:38:45 +01:00
Tiago Ribeiro
9ef04b822a Updated the design of the feedback 2024-07-27 00:04:52 +01:00
Tiago Ribeiro
a6160c3cf0 Updated and fixed the level generation 2024-07-26 23:43:23 +01:00
Tiago Ribeiro
8f6639b7fc Finalized the Level Generation 2024-07-26 14:09:08 +01:00
Tiago Ribeiro
6a803fe137 Merge branch 'develop' of bitbucket.org:ecropdev/ielts-ui into develop 2024-07-26 11:51:03 +01:00
Carlos Mesquita
d7f6a4dde7 Undefined was causing record page to crash 2024-07-26 11:00:18 +01:00
Tiago Ribeiro
6058e510de Added the ability to generate custom level exams, still WIP in some parts 2024-07-26 10:29:36 +01:00
carlos.mesquita
7208530879 Merged in feature/ai-detection (pull request #57)
Used main branch as base branch in the last time

Approved-by: Tiago Ribeiro
2024-07-25 21:01:56 +00:00
Tiago Ribeiro
9b6c545932 Merged develop into feature/ai-detection 2024-07-25 21:00:40 +00:00
João Ramos
afb9071758 Merged in ENCOA-56_Permissions (pull request #55)
ENCOA-56 Permissions

Approved-by: Tiago Ribeiro
2024-07-25 21:00:28 +00:00
Tiago Ribeiro
d50393930e Merged develop into ENCOA-56_Permissions 2024-07-25 21:00:19 +00:00
João Ramos
03e1f2cfa3 Merged in master-corporate (pull request #54)
Master corporate

Approved-by: Tiago Ribeiro
2024-07-25 21:00:04 +00:00
Carlos Mesquita
877d2f359f Used main branch as base branch in the last time 2024-07-25 16:59:15 +01:00
Joao Ramos
45df9837e7 Added permissions to filter out the user update 2024-07-25 11:23:11 +01:00
Joao Ramos
923319051c Added code role validation 2024-07-25 09:43:11 +01:00
Joao Ramos
f6b4d6ad52 Initial approach where I replaced all the entries for checkAccess 2024-07-25 09:13:13 +01:00
Joao Ramos
19d16c9cef Added new permission system 2024-07-24 18:52:02 +01:00
Joao Ramos
daa27e41b3 Merge branch 'develop' into master-corporate 2024-07-24 09:50:53 +01:00
Joao Ramos
916fa66446 Added linked account 2024-07-24 09:25:29 +01:00
Tiago Ribeiro
10a3243756 Updated it to work with the new canges 2024-07-23 14:43:24 +01:00
Tiago Ribeiro
a1c7f70329 Solved some small issues 2024-07-22 23:35:22 +01:00
Tiago Ribeiro
bd2efb0ef5 Updated the color of the task response 2024-07-22 23:30:22 +01:00
Joao Ramos
34065f1f6e Merge branch 'develop' into master-corporate 2024-07-20 12:38:52 +01:00
Tiago Ribeiro
41873f80d7 ENCOA-66: Payment record not sorting 2024-07-18 15:45:28 +01:00
João Ramos
a1b67c017d Merged in ENCOA-57_EditExamsGenerate (pull request #53)
ENCOA-57 EditExamsGenerate

Approved-by: Tiago Ribeiro
2024-07-18 09:32:05 +00:00
Joao Ramos
13fd7e1ee5 Updated Level Generation 2024-07-17 23:44:53 +01:00
Joao Ramos
4996417218 Fixed sentence header mapping 2024-07-17 23:00:16 +01:00
Joao Ramos
60d436b5b9 Fixed wrong count 2024-07-13 17:25:10 +01:00
Joao Ramos
8d39a20267 Fixed broken list 2024-07-13 17:22:55 +01:00
Joao Ramos
5d46d7e453 Added initial support for "mastercorporate" 2024-07-13 17:19:42 +01:00
Joao Ramos
15f9fb320d Changed approach on available type selection 2024-07-09 23:42:16 +01:00
Joao Ramos
494fc9bab6 Changed approach on available type selection 2024-07-07 16:59:49 +01:00
Joao Ramos
0c5c024098 Added Match Sentences to Reading Generation 2024-07-07 16:43:14 +01:00
Joao Ramos
903a567805 Fixed error with update part 2024-07-04 01:35:10 +01:00
Joao Ramos
df3929d5e6 Added parse for speaking generation 2024-07-04 01:34:07 +01:00
Joao Ramos
6d62500596 Added write the blanks for Listening 2024-07-04 01:23:59 +01:00
Joao Ramos
e5e4e87752 Added Listening Multiple Choice Edit 2024-07-04 01:15:33 +01:00
Joao Ramos
0b3e686f3f Match sentence log 2024-07-04 00:25:04 +01:00
Joao Ramos
3da87cce60 Added write blanks edit 2024-07-04 00:19:07 +01:00
Joao Ramos
c9daba17e1 Added Fill Blanks Edits + True False Edit 2024-07-03 11:13:13 +01:00
João Ramos
5cfd6d56a6 Merged in bug-fixing-19-Jun-24 (pull request #52)
Bug fixing 19 Jun 24

Approved-by: Tiago Ribeiro
2024-06-25 11:47:24 +00:00
Joao Ramos
ec8c06ca94 Filtered out the fields from the UserCard in the list wouldn't have anything to display 2024-06-24 11:12:16 +01:00
Joao Ramos
77a22b3ab3 Added a screen to display the desired levels for the students of a specific corporate 2024-06-22 00:16:30 +01:00
Joao Ramos
e79139174b Added an initial filter for corporate on records 2024-06-21 23:22:49 +01:00
Joao Ramos
61a86394ed Added persistance to the selected user record 2024-06-20 23:20:52 +01:00
Joao Ramos
f6741dd80e Added date filter to code list 2024-06-20 22:57:34 +01:00
Joao Ramos
ce6708be6e Minor fixing on a duplicated key on table 2024-06-20 22:34:02 +01:00
Joao Ramos
b62cae2e3a Filtered done ticket out of the view 2024-06-19 23:14:05 +01:00
Joao Ramos
d73b6d9d12 Added badges for students 2024-06-19 23:00:43 +01:00
Joao Ramos
c11906a395 Standardized the access to the list of users 2024-06-19 21:59:35 +01:00
Joao Ramos
a29b0b56d9 Changed to the correct format for CSV 2024-06-19 21:39:27 +01:00
Tiago Ribeiro
53dbf99fba Fixed some more issues with the Speaking 2024-06-18 22:16:31 +01:00
Tiago Ribeiro
cb49e15cb0 Updated the speaking and interactive speaking to the new format 2024-06-18 10:02:03 +01:00
Tiago Ribeiro
0eddded560 Updated part of the speaking accordingly 2024-06-13 18:17:07 +01:00
Tiago Ribeiro
11c6f70576 Updated a simple bug 2024-06-11 22:44:00 +01:00
Tiago Ribeiro
6712e89c47 Updated the format of the interactive speaking 2024-06-11 11:20:54 +01:00
Tiago Ribeiro
9959cf4294 Removed the persistence for the Speaking exam for now 2024-06-08 10:39:26 +01:00
Tiago Ribeiro
daec246835 Updated the Level Exam to work based on Parts 2024-06-07 13:25:18 +01:00
Tiago Ribeiro
8ea97ee944 Added a new feature to check for and register inactivity during an exam 2024-06-04 22:18:45 +01:00
Tiago Ribeiro
975f4c8285 Updated Firebase to use a service account depending on the environment 2024-05-29 09:06:46 +01:00
Tiago Ribeiro
f0b85409c9 Merge branch 'main' into develop 2024-05-27 16:37:09 +01:00
Tiago Ribeiro
bdd862c633 Updated the state to be active on payment 2024-05-27 13:08:11 +01:00
Tiago Ribeiro
4166781f7e Improved some issues with the payment 2024-05-27 13:05:38 +01:00
Tiago Ribeiro
1f8e9106de Updated the code to set the expiry date to the end of the day 2024-05-27 12:19:25 +01:00
Tiago Ribeiro
9e651358d5 Updated the code to return a 400 when it is not a success 2024-05-27 12:16:08 +01:00
Tiago Ribeiro
5aed336c96 Added a log 2024-05-27 11:02:11 +01:00
Tiago Ribeiro
85b94512e9 Merge branch 'develop' into ENCOA-38/add-validity-date-for-discounts 2024-05-23 19:22:31 +01:00
Tiago Ribeiro
906646ebce Created the validity dates for discounts 2024-05-23 19:21:52 +01:00
Tiago Ribeiro
96108a4958 Reverted to have checks 2024-05-23 17:22:57 +01:00
Tiago Ribeiro
fb449f2054 Updated the status when the transaction is not successful 2024-05-21 15:40:18 +01:00
Tiago Ribeiro
d5ee3d9519 Added a log for debugging 2024-05-21 15:35:57 +01:00
Tiago Ribeiro
4e20ec6575 Removed a check from the webhook 2024-05-21 12:04:31 +01:00
Tiago Ribeiro
836b674076 Added some changes to the propagate corporate changes 2024-05-21 11:21:14 +01:00
Tiago Ribeiro
5086c6fb09 Solved a visual bug 2024-05-21 11:09:36 +01:00
Tiago Ribeiro
489c9c3b7e Possibly solved part of the issue with speaking 2024-05-20 21:28:45 +01:00
Tiago Ribeiro
e3ded29e77 Merge branch 'develop' 2024-05-20 21:09:43 +01:00
Tiago Ribeiro
16419a5584 Fixed a bug introduced on the last one 2024-05-20 11:23:52 +01:00
Tiago Ribeiro
3e3b24cc30 Solved a bug for level test 2024-05-20 11:18:46 +01:00
Tiago Ribeiro
841698ba10 Updated the profile to also have the focus in it 2024-05-20 11:13:09 +01:00
Tiago Ribeiro
d50904611c Added a missing space 2024-05-16 15:42:13 +01:00
Tiago Ribeiro
e77fd16d26 Added a space to it 2024-05-16 15:03:31 +01:00
Tiago Ribeiro
649f24e4ae Updated the showcase 2024-05-16 14:51:19 +01:00
Tiago Ribeiro
2f0cbfe74e Removed the billing details modal 2024-05-16 14:30:44 +01:00
Tiago Ribeiro
d022bd078a Updated the currencies to have OMR as well 2024-05-16 13:44:27 +01:00
Tiago Ribeiro
c18afee9ad Updated the packages 2024-05-16 13:34:18 +01:00
541 changed files with 61056 additions and 28242 deletions

6
.gitignore vendored
View File

@@ -1,3 +1,5 @@
src/constants/test_firebase.json
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
@@ -38,4 +40,6 @@ next-env.d.ts
.env
.yarn/*
.history*
__ENV.js
__ENV.js
settings.json

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
@@ -54,4 +56,4 @@ EXPOSE 3000
ENV PORT 3000
ENV HOSTNAME localhost
CMD ["node", "server.js"]
CMD HOSTNAME="0.0.0.0" node server.js

17
components.json Normal file
View File

@@ -0,0 +1,17 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "new-york",
"rsc": false,
"tsx": true,
"tailwind": {
"config": "tailwind.config.ts",
"css": "src/styles/globals.css",
"baseColor": "neutral",
"cssVariables": false,
"prefix": ""
},
"aliases": {
"components": "@/components",
"utils": "@/lib/utils"
}
}

View File

@@ -1,7 +1,7 @@
/** @type {import('next').NextConfig} */
const websiteUrl = process.env.NODE_ENV === 'production' ? "https://encoach.com" : "http://localhost:3000";
const nextConfig = {
reactStrictMode: true,
reactStrictMode: false,
output: "standalone",
async headers() {
return [

9871
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,102 +1,119 @@
{
"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",
"@headlessui/react": "^1.7.13",
"@mdi/js": "^7.1.96",
"@mdi/react": "^1.6.1",
"@next/font": "13.1.6",
"@paypal/paypal-js": "^7.1.0",
"@paypal/react-paypal-js": "^8.1.3",
"@react-pdf/renderer": "^3.1.14",
"@tanstack/react-table": "^8.10.1",
"@types/node": "18.13.0",
"@types/react": "18.0.27",
"@types/react-dom": "18.0.10",
"axios": "^1.3.5",
"bcrypt": "^5.1.1",
"chart.js": "^4.2.1",
"clsx": "^1.2.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",
"express-handlebars": "^7.1.2",
"firebase": "9.19.1",
"firebase-admin": "^11.10.1",
"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": "13.1.6",
"nodemailer": "^6.9.5",
"nodemailer-express-handlebars": "^6.1.0",
"paymob-react": "git+https://github.com/tiago-ecrop/paymob-react-oman.git",
"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": "^4.8.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-xarrows": "^2.0.2",
"read-excel-file": "^5.7.1",
"short-unique-id": "^5.0.2",
"stripe": "^13.10.0",
"swr": "^2.1.3",
"tailwind-scrollbar-hide": "^1.1.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": {
"@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",
"types/": "paypal/react-paypal-js"
}
}
"name": "next-wind",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "next lint",
"prepare": "husky install"
},
"dependencies": {
"@beam-australia/react-env": "^3.1.1",
"@dnd-kit/core": "^6.1.0",
"@dnd-kit/modifiers": "^7.0.0",
"@dnd-kit/sortable": "^8.0.0",
"@firebase/util": "^1.9.7",
"@headlessui/react": "^2.1.2",
"@mdi/js": "^7.1.96",
"@mdi/react": "^1.6.1",
"@paypal/paypal-js": "^7.1.0",
"@paypal/react-paypal-js": "^8.1.3",
"@radix-ui/react-icons": "^1.3.0",
"@radix-ui/react-popover": "^1.1.1",
"@react-pdf/renderer": "^3.1.14",
"@react-spring/web": "^9.7.4",
"@tanstack/react-table": "^8.10.1",
"@types/node": "18.13.0",
"@types/react": "^18.3.3",
"@types/react-dom": "^18.3.0",
"@use-gesture/react": "^10.3.1",
"axios": "^1",
"axios-cache-interceptor": "^1",
"bcrypt": "^5.1.1",
"chart.js": "^4.2.1",
"class-variance-authority": "^0.7.0",
"clsx": "^2.1.1",
"countries-list": "^3.0.1",
"country-codes-list": "^1.6.11",
"currency-symbol-map": "^5.1.0",
"daisyui": "^3.1.5",
"deep-diff": "^1.0.2",
"eslint": "8.33.0",
"eslint-config-next": "13.1.6",
"exceljs": "^4.4.0",
"express-handlebars": "^7.1.2",
"firebase": "9.19.1",
"firebase-admin": "^11.10.1",
"firebase-scrypt": "^2.2.0",
"formidable": "^3.5.0",
"formidable-serverless": "^1.1.1",
"framer-motion": "^9.0.2",
"howler": "^2.2.4",
"immer": "^10.1.1",
"iron-session": "^6.3.1",
"lodash": "^4.17.21",
"moment": "^2.29.4",
"moment-timezone": "^0.5.44",
"mongodb": "^6.8.1",
"next": "^14.2.5",
"nodemailer": "^6.9.5",
"nodemailer-express-handlebars": "^6.1.0",
"primeicons": "^6.0.1",
"primereact": "^9.2.3",
"qrcode": "^1.5.3",
"random-words": "^2.0.0",
"react": "18.2.0",
"react-chartjs-2": "^5.2.0",
"react-csv": "^2.2.2",
"react-currency-input-field": "^3.6.12",
"react-datepicker": "^4.18.0",
"react-diff-viewer": "^3.1.1",
"react-dom": "18.2.0",
"react-firebase-hooks": "^5.1.1",
"react-icons": "^5.3.0",
"react-lineto": "^3.3.0",
"react-media-recorder": "1.6.5",
"react-phone-number-input": "^3.3.6",
"react-player": "^2.12.0",
"react-select": "^5.7.5",
"react-string-replace": "^1.1.0",
"react-toastify": "^9.1.2",
"react-tooltip": "^5.27.1",
"react-xarrows": "^2.0.2",
"read-excel-file": "^5.7.1",
"short-unique-id": "5.0.2",
"stripe": "^13.10.0",
"swr": "^2.2.5",
"tailwind-merge": "^2.5.2",
"tailwind-scrollbar-hide": "^1.1.7",
"tailwindcss-animate": "^1.0.7",
"typescript": "4.9.5",
"use-file-picker": "^2.1.0",
"uuid": "^9.0.0",
"wavesurfer.js": "^6.6.4",
"zustand": "^4.3.6"
},
"devDependencies": {
"@simbathesailor/use-what-changed": "^2.0.0",
"@types/blob-stream": "^0.1.33",
"@types/deep-diff": "^1.0.5",
"@types/formidable": "^3.4.0",
"@types/howler": "^2.2.11",
"@types/lodash": "^4.14.191",
"@types/nodemailer": "^6.4.11",
"@types/nodemailer-express-handlebars": "^4.0.3",
"@types/qrcode": "^1.5.5",
"@types/react-csv": "^1.1.10",
"@types/react-datepicker": "^4.15.1",
"@types/uuid": "^9.0.1",
"@types/wavesurfer.js": "^6.0.6",
"@wixc3/react-board": "^2.2.0",
"autoprefixer": "^10.4.13",
"husky": "^8.0.3",
"postcss": "^8.4.21",
"tailwindcss": "^3.2.4"
},
"packageManager": "yarn@1.22.22+sha512.a6b2f7906b721bba3d67d4aff083df04dad64c399707841b7acf00f6b133b7ac24255f2652fa22ae3534329dc6180534e98d17432037ff6fd140556e2bb3137e"
}

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 419 KiB

1
public/mat-icon-info.svg Normal file
View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 -960 960 960" width="24px" fill="#e8eaed"><path d="M440-280h80v-240h-80v240Zm40-320q17 0 28.5-11.5T520-640q0-17-11.5-28.5T480-680q-17 0-28.5 11.5T440-640q0 17 11.5 28.5T480-600Zm0 520q-83 0-156-31.5T197-197q-54-54-85.5-127T80-480q0-83 31.5-156T197-763q54-54 127-85.5T480-880q83 0 156 31.5T763-763q54 54 85.5 127T880-480q0 83-31.5 156T763-197q-54 54-127 85.5T480-80Zm0-80q134 0 227-93t93-227q0-134-93-227t-227-93q-134 0-227 93t-93 227q0 134 93 227t227 93Zm0-320Z"/></svg>

After

Width:  |  Height:  |  Size: 535 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 418 KiB

BIN
public/red-stock-photo.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.3 MiB

View File

@@ -0,0 +1,51 @@
import dotenv from "dotenv";
dotenv.config();
import { MongoClient } from "mongodb";
const uri = process.env.MONGODB_URI || "";
const options = {
maxPoolSize: 10,
};
const dbName = process.env.MONGODB_DB; // change this to prod db when needed
async function migrateData() {
const MODULE_ARRAY = ["reading", "listening", "writing", "speaking", "level"];
const client = new MongoClient(uri, options);
try {
await client.connect();
console.log("Connected to MongoDB");
if (!process.env.MONGODB_DB) {
throw new Error("Missing env var: MONGODB_DB");
}
const db = client.db(dbName);
for (const string of MODULE_ARRAY) {
const collection = db.collection(string);
const result = await collection.updateMany(
{ private: { $exists: false } },
{ $set: { access: "public" } }
);
const result2 = await collection.updateMany(
{ private: true },
{ $set: { access: "private" }, $unset: { private: "" } }
);
const result1 = await collection.updateMany(
{ private: { $exists: true } },
{ $set: { access: "public" } }
);
console.log(
`Updated ${
result.modifiedCount + result1.modifiedCount
} documents to "access: public" in ${string}`
);
console.log(
`Updated ${result2.modifiedCount} documents to "access: private" and removed private var in ${string}`
);
}
console.log("Migration completed successfully!");
} catch (error) {
console.error("Migration failed:", error);
} finally {
await client.close();
console.log("MongoDB connection closed.");
}
}
//migrateData(); // uncomment to run the migration

View File

@@ -0,0 +1,193 @@
import Image from "next/image";
import clsx from "clsx";
import RadialProgressBar from "./RadialProgressBar";
import { AIDetectionAttributes } from "@/interfaces/exam";
import { Tooltip } from 'react-tooltip';
import SegmentedProgressBar from "./SegmentedProgressBar";
// Colors and texts scrapped from gpt's zero react bundle
const AIDetection: React.FC<AIDetectionAttributes> = ({ predicted_class, confidence_category, class_probabilities, sentences }) => {
const probabilityTooltipContent = `
Encoach's deep learning model predicts the <br/>
probability this text has been entirely <br/>
generated by AI. For instance, a 40% AI <br/>
probability does not indicate that the text<br/>
contains 40% AI-written content. Rather, it<br/>
indicates the text is more likely to be partially<br/>
human written than be entirely AI-written.
`;
const confidenceTooltipContent = `
Confidence scores are a safeguard to better<br/>
understand AI identification results. Encoach<br/>
trained it's deep learning model on a diverse<br/>
dataset of millions of human and AI-written<br/>
documents. Green scores indicate that you can scan<br/>
with confidence that the model has classified<br/>
many similar documents with high accuracy.<br/>
Red scores indicate that this text is dissimilar<br/>
to the ones in their training set, which can impact<br/>
the model's accuracy, and to proceed with caution.
`;
const confidenceKeywords = ["moderately", "highly", "confident", "uncertain"];
var confidence = {
low: {
ai: "Encoach is uncertain about this text. If Encoach had to classify it, it would be considered",
human: "Encoach is uncertain about this text. If Encoach had to classify it, it would likely be considered",
mixed: "Encoach is uncertain about this text. If Encoach had to classify it, it would likely be a"
},
medium: {
ai: "Encoach is moderately confident this text was",
human: "Encoach is moderately confident this text is entirely",
mixed: "Encoach is moderately confident this text is a"
},
high: {
ai: "Encoach is highly confident this text was",
human: "Encoach is highly confident this text is entirely",
mixed: "Encoach is highly confident this text is a"
}
}
var classPrediction = {
ai: {
background: "bg-ai-detection-result-ai-bg",
color: "text-ai-detection-result-ai",
text: "ai generated"
},
mixed: {
background: "bg-ai-detection-result-mixed-bg",
color: "text-ai-detection-result-mixed",
text: "mix of ai and human"
},
human: {
background: "bg-ai-detection-result-human-bg",
color: "text-ai-detection-result-human",
text: "human"
}
}
const segments = [
{ percentage: Math.round(class_probabilities["human"] * 100), subtitle: 'human', color: "ai-detection-result-human" },
{ percentage: Math.round(class_probabilities["mixed"] * 100), subtitle: 'mixed', color: "ai-detection-result-mixed" },
{ percentage: Math.round(class_probabilities["ai"] * 100), subtitle: 'ai', color: "ai-detection-result-ai" }
];
const styleConfidenceText = (text: string): [string, string[]] => {
const keywords: string[] = [];
const styledText = text.split(" ").map(word => {
if (confidenceKeywords.includes(word)) {
keywords.push(word);
return `<span style="font-weight: 500; text-decoration: underline;">${word}</span>`;
}
return word
}).join(" ");
return [styledText, keywords];
};
const confidenceText = confidence[confidence_category][predicted_class];
const [styledText, keywords] = styleConfidenceText(confidenceText);
const tooltipStyle = {
"backgroundColor": "rgb(255, 255, 255)",
"color": "#8992B1",
boxShadow: '0 4px 6px rgba(0, 0, 0, 0.1)',
borderRadius: '0.125rem'
}
const highestProbability = Math.max(class_probabilities["ai"], class_probabilities["human"], class_probabilities["mixed"]);
const spanTextColor = highestProbability === class_probabilities["ai"]
? "#f4bf4f"
: highestProbability === class_probabilities["human"]
? "#50c08a"
: "#93aafb";
let spanClassName = highestProbability === class_probabilities["ai"]
? "text-ai-detection-result-ai"
: highestProbability === class_probabilities["human"]
? "text-ai-detection-result-human"
: "text-ai-detection-result-mixed";
spanClassName = `${spanClassName} font-bold text-lg`
const percentage = Math.round(highestProbability * 100)
const hasHighlightedForAI = sentences.some(item => item.highlight_sentence_for_ai);
return (
<>
<Tooltip id="probability-tooltip" className="z-50 bg-white shadow-md rounded-sm" style={tooltipStyle} />
<Tooltip id="confidence-tooltip" className="z-50 bg-white shadow-md rounded-sm" style={tooltipStyle} />
<div className="flex flex-col bg-white p-6 rounded-lg shadow-lg gap-16">
<h1 className="text-lg font-semibold">Encoach Detection Results</h1>
<div className="flex flex-row -md:flex-col -lg:gap-0 -xl:gap-10 gap-20 items-stretch -md:items-center">
<div className="flex -md:w-5/6 w-1/2 justify-center">
<div className="flex flex-col border rounded-xl">
<h1 className="border-b p-6 font-medium">Text Classification</h1>
<div className="flex flex-row gap-8 items-center p-6">
<RadialProgressBar
percentage={percentage}
text={predicted_class}
color={spanTextColor}
spanClassName={spanClassName}
/>
<div className="flex flex-col gap-1 text-sm">
<div className="flex flex-row items-center">
<span className="mr-2 text-ai-detection-result-ai-text font-semibold text-xl">
{`${Math.round(class_probabilities["ai"] * 100)}%`}
</span>
<span className="text-sm -md:text-xs text-ai-detection-text">Probability AI generated</span>
<a data-tooltip-id="probability-tooltip" data-tooltip-html={probabilityTooltipContent} className='ml-1 flex items-center justify-center'>
<Image src="/mat-icon-info.svg" width={24} height={24} alt="Probability Tooltip" />
</a>
</div>
<div className="flex flex-row items-center gap-1">
<div className={clsx(
"rounded-full w-3 h-3",
confidence_category == 'low' ?
"bg-ai-detection-confidence-low border border-ai-detection-confidence-border" : "bg-ai-detection-confidence-low-transparent"
)}></div>
<div className={clsx(
"rounded-full w-3 h-3",
confidence_category == 'medium' ?
"bg-ai-detection-confidence-medium border border-ai-detection-confidence-border" : "bg-ai-detection-confidence-medium-transparent"
)}></div>
<div className={clsx(
"rounded-full w-3 h-3 mr-2",
confidence_category == 'high' ?
"bg-ai-detection-confidence-high border border-ai-detection-confidence-border" : "bg-ai-detection-confidence-high-transparent"
)}></div>
<span className="text-sm -md:text-xs text-ai-detection-text">{keywords.join(' ')}</span>
<a data-tooltip-id="confidence-tooltip" data-tooltip-html={confidenceTooltipContent} className='ml-1 flex items-center justify-center'>
<Image src="/mat-icon-info.svg" width={24} height={24} alt="Probability Tooltip" />
</a>
</div>
</div>
</div>
</div>
</div>
<div className="flex flex-col border rounded-xl -md:w-5/6 w-2/6">
<h1 className="border-b p-6 font-medium">Probability Breakdown</h1>
<div className="flex items-center w-full h-full">
<SegmentedProgressBar segments={segments} className="w-full px-8 -md:py-8 text-ai-detection-text" />
</div>
</div>
</div>
<div className="flex flex-col gap-2">
<div className="flex flex-row items-center">
<div dangerouslySetInnerHTML={{ __html: styledText }} className="mr-2"></div>
<div className={clsx(
"flex items-center justify-center p-2 rounded",
classPrediction[predicted_class]['color'],
classPrediction[predicted_class]['background']
)}>
<span className="text-sm">{classPrediction[predicted_class]['text']}</span>
</div>
</div>
{(hasHighlightedForAI && <div>
Sentences that are likely written by AI are <span className="font-semibold bg-ai-detection-highlight">highlighted</span>.
</div>)}
</div>
</div >
<div>
{sentences.map((item, index) => (
<span
key={index}
className={item.highlight_sentence_for_ai ? 'bg-ai-detection-highlight' : ''}
>
{item.sentence}{' '}
</span>
))}
</div>
</>
)
}
export default AIDetection;

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

@@ -0,0 +1,32 @@
import Image from "next/image";
import React from "react";
import { FaRegUser } from "react-icons/fa";
interface Props {
prefix: string;
name: string;
profileImage: string;
}
export default function RequestedBy({ prefix, name, profileImage }: Props) {
return (
<div className="flex items-center space-x-3">
<div className="flex items-center justify-center w-12 h-12 bg-gray-100 rounded-lg border border-gray-300">
<FaRegUser className="text-mti-purple-dark size-5"/>
</div>
<div>
<p className="text-sm font-medium text-gray-800">Requested by</p>
<div className="flex items-center space-x-2">
<p className="text-xs font-medium text-gray-800">{prefix} {name}</p>
<img
src={profileImage ? profileImage : "/defaultAvatar.png"}
alt={name}
width={24}
height={24}
className="w-6 h-6 rounded-full border-[1px] border-gray-400 border-opacity-50"
/>
</div>
</div>
</div>
);
};

View File

@@ -0,0 +1,41 @@
import React from "react";
import { PiCalendarDots } from "react-icons/pi";
interface Props {
date: number;
}
export default function StartedOn({ date }: Props) {
const formattedDate = new Date(date);
const yearMonthDay = formattedDate.toISOString().split("T")[0];
const fullDateTime = formattedDate.toLocaleString("en-US", {
year: "numeric",
month: "long",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
hour12: false,
});
return (
<div className="flex items-center space-x-3">
<div className="flex items-center justify-center w-12 h-12 bg-gray-100 rounded-lg border border-gray-300">
<PiCalendarDots className="text-mti-purple-dark size-7" />
</div>
<div>
<p className="pb-1 text-sm font-medium text-gray-800">Started on</p>
<div className="flex items-center">
<p
className="text-xs font-medium text-gray-800"
title={fullDateTime}
>
{yearMonthDay}
</p>
</div>
</div>
</div>
);
};

View File

@@ -0,0 +1,23 @@
import { ApprovalWorkflowStatus, ApprovalWorkflowStatusLabel } from "@/interfaces/approval.workflow";
import React from "react";
import { RiProgress5Line } from "react-icons/ri";
interface Props {
status: ApprovalWorkflowStatus;
}
export default function Status({ status }: Props) {
return (
<div className="flex items-center space-x-3">
<div className="flex items-center justify-center w-12 h-12 bg-gray-100 rounded-lg border border-gray-300">
<RiProgress5Line className="text-mti-purple-dark size-7"/>
</div>
<div>
<p className="pb-1 text-sm font-medium text-gray-800">Status</p>
<div className="flex items-center">
<p className="text-xs font-medium text-gray-800">{ApprovalWorkflowStatusLabel[status]}</p>
</div>
</div>
</div>
);
};

View File

@@ -0,0 +1,14 @@
import { MdTipsAndUpdates } from "react-icons/md";
interface Props {
text: string;
}
export default function Tip({ text }: Props) {
return (
<div className="flex flex-row gap-3 text-gray-500 font-medium">
<MdTipsAndUpdates size={25} />
<p>{text}</p>
</div>
);
};

View File

@@ -0,0 +1,24 @@
import Image from "next/image";
interface Props {
prefix: string;
name: string;
profileImage: string;
textSize?: string;
}
export default function UserWithProfilePic({ prefix, name, profileImage, textSize }: Props) {
const textClassName = `${textSize ? textSize : "text-xs"} font-medium`
return (
<div className="flex items-center space-x-2">
<p className={textClassName}>{prefix} {name}</p>
<img
src={profileImage ? profileImage : "/defaultAvatar.png"}
alt={name}
width={24}
height={24}
className="rounded-full h-auto border-[1px] border-gray-400 border-opacity-50"
/>
</div>
);
};

View File

@@ -0,0 +1,136 @@
import { EditableWorkflowStep } from "@/interfaces/approval.workflow";
import Option from "@/interfaces/option";
import { CorporateUser, DeveloperUser, MasterCorporateUser, TeacherUser } from "@/interfaces/user";
import Image from "next/image";
import { useEffect, useMemo, useState } from "react";
import { AiOutlineUserAdd } from "react-icons/ai";
import { BsTrash } from "react-icons/bs";
import { LuGripHorizontal } from "react-icons/lu";
import WorkflowStepNumber from "./WorkflowStepNumber";
import WorkflowStepSelects from "./WorkflowStepSelects";
interface Props extends Pick<EditableWorkflowStep, 'stepNumber' | 'assignees' | 'finalStep' | 'onDelete'> {
entityApprovers: (TeacherUser | CorporateUser | MasterCorporateUser | DeveloperUser)[];
onSelectChange: (numberOfSelects: number, index: number, value: Option | null) => void;
isCompleted: boolean,
}
export default function WorkflowEditableStepComponent({
stepNumber,
assignees = [null],
finalStep,
onDelete,
onSelectChange,
entityApprovers,
isCompleted,
}: Props) {
const [selects, setSelects] = useState<(Option | null | undefined)[]>([null]);
const [isAdding, setIsAdding] = useState(false);
const approverOptions: Option[] = useMemo(() =>
entityApprovers
.map((approver) => ({
value: approver.id,
label: approver.name,
icon: () => <img src={approver.profilePicture} alt={approver.name} />
}))
.sort((a, b) => a.label.localeCompare(b.label)),
[entityApprovers]
);
useEffect(() => {
if (assignees && assignees.length > 0) {
const initialSelects = assignees.map((assignee) =>
typeof assignee === 'string' ? approverOptions.find(option => option.value === assignee) || null : null
);
setSelects((prevSelects) => {
// This is needed to avoid unnecessary re-renders which can cause warning of a child component being re-rendered while parent is in the midle of also re-rendering.
const areEqual = initialSelects.length === prevSelects.length && initialSelects.every((option, idx) => option?.value === prevSelects[idx]?.value);
if (!areEqual) {
return initialSelects;
}
return prevSelects;
});
}
}, [assignees, approverOptions]);
const selectedValues = useMemo(() =>
selects.filter((opt): opt is Option => !!opt).map(opt => opt.value),
[selects]
);
const availableApproverOptions = useMemo(() =>
approverOptions.filter(opt => !selectedValues.includes(opt.value)),
[approverOptions, selectedValues]
);
const handleAddSelectComponent = () => {
setIsAdding(true); // I hate to use flags... but it was the only way i was able to prevent onSelectChange to cause parent component from re-rendering in the midle of EditableWorkflowStep rerender.
setSelects(prev => [...prev, null]);
};
useEffect(() => {
if (isAdding) {
onSelectChange(selects.length, selects.length - 1, null);
setIsAdding(false);
}
}, [selects.length, isAdding, onSelectChange]);
const handleSelectChangeAt = (numberOfSelects: number, index: number, option: Option | null) => {
const updated = [...selects];
updated[index] = option;
setSelects(updated);
onSelectChange(numberOfSelects, index, option);
};
return (
<div className="flex w-full">
<div className="flex flex-col items-center">
<WorkflowStepNumber stepNumber={stepNumber} completed={false} selected={false} />
{/* Vertical Bar connecting steps */}
{!finalStep && (
<div className="w-1 h-full min-h-10 bg-mti-purple-dark"></div>
)}
</div>
{stepNumber !== 1 && !finalStep && !isCompleted
? <LuGripHorizontal className="ml-3 mt-2 cursor-grab active:cursor-grabbing min-w-[25px] min-h-[25px]" />
: <div className="ml-3 mt-2" style={{ width: 25, height: 25 }}></div>
}
<div className="ml-10 mb-12">
<WorkflowStepSelects
approvers={availableApproverOptions}
selects={selects}
placeholder={stepNumber === 1 ? "Form Intake By:" : "Approval By:"}
onSelectChange={handleSelectChangeAt}
isCompleted={isCompleted}
/>
</div>
<div className="flex flex-row items-start mt-1.5 ml-3">
<button
type="button"
onClick={handleAddSelectComponent}
className="cursor-pointer"
>
<AiOutlineUserAdd className="size-7 hover:text-mti-purple-light transition ease-in-out duration-300" />
</button>
{stepNumber !== 1 && !finalStep && (
<button
className="cursor-pointer"
onClick={onDelete}
type="button"
>
<BsTrash className="size-6 mt-0.5 ml-3 hover:text-mti-purple-light transition ease-in-out duration-300" />
</button>
)}
</div>
</div>
);
};

View File

@@ -0,0 +1,203 @@
import { EditableApprovalWorkflow, EditableWorkflowStep } from "@/interfaces/approval.workflow";
import Option from "@/interfaces/option";
import { CorporateUser, DeveloperUser, MasterCorporateUser, TeacherUser } from "@/interfaces/user";
import { AnimatePresence, Reorder, motion } from "framer-motion";
import { FaRegCheckCircle, FaSpinner } from "react-icons/fa";
import { IoIosAddCircleOutline } from "react-icons/io";
import Button from "../Low/Button";
import Tip from "./Tip";
import WorkflowEditableStepComponent from "./WorkflowEditableStepComponent";
interface Props {
workflow: EditableApprovalWorkflow;
onWorkflowChange: (workflow: EditableApprovalWorkflow) => void;
entityApprovers: (TeacherUser | CorporateUser | MasterCorporateUser | DeveloperUser)[];
entityAvailableFormIntakers?: (TeacherUser | CorporateUser | MasterCorporateUser | DeveloperUser)[];
isLoading: boolean;
isRedirecting?: boolean;
}
export default function WorkflowForm({ workflow, onWorkflowChange, entityApprovers, entityAvailableFormIntakers, isLoading, isRedirecting }: Props) {
const lastStep = workflow.steps[workflow.steps.length - 1];
const renumberSteps = (steps: EditableWorkflowStep[]): EditableWorkflowStep[] => {
return steps.map((step, index) => ({
...step,
stepNumber: index + 1,
}));
};
const addStep = () => {
const newStep: EditableWorkflowStep = {
key: Date.now(),
stepType: "approval-by",
stepNumber: workflow.steps.length,
completed: false,
assignees: [null],
firstStep: false,
finalStep: false,
};
const updatedSteps = [
...workflow.steps.slice(0, -1),
newStep,
lastStep
];
onWorkflowChange({ ...workflow, steps: renumberSteps(updatedSteps) });
};
const handleDelete = (key: number | undefined) => {
if (!key) return;
const updatedSteps = workflow.steps.filter((step) => step.key !== key);
onWorkflowChange({ ...workflow, steps: renumberSteps(updatedSteps) });
};
const handleSelectChange = (key: number | undefined, numberOfSelects: number, index: number, selectedOption: Option | null) => {
if (!key) return;
const updatedSteps = workflow.steps.map((step) => {
if (step.key !== key) return step;
const assignees = step.assignees ?? [];
let newAssignees = [...assignees];
if (numberOfSelects === assignees.length) { // means no new select was added and instead one was changed
newAssignees[index] = selectedOption?.value;
} else if (numberOfSelects === assignees.length + 1) { // means a new select was added
newAssignees.push(selectedOption?.value || null);
}
return { ...step, assignees: newAssignees };
});
onWorkflowChange({ ...workflow, steps: updatedSteps });
};
const handleReorder = (newOrder: EditableWorkflowStep[]) => {
let draggableIndex = 0;
const updatedSteps = workflow.steps.map((step) => {
if (!step.firstStep && !step.finalStep && !step.completed) {
return newOrder[draggableIndex++];
}
// Keep static steps as-is
return step;
});
onWorkflowChange({ ...workflow, steps: renumberSteps(updatedSteps) });
};
return (
<>
{workflow.entityId && workflow.name &&
<div>
<div
className="flex flex-col gap-6"
>
<Tip text="Introduce here all the steps associated with this instance." />
<Button
color="purple"
variant="solid"
onClick={addStep}
type="button"
className="max-w-fit text-lg font-medium flex items-center gap-2 text-left mb-7"
>
<IoIosAddCircleOutline className="size-6" />
Add Step
</Button>
</div>
<Reorder.Group
axis="y"
values={workflow.steps}
onReorder={handleReorder}
className="flex flex-col gap-0"
>
<AnimatePresence>
{workflow.steps.map((step, index) =>
step.completed || step.firstStep || step.finalStep ? (
<motion.div
key={step.key}
layout
initial={{ opacity: 0, y: -30 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, x: 30 }}
transition={{ duration: 0.20 }}
>
<WorkflowEditableStepComponent
stepNumber={index + 1}
assignees={step.assignees}
finalStep={step.finalStep}
onDelete={() => handleDelete(step.key)}
onSelectChange={(numberOfSelects, idx, option) =>
handleSelectChange(step.key, numberOfSelects, idx, option)
}
entityApprovers={
step.stepNumber === 1 && entityAvailableFormIntakers
? entityAvailableFormIntakers
: entityApprovers
}
isCompleted={step.completed}
/>
</motion.div>
) : (
// Render non-completed steps as draggable items
<Reorder.Item
key={step.key}
value={step}
initial={{ opacity: 0, y: -30 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, x: 30 }}
transition={{ duration: 0.20 }}
layout
drag={!step.firstStep && !step.finalStep}
dragListener={!step.firstStep && !step.finalStep}
>
<WorkflowEditableStepComponent
stepNumber={index + 1}
assignees={step.assignees}
finalStep={step.finalStep}
onDelete={() => handleDelete(step.key)}
onSelectChange={(numberOfSelects, idx, option) =>
handleSelectChange(step.key, numberOfSelects, idx, option)
}
entityApprovers={
step.stepNumber === 1 && entityAvailableFormIntakers
? entityAvailableFormIntakers
: entityApprovers
}
isCompleted={step.completed}
/>
</Reorder.Item>
)
)}
<Button
type="submit"
color="purple"
variant="solid"
disabled={isLoading}
className="max-w-fit text-lg font-medium flex items-center gap-2 text-left -mt-4"
>
{isRedirecting ? (
<>
<FaSpinner className="animate-spin size-5" />
Redirecting...
</>
) : isLoading ? (
<>
<FaSpinner className="animate-spin size-5" />
Loading...
</>
) : (
<>
<FaRegCheckCircle className="size-5" />
Confirm Exam Workflow Pipeline
</>
)}
</Button>
</AnimatePresence>
</Reorder.Group>
</div>
}
</>
);
};

View File

@@ -0,0 +1,101 @@
import { getUserTypeLabel, getUserTypeLabelShort, WorkflowStep } from "@/interfaces/approval.workflow";
import WorkflowStepNumber from "./WorkflowStepNumber";
import clsx from "clsx";
import { RiThumbUpLine } from "react-icons/ri";
import { FaWpforms } from "react-icons/fa6";
import { User } from "@/interfaces/user";
import UserWithProfilePic from "./UserWithProfilePic";
interface Props extends WorkflowStep {
workflowAssignees: User[],
currentStep: boolean,
}
export default function WorkflowStepComponent({
workflowAssignees,
currentStep,
stepType,
stepNumber,
completed,
rejected = false,
completedBy,
assignees,
finalStep,
selected = false,
onClick,
}: Props) {
const completedByUser = workflowAssignees.find((assignee) => assignee.id === completedBy);
const assigneesUsers = workflowAssignees.filter(user => assignees.includes(user.id));
return (
<div
onClick={onClick}
className={clsx("flex flex-row gap-5 w-[600px] p-6 mb-5 rounded-2xl transition ease-in-out duration-300 cursor-pointer", {
"bg-mti-red-ultralight": rejected && selected,
"bg-mti-purple-ultralight": selected,
})}
>
<div className="relative flex flex-col items-center">
<WorkflowStepNumber stepNumber={stepNumber} selected={selected} completed={completed} finalStep={finalStep} rejected={rejected} />
{/* Vertical Bar connecting steps */}
{!finalStep && (
<div className="absolute w-1 bg-mti-purple-dark -bottom-20 top-11"></div>
)}
</div>
<div className="mt-1.5">
{stepType === "approval-by" ? (
<RiThumbUpLine size={25} />
) : (
<FaWpforms size={25} />
)
}
</div>
<div className="mt-1 flex flex-col gap-0">
{completed && completedBy && rejected ? (
<div className={clsx("text-xs font-medium", { "text-mti-purple-ultradark": selected, "text-gray-800": !selected })}>
<p className="text-sm font-medium text-gray-800">{stepType === "approval-by" ? `Approval: ${getUserTypeLabel(completedByUser!.type)} Approval` : `Form Intake: ${getUserTypeLabel(completedByUser!.type)} Intake`} </p>
<UserWithProfilePic
prefix={`Rejected by: ${getUserTypeLabelShort(completedByUser!.type)}`}
name={completedByUser!.name}
profileImage={completedByUser!.profilePicture}
/>
</div>
) : completed && completedBy && !rejected ? (
<div className={clsx("text-xs font-medium", { "text-mti-purple-ultradark": selected, "text-gray-800": !selected })}>
<p className="text-sm font-medium text-gray-800">{stepType === "approval-by" ? `Approval: ${getUserTypeLabel(completedByUser!.type)} Approval` : `Form Intake: ${getUserTypeLabel(completedByUser!.type)} Intake`} </p>
<UserWithProfilePic
prefix={`Completed by: ${getUserTypeLabelShort(completedByUser!.type)}`}
name={completedByUser!.name}
profileImage={completedByUser!.profilePicture}
/>
</div>
) : !completed && currentStep ? (
<div className={clsx("text-xs font-medium", { "text-mti-purple-ultradark": selected, "text-gray-800": !selected })}>
<p className="text-sm font-medium text-gray-800">{stepType === "approval-by" ? `Approval:` : `Form Intake:`} </p>
In Progress... Assignees:
<div className="flex flex-row flex-wrap gap-3 items-center">
{assigneesUsers.map(user => (
<span key={user.id}>
<UserWithProfilePic
prefix={getUserTypeLabelShort(user.type)}
name={user.name}
profileImage={user.profilePicture}
/>
</span>
))}
</div>
</div>
) : (
<div className={clsx("text-xs font-medium", { "text-mti-purple-ultradark": selected, "text-gray-800": !selected })}>
<p className="text-sm font-medium text-gray-800">{stepType === "approval-by" ? `Approval:` : `Form Intake:`} </p>
Waiting for previous steps...
</div>
)}
</div>
</div>
);
};

View File

@@ -0,0 +1,31 @@
import { WorkflowStep } from "@/interfaces/approval.workflow";
import clsx from "clsx";
import { IoCheckmarkDoneSharp, IoCheckmarkSharp } from "react-icons/io5";
import { RxCross2 } from "react-icons/rx";
type Props = Pick<WorkflowStep, 'stepNumber' | 'completed' | 'finalStep' | 'selected' | 'rejected'>
export default function WorkflowStepNumber({ stepNumber, selected = false, completed, rejected, finalStep }: Props) {
return (
<div
className={clsx(
'flex items-center justify-center min-w-11 min-h-11 rounded-full',
{
'bg-mti-red-dark text-mti-red-ultralight': rejected,
'bg-mti-purple-dark text-mti-purple-ultralight': selected,
'bg-mti-purple-ultralight text-gray-500': !selected,
}
)}
>
{rejected ? (
<RxCross2 className="text-xl font-bold" size={25}/>
) : completed && finalStep ? (
<IoCheckmarkDoneSharp className="text-xl font-bold" size={25} />
) : completed && !finalStep ? (
<IoCheckmarkSharp className="text-xl font-bold" size={25} />
) : (
<span className="text-lg font-semibold">{stepNumber}</span>
)}
</div>
);
};

View File

@@ -0,0 +1,51 @@
import Option from "@/interfaces/option";
import Select from "../Low/Select";
interface Props {
approvers: Option[];
selects: (Option | null | undefined)[];
placeholder: string;
onSelectChange: (numberOfSelects: number, index: number, value: Option | null) => void;
isCompleted: boolean;
}
export default function WorkflowStepSelects({
approvers,
selects,
placeholder,
onSelectChange,
isCompleted,
}: Props) {
return (
<div
className={"flex flex-wrap gap-0"}
>
{selects.map((option, index) => {
let classes = "px-2 rounded-none";
if (index === 0 && selects.length === 1) {
classes += " rounded-l-2xl rounded-r-2xl";
} else if (index === 0) {
classes += " rounded-l-2xl";
} else if (index === selects.length - 1) {
classes += " rounded-r-2xl";
}
return (
<div key={index} className="w-[275px]">
<Select
options={approvers}
value={option}
onChange={(option) => onSelectChange(selects.length, index, option)}
placeholder={placeholder}
flat
isClearable
className={classes}
disabled={isCompleted}
/>
</div>
);
})}
</div>
);
}

View File

@@ -1,22 +1,29 @@
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 { BsBook, BsClipboard, BsHeadphones, BsMegaphone, BsPen } from "react-icons/bs";
import { usePDFDownload } from "@/hooks/usePDFDownload";
import { useAssignmentArchive } from "@/hooks/useAssignmentArchive";
import { uniqBy } from "lodash";
import { useAssignmentUnarchive } from "@/hooks/useAssignmentUnarchive";
import { useAssignmentRelease } from "@/hooks/useAssignmentRelease";
import { getUserName } from "@/utils/users";
import { User } from "@/interfaces/user";
import { EntityWithRoles } from "@/interfaces/entity";
interface Props {
users: User[];
onClick?: () => void;
allowDownload?: boolean;
reload?: Function;
allowArchive?: boolean;
allowUnarchive?: boolean;
allowExcelDownload?: boolean;
entityObj?: EntityWithRoles
}
export default function AssignmentCard({
@@ -25,6 +32,7 @@ export default function AssignmentCard({
assigner,
startDate,
endDate,
entityObj,
assignees,
results,
exams,
@@ -34,10 +42,15 @@ export default function AssignmentCard({
reload,
allowArchive,
allowUnarchive,
allowExcelDownload,
users,
released,
}: Assignment & Props) {
const renderPdfIcon = usePDFDownload("assignments");
const renderExcelIcon = usePDFDownload("assignments", "excel");
const renderArchiveIcon = useAssignmentArchive(id, reload);
const renderUnarchiveIcon = useAssignmentUnarchive(id, reload);
const renderReleaseIcon = useAssignmentRelease(id, reload);
const calculateAverageModuleScore = (module: Module) => {
const resultModuleBandScores = results.map((r) => {
@@ -51,6 +64,30 @@ export default function AssignmentCard({
return resultModuleBandScores.length === 0 ? -1 : resultModuleBandScores.reduce((acc, curr) => acc + curr, 0) / results.length;
};
const uniqModules = uniqBy(exams, (x) => x.module);
const shouldRenderPDF = () => {
if (released && allowDownload) {
// in order to be downloadable, the assignment has to be released
// the component should have the allowDownload prop
// and the assignment should not have the level module
return uniqModules.every(({ module }) => module !== "level");
}
return false;
};
const shouldRenderExcel = () => {
if (released && allowExcelDownload) {
// in order to be downloadable, the assignment has to be released
// the component should have the allowExcelDownload prop
// and the assignment should have the level module
return uniqModules.some(({ module }) => module === "level");
}
return false;
};
return (
<div
onClick={onClick}
@@ -59,9 +96,11 @@ export default function AssignmentCard({
<div className="flex flex-row justify-between">
<h3 className="text-xl font-semibold">{name}</h3>
<div className="flex gap-2">
{allowDownload && renderPdfIcon(id, "text-mti-gray-dim", "text-mti-gray-dim")}
{shouldRenderPDF() && renderPdfIcon(id, "text-mti-gray-dim", "text-mti-gray-dim")}
{shouldRenderExcel() && renderExcelIcon(id, "text-mti-gray-dim", "text-mti-gray-dim")}
{allowArchive && !archived && renderArchiveIcon("text-mti-gray-dim", "text-mti-gray-dim")}
{allowUnarchive && archived && renderUnarchiveIcon("text-mti-gray-dim", "text-mti-gray-dim")}
{!released && renderReleaseIcon("text-mti-gray-dim", "text-mti-gray-dim")}
</div>
</div>
<ProgressBar
@@ -72,13 +111,17 @@ export default function AssignmentCard({
textClassName={results.length / assignees.length < 0.5 ? "!text-mti-gray-dim font-light" : "text-white"}
/>
</div>
<span className="flex justify-between gap-1">
<span>{moment(startDate).format("DD/MM/YY, HH:mm")}</span>
<span>-</span>
<span>{moment(endDate).format("DD/MM/YY, HH:mm")}</span>
</span>
<div className="flex flex-col gap-1">
<span className="flex justify-between gap-1">
<span>{moment(startDate).format("DD/MM/YY, HH:mm")}</span>
<span>-</span>
<span>{moment(endDate).format("DD/MM/YY, HH:mm")}</span>
</span>
<span>Assigner: {getUserName(users.find((x) => x.id === assigner))}</span>
{entityObj && <span>Entity: {entityObj.label}</span>}
</div>
<div className="-md:mt-2 grid w-full grid-cols-4 place-items-start gap-2">
{uniqBy(exams, (x) => x.module).map(({module}) => (
{uniqModules.map(({ module }) => (
<div
key={module}
className={clsx(

View File

@@ -2,36 +2,34 @@ 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/examStore";
import {getExamById} from "@/utils/exams";
import {sortByModule} from "@/utils/moduleUtils";
import {calculateBandScore} from "@/utils/score";
import {convertToUserSolutions} from "@/utils/stats";
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 { 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 { 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, assignment, onClose}: Props) {
const {users} = useUsers();
export default function AssignmentView({ isOpen, users, assignment, onClose }: Props) {
const router = useRouter();
const setExams = useExamStore((state) => state.setExams);
const setShowSolutions = useExamStore((state) => state.setShowSolutions);
const setUserSolutions = useExamStore((state) => state.setUserSolutions);
const setSelectedModules = useExamStore((state) => state.setSelectedModules);
const dispatch = useExamStore((s) => s.dispatch);
const deleteAssignment = async () => {
if (!confirm("Are you sure you want to delete this assignment?")) return;
@@ -43,6 +41,20 @@ export default function AssignmentView({isOpen, assignment, onClose}: Props) {
.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";
@@ -64,9 +76,9 @@ export default function AssignmentView({isOpen, assignment, onClose}: Props) {
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 aggregateScoresByModule = (stats: Stat[]): { module: Module; total: number; missing: number; correct: number }[] => {
const scores: {
[key in Module]: {total: number; missing: number; correct: number};
[key in Module]: { total: number; missing: number; correct: number };
} = {
reading: {
total: 0,
@@ -95,7 +107,7 @@ export default function AssignmentView({isOpen, assignment, onClose}: Props) {
},
};
stats.forEach((x) => {
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,
@@ -105,7 +117,7 @@ export default function AssignmentView({isOpen, assignment, onClose}: Props) {
return Object.keys(scores)
.filter((x) => scores[x as Module].total > 0)
.map((x) => ({module: x as Module, ...scores[x as Module]}));
.map((x) => ({ module: x as Module, ...scores[x as Module] }));
};
const customContent = (stats: Stat[], user: string, focus: "academic" | "general") => {
@@ -125,16 +137,17 @@ export default function AssignmentView({isOpen, assignment, onClose}: Props) {
Promise.all(examPromises).then((exams) => {
if (exams.every((x) => !!x)) {
setUserSolutions(convertToUserSolutions(stats));
setShowSolutions(true);
setExams(exams.map((x) => x!).sort(sortByModule));
setSelectedModules(
exams
.map((x) => x!)
.sort(sortByModule)
.map((x) => x!.module),
);
router.push("/exercises");
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");
}
});
};
@@ -146,7 +159,7 @@ export default function AssignmentView({isOpen, assignment, onClose}: Props) {
<span className="font-medium">{formatTimestamp(stats[0].date.toString())}</span>
{timeSpent && (
<>
<span className="md:hidden 2xl:flex"> </span>
<span className="md:hidden 2xl:flex">⢠</span>
<span className="text-sm">{Math.floor(timeSpent / 60)} minutes</span>
</>
)}
@@ -164,7 +177,7 @@ export default function AssignmentView({isOpen, assignment, onClose}: Props) {
<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}) => (
{aggregatedLevels.map(({ module, level }) => (
<div
key={module}
className={clsx(
@@ -224,6 +237,16 @@ export default function AssignmentView({isOpen, assignment, onClose}: Props) {
);
};
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">
@@ -241,19 +264,22 @@ export default function AssignmentView({isOpen, assignment, onClose}: Props) {
<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>
<span>
Assignees:{" "}
{users
.filter((u) => assignment?.assignees.includes(u.id))
.map((u) => `${u.name} (${u.email})`)
.join(", ")}
</span>
<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}) => (
uniqBy(assignment.exams, (x) => x.module).map(({ module }) => (
<div
data-tip={capitalize(module)}
key={module}
@@ -297,6 +323,12 @@ export default function AssignmentView({isOpen, assignment, onClose}: Props) {
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>

View File

@@ -1,56 +0,0 @@
import {Dialog, Transition} from "@headlessui/react";
import {Fragment} from "react";
import Button from "./Low/Button";
interface Props {
isOpen: boolean;
onClose: (next?: boolean) => void;
}
export default function BlankQuestionsModal({isOpen, onClose}: Props) {
return (
<Transition show={isOpen} as={Fragment}>
<Dialog onClose={() => onClose(false)} className="relative z-50">
<Transition.Child
as={Fragment}
enter="ease-out duration-300"
enterFrom="opacity-0"
enterTo="opacity-100"
leave="ease-in duration-200"
leaveFrom="opacity-100"
leaveTo="opacity-0">
<div className="fixed inset-0 bg-black/30" />
</Transition.Child>
<Transition.Child
as={Fragment}
enter="ease-out duration-300"
enterFrom="opacity-0 scale-95"
enterTo="opacity-100 scale-100"
leave="ease-in duration-200"
leaveFrom="opacity-100 scale-100"
leaveTo="opacity-0 scale-95">
<div className="fixed inset-0 flex items-center justify-center p-4">
<Dialog.Panel className="w-full max-w-2xl h-fit p-8 rounded-xl bg-white flex flex-col gap-4">
<Dialog.Title className="font-bold text-xl">Questions Unanswered</Dialog.Title>
<span>
Please note that you are finishing the current module and once you proceed to the next module, you will no longer be
able to change the answers in the current one, including your unanswered questions. <br />
<br />
Are you sure you want to continue without completing those questions?
</span>
<div className="w-full flex justify-between mt-8">
<Button color="purple" onClick={() => onClose(false)} variant="outline" className="max-w-[200px] self-end w-full">
Go Back
</Button>
<Button color="purple" onClick={() => onClose(true)} className="max-w-[200px] self-end w-full">
Continue
</Button>
</div>
</Dialog.Panel>
</div>
</Transition.Child>
</Dialog>
</Transition>
);
}

View File

@@ -17,18 +17,22 @@ import moment from "moment";
interface Props {
user: User;
mutateUser: KeyedMutator<User>;
mutateUser: (user: User) => void;
}
export default function DemographicInformationInput({user, mutateUser}: Props) {
const [country, setCountry] = useState<string>();
const [phone, setPhone] = useState<string>();
const [country, setCountry] = useState(user.demographicInformation?.country);
const [phone, setPhone] = useState(user.demographicInformation?.phone);
const [passport_id, setPassportID] = useState<string | undefined>(user.type === "student" ? user.demographicInformation?.passport_id : undefined);
const [gender, setGender] = useState<Gender>();
const [employment, setEmployment] = useState<EmploymentStatus>();
const [position, setPosition] = useState<string>();
const [timezone, setTimezone] = useState<string>(moment.tz.guess());
const [isLoading, setIsLoading] = useState(false);
const [position, setPosition] = useState(
user.type === "corporate" || user.type === "mastercorporate"
? user.demographicInformation?.position
: user.demographicInformation?.employment,
);
const [companyName, setCompanyName] = useState<string>();
const [commercialRegistration, setCommercialRegistration] = useState<string>();
@@ -38,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}`,
@@ -50,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"});
})
@@ -85,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)} 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
@@ -106,7 +118,7 @@ export default function DemographicInformationInput({user, mutateUser}: Props) {
<GenderInput value={gender} onChange={setGender} />
{user.type === "corporate" && (
<Input name="position" onChange={setPosition} type="text" label="Position" placeholder="CEO, Head of Marketing..." required />
<Input name="position" onChange={setPosition} type="text" label="Department" placeholder="CEO, Head of Marketing..." required />
)}
{user.type !== "corporate" && <EmploymentStatusInput value={employment} onChange={setEmployment} />}
</form>

View File

@@ -1,17 +1,12 @@
import {infoButtonStyle} from "@/constants/buttonStyles";
import {Module} from "@/interfaces";
import {User} from "@/interfaces/user";
import useExamStore from "@/stores/examStore";
import {getExam, getExamById} from "@/utils/exams";
import useExamStore from "@/stores/exam";
import {getExam} from "@/utils/exams";
import {MODULE_ARRAY} from "@/utils/moduleUtils";
import {writingMarking} from "@/utils/score";
import {Menu} from "@headlessui/react";
import axios from "axios";
import clsx from "clsx";
import {capitalize} from "lodash";
import {useRouter} from "next/router";
import {useEffect, useState} from "react";
import {BsBook, BsChevronDown, BsHeadphones, BsMegaphone, BsPen, BsQuestionSquare} from "react-icons/bs";
import { useState} from "react";
import { BsQuestionSquare} from "react-icons/bs";
import {toast} from "react-toastify";
import Button from "./Low/Button";
import ModuleLevelSelector from "./Medium/ModuleLevelSelector";
@@ -28,8 +23,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 +35,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");
}
});
};

104
src/components/Dropdown.tsx Normal file
View File

@@ -0,0 +1,104 @@
import React, { useState, ReactNode, useRef, useEffect } from 'react';
import { animated, useSpring } from '@react-spring/web';
interface DropdownProps {
title?: ReactNode;
open?: boolean;
setIsOpen?: React.Dispatch<React.SetStateAction<boolean>> | ((isOpen: boolean) => void);
className?: string;
contentWrapperClassName?: string;
titleClassName?: string;
bottomPadding?: number;
disabled?: boolean,
wrapperClassName?: string;
customTitle?: ReactNode;
children: ReactNode;
}
const Dropdown: React.FC<DropdownProps> = ({
title,
open = false,
titleClassName = "",
setIsOpen: externalSetIsOpen,
className = "w-full text-left font-semibold flex justify-between items-center p-4",
contentWrapperClassName = "px-6",
bottomPadding = 12,
disabled = false,
customTitle = undefined,
wrapperClassName,
children
}) => {
const [internalIsOpen, setInternalIsOpen] = useState<boolean>(open);
const isOpen = externalSetIsOpen !== undefined ? open : internalIsOpen;
const toggleOpen = externalSetIsOpen !== undefined ? externalSetIsOpen : setInternalIsOpen;
const contentRef = useRef<HTMLDivElement>(null);
const [contentHeight, setContentHeight] = useState<number>(0);
useEffect(() => {
let resizeObserver: ResizeObserver | null = null;
if (contentRef.current) {
resizeObserver = new ResizeObserver(entries => {
for (let entry of entries) {
if (entry.borderBoxSize && entry.borderBoxSize.length > 0) {
const height = entry.borderBoxSize[0].blockSize;
setContentHeight(height + bottomPadding);
} else {
// Fallback for browsers that don't support borderBoxSize
const height = entry.contentRect.height;
setContentHeight(height + bottomPadding);
}
}
});
resizeObserver.observe(contentRef.current);
}
return () => {
if (resizeObserver) {
resizeObserver.disconnect();
}
};
}, [bottomPadding]);
const springProps = useSpring({
height: isOpen ? contentHeight : 0,
opacity: isOpen ? 1 : 0,
config: { tension: 300, friction: 30 }
});
return (
<div className={wrapperClassName}>
<button
onClick={() => toggleOpen(!isOpen)}
className={className}
disabled={disabled}
>
<div className='flex flex-row w-full justify-between items-center'>
{customTitle ? (
customTitle
) : (
<p className={titleClassName}>{title}</p>
)}
<svg
className={`w-4 h-4 transform transition-transform ${isOpen ? 'rotate-180' : ''}`}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
</svg>
</div>
</button>
<animated.div style={springProps} className="overflow-hidden">
<div ref={contentRef} className={contentWrapperClassName} style={{ paddingBottom: bottomPadding }}>
{children}
</div>
</animated.div>
</div>
);
};
export default Dropdown;

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,353 @@
import { Difficulty, FillBlanksExercise, ReadingPart } from "@/interfaces/exam";
import { useCallback, useEffect, useReducer, useState } from "react";
import BlanksEditor from "..";
import { Card, CardContent } from "@/components/ui/card";
import { MdEdit, MdEditOff } from "react-icons/md";
import FillBlanksWord from "./FillBlanksWord";
import { FaPlus } from "react-icons/fa";
import useExamEditorStore from "@/stores/examEditor";
import { blanksReducer, BlankState, getTextSegments } from "../BlanksReducer";
import useSectionEdit from "@/components/ExamEditor/Hooks/useSectionEdit";
import { AlertItem } from "../../Shared/Alert";
import validateBlanks from "../validateBlanks";
import { toast } from "react-toastify";
import setEditingAlert from "../../Shared/setEditingAlert";
import PromptEdit from "../../Shared/PromptEdit";
import { uuidv4 } from "@firebase/util";
interface Word {
letter: string;
word: string;
}
const FillBlanksLetters: React.FC<{ exercise: FillBlanksExercise; sectionId: number }> = ({ exercise, sectionId }) => {
const { currentModule, dispatch } = useExamEditorStore();
const difficulty = useExamEditorStore((state) => state.modules[currentModule].difficulty);
const { state } = useExamEditorStore(
(state) => state.modules[currentModule].sections.find((section) => section.sectionId === sectionId)!
);
const section = state as ReadingPart;
const [alerts, setAlerts] = useState<AlertItem[]>([]);
const [local, setLocal] = useState(exercise);
const [selectedBlankId, setSelectedBlankId] = useState<string | null>(null);
const [answers, setAnswers] = useState<Map<string, string>>(
new Map(exercise.solutions.map(({ id, solution }) => [id, solution]))
);
const [isEditMode, setIsEditMode] = useState(false);
const [newWord, setNewWord] = useState('');
const [editing, setEditing] = useState(false);
const updateLocal = (exercise: FillBlanksExercise) => {
setLocal(exercise);
setEditingAlert(true, setAlerts);
setEditing(true);
};
const [blanksState, blanksDispatcher] = useReducer(blanksReducer, {
text: exercise.text || "",
blanks: [],
selectedBlankId: null,
draggedItemId: null,
textMode: false,
setEditing,
});
const { handleSave, handleDiscard, handleDelete, handlePractice } = useSectionEdit({
sectionId,
editing,
setEditing,
onSave: () => {
if (!validateBlanks(blanksState.blanks, answers, alerts, setAlerts)) {
toast.error("Please fix the errors before saving!");
return;
}
setEditing(false);
setAlerts([]);
const updatedExercise = {
...local,
text: blanksState.text,
solutions: Array.from(answers.entries()).map(([id, solution]) => ({
uuid: local.solutions.find(sol => sol.id === id)?.uuid || uuidv4(),
id,
solution
}))
};
const newState = { ...section };
newState.exercises = newState.exercises.map((ex) =>
ex.id === exercise.id ? updatedExercise : ex
);
dispatch({ type: 'UPDATE_SECTION_STATE', payload: { sectionId, update: newState, module: currentModule } });
},
onDiscard: () => {
setSelectedBlankId(null);
setAnswers(new Map(exercise.solutions.map(({ id, solution }) => [id, solution])));
setIsEditMode(false);
setNewWord('');
setLocal(exercise);
blanksDispatcher({ type: "RESET", payload: { text: exercise.text } });
blanksDispatcher({ type: "SET_TEXT", payload: exercise.text || "" });
const tokens = getTextSegments(exercise.text || "");
const initialBlanks = tokens.reduce((acc, token, idx) => {
if (token.type === 'blank') {
acc.push({ id: token.id, position: idx });
}
return acc;
}, [] as BlankState[]);
blanksDispatcher({ type: "SET_BLANKS", payload: initialBlanks });
},
onDelete: () => {
const newSection = {
...section,
exercises: section.exercises.filter((ex) => ex.id !== local.id)
};
dispatch({ type: "UPDATE_SECTION_STATE", payload: { sectionId, update: newSection, module: currentModule } });
},
onPractice: () => {
const updatedExercise = {
...local,
isPractice: !local.isPractice,
};
const newState = { ...section };
newState.exercises = newState.exercises.map((ex) =>
ex.id === exercise.id ? updatedExercise : ex
);
setLocal((prev) => ({...prev, isPractice: !local.isPractice}))
dispatch({ type: 'UPDATE_SECTION_STATE', payload: { sectionId, update: newState, module: currentModule } });
}
});
useEffect(() => {
if (!editing) {
setLocal(exercise);
setAnswers(new Map(exercise.solutions.map(({ id, solution }) => [id, solution])));
}
}, [exercise, editing]);
const handleWordSelect = (word: string) => {
if (!selectedBlankId) return;
if (!editing) setEditing(true);
const newAnswers = new Map(answers);
newAnswers.set(selectedBlankId, word);
setAnswers(newAnswers);
setLocal(prev => ({
...prev,
solutions: Array.from(newAnswers.entries()).map(([id, solution]) => ({
uuid: prev.solutions.find(sol => sol.id === id)?.uuid || uuidv4(),
id,
solution
}))
}));
};
const handleAddWord = () => {
const word = newWord.trim();
if (!word) return;
setLocal(prev => {
const nextLetter = String.fromCharCode(65 + prev.words.length);
return {
...prev,
words: [...prev.words, { letter: nextLetter, word }]
};
});
setNewWord('');
};
const handleRemoveWord = (index: number) => {
if (!editing) setEditing(true);
if (answers.size === 1) {
toast.error("There needs to be at least 1 word!");
return;
}
setLocal(prev => {
const newWords = prev.words.filter((_, i) => i !== index) as Word[];
const removedWord = prev.words[index] as Word;
const newAnswers = new Map(answers);
for (const [blankId, answer] of newAnswers.entries()) {
if (answer === removedWord.word) {
newAnswers.delete(blankId);
}
}
setAnswers(newAnswers);
return {
...prev,
words: newWords,
solutions: Array.from(newAnswers.entries()).map(([id, solution]) => ({
uuid: prev.solutions.find(sol => sol.id === id)?.uuid || uuidv4(),
id,
solution
}))
};
});
};
const handleEditWord = (index: number, newWord: string) => {
if (!editing) setEditing(true);
setLocal(prev => {
const newWords = [...prev.words] as Word[];
const oldWord = newWords[index].word;
newWords[index] = { ...newWords[index], word: newWord };
const newAnswers = new Map(answers);
for (const [blankId, answer] of newAnswers.entries()) {
if (answer === oldWord) {
newAnswers.set(blankId, newWord);
}
}
setAnswers(newAnswers);
return {
...prev,
words: newWords,
solutions: Array.from(newAnswers.entries()).map(([id, solution]) => ({
uuid: prev.solutions.find(sol => sol.id === id)?.uuid || uuidv4(),
id,
solution
}))
};
});
};
const handleBlankRemove = (blankId: number) => {
if (!editing) setEditing(true);
const newAnswers = new Map(answers);
newAnswers.delete(blankId.toString());
setAnswers(newAnswers);
setLocal(prev => ({
...prev,
solutions: Array.from(newAnswers.entries()).map(([id, solution]) => ({
uuid: prev.solutions.find(sol => sol.id === id)?.uuid || uuidv4(),
id,
solution
}))
}));
blanksDispatcher({ type: "REMOVE_BLANK", payload: blankId });
};
useEffect(() => {
validateBlanks(blanksState.blanks, answers, alerts, setAlerts);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [answers, blanksState.blanks, blanksState.textMode])
useEffect(()=> {
setEditingAlert(editing, setAlerts);
}, [editing])
const saveDifficulty = useCallback((diff: Difficulty) => {
if (!difficulty.includes(diff)) {
dispatch({ type: 'UPDATE_MODULE', payload: { updates: { difficulty: [...difficulty, diff]} } });
}
const updatedExercise = { ...exercise, difficulty: diff };
const newState = { ...section };
newState.exercises = newState.exercises.map((ex) => ex.id === exercise.id ? updatedExercise : ex );
dispatch({ type: 'UPDATE_SECTION_STATE', payload: { sectionId, update: newState, module: currentModule } });
}, [currentModule, difficulty, dispatch, exercise, section, sectionId]);
return (
<div className="space-y-4">
<BlanksEditor
alerts={alerts}
editing={editing}
state={blanksState}
difficulty={exercise.difficulty}
saveDifficulty={saveDifficulty}
blanksDispatcher={blanksDispatcher}
description="Place blanks and assign words from the word bank"
initialText={local.text}
module={currentModule}
showBlankBank={true}
onBlankSelect={(blankId) => setSelectedBlankId(blankId?.toString() || null)}
onBlankRemove={handleBlankRemove}
onSave={handleSave}
onDiscard={handleDiscard}
onDelete={handleDelete}
setEditing={setEditing}
onPractice={handlePractice}
isEvaluationEnabled={!local.isPractice}
prompt={local.prompt}
updatePrompt={(prompt: string) => updateLocal({...local, prompt})}
>
<>
{!blanksState.textMode && <Card className="p-4">
<CardContent>
<div className="flex justify-between items-center mb-4">
<div className="text-lg font-semibold">Word Bank</div>
<button
onClick={() => setIsEditMode(!isEditMode)}
className="p-2 hover:bg-gray-100 rounded-lg transition-colors"
>
{isEditMode ?
<MdEditOff size={20} className="text-gray-500" /> :
<MdEdit size={20} className="text-gray-500" />
}
</button>
</div>
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 gap-2">
{(local.words as Word[]).map((wordItem, index) => (
<FillBlanksWord
key={wordItem.letter}
letter={wordItem.letter}
word={wordItem.word}
isSelected={answers.get(selectedBlankId || '') === wordItem.word}
onClick={() => handleWordSelect(wordItem.word)}
onRemove={isEditMode ? () => handleRemoveWord(index) : undefined}
onEdit={isEditMode ? (newWord) => handleEditWord(index, newWord) : undefined}
isEditMode={isEditMode}
/>
))}
</div>
{isEditMode && (
<div className="flex flex-row mt-8">
<input
type="text"
value={newWord}
onChange={(e) => setNewWord(e.target.value)}
placeholder="Enter new word"
className="flex-1 px-3 py-2 border border-r-0 rounded-l-md focus:outline-none"
name=""
/>
<button
onClick={handleAddWord}
disabled={!isEditMode || newWord === ""}
className="px-4 bg-blue-500 text-white rounded-r-md border border-blue-500 hover:bg-blue-600 disabled:opacity-50 disabled:cursor-not-allowed"
>
<FaPlus className="h-4 w-4" />
</button>
</div>
)}
</CardContent>
</Card>
}
</>
</BlanksEditor>
</div>
);
};
export default FillBlanksLetters;

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,345 @@
import { Difficulty, FillBlanksExercise, FillBlanksMCOption, ReadingPart } from "@/interfaces/exam";
import { useCallback, useEffect, useReducer, useState } from "react";
import BlanksEditor from "..";
import { Card, CardContent } from "@/components/ui/card";
import useExamEditorStore from "@/stores/examEditor";
import { blanksReducer, BlankState, getTextSegments } from "../BlanksReducer";
import useSectionEdit from "@/components/ExamEditor/Hooks/useSectionEdit";
import { AlertItem } from "../../Shared/Alert";
import validateBlanks from "../validateBlanks";
import { toast } from "react-toastify";
import setEditingAlert from "../../Shared/setEditingAlert";
import { MdEdit, MdEditOff } from "react-icons/md";
import MCOption from "./MCOption";
import { uuidv4 } from "@firebase/util";
const FillBlanksMC: React.FC<{ exercise: FillBlanksExercise; sectionId: number }> = ({ exercise, sectionId }) => {
const { currentModule, dispatch } = useExamEditorStore();
const difficulty = useExamEditorStore((state) => state.modules[currentModule].difficulty);
const { state } = useExamEditorStore(
(state) => state.modules[currentModule].sections.find((section) => section.sectionId === sectionId)!
);
const section = state as ReadingPart;
const [alerts, setAlerts] = useState<AlertItem[]>([]);
const [local, setLocal] = useState(exercise);
const [selectedBlankId, setSelectedBlankId] = useState<string | null>(null);
const [answers, setAnswers] = useState<Map<string, string>>(() => {
return new Map(
exercise.solutions.map(({ id, solution }) => [
id.toString(),
solution
])
);
});
const [isEditMode, setIsEditMode] = useState(false);
const [editing, setEditing] = useState(false);
const updateLocal = (exercise: FillBlanksExercise) => {
setLocal(exercise);
setEditingAlert(true, setAlerts);
setEditing(true);
};
const [blanksState, blanksDispatcher] = useReducer(blanksReducer, {
text: exercise.text || "",
blanks: [],
selectedBlankId: null,
draggedItemId: null,
textMode: false,
setEditing,
});
const { handleSave, handleDiscard, handleDelete, handlePractice } = useSectionEdit({
sectionId,
editing,
setEditing,
onSave: () => {
if (!validateBlanks(blanksState.blanks, answers, alerts, setAlerts)) {
toast.error("Please fix the errors before saving!");
return;
}
setEditing(false);
setAlerts([]);
const updatedExercise = {
...local,
text: blanksState.text,
solutions: Array.from(answers.entries()).map(([id, solution]) => ({
uuid: local.solutions.find(sol => sol.id === id)?.uuid || uuidv4(),
id,
solution
}))
};
const newState = { ...section };
newState.exercises = newState.exercises.map((ex) =>
ex.id === exercise.id ? updatedExercise : ex
);
dispatch({ type: 'UPDATE_SECTION_STATE', payload: { sectionId, update: newState, module: currentModule } });
},
onDiscard: () => {
setSelectedBlankId(null);
setAnswers(new Map(exercise.solutions.map(({ id, solution }) => [id, solution])));
setIsEditMode(false);
setLocal(exercise);
blanksDispatcher({ type: "RESET", payload: { text: exercise.text } });
blanksDispatcher({ type: "SET_TEXT", payload: exercise.text || "" });
const tokens = getTextSegments(exercise.text || "");
const initialBlanks = tokens.reduce((acc, token, idx) => {
if (token.type === 'blank') {
acc.push({ id: token.id, position: idx });
}
return acc;
}, [] as BlankState[]);
blanksDispatcher({ type: "SET_BLANKS", payload: initialBlanks });
},
onDelete: () => {
const newSection = {
...section,
exercises: section.exercises.filter((ex) => ex.id !== local.id)
};
dispatch({ type: "UPDATE_SECTION_STATE", payload: { sectionId, update: newSection, module: currentModule } });
},
onPractice: () => {
const updatedExercise = {
...local,
isPractice: !local.isPractice
};
const newState = { ...section };
newState.exercises = newState.exercises.map((ex) =>
ex.id === exercise.id ? updatedExercise : ex
);
setLocal((prev) => ({ ...prev, isPractice: !local.isPractice }))
dispatch({ type: 'UPDATE_SECTION_STATE', payload: { sectionId, update: newState, module: currentModule } });
}
});
useEffect(() => {
if (!editing) {
setLocal(exercise);
setAnswers(new Map(exercise.solutions.map(({ id, solution }) => [id, solution])));
}
}, [exercise, editing]);
const handleOptionSelect = (option: string) => {
if (!selectedBlankId) return;
if (!editing) setEditing(true);
const newAnswers = new Map(answers);
newAnswers.set(selectedBlankId, option);
setAnswers(newAnswers);
setLocal(prev => ({
...prev,
solutions: Array.from(newAnswers.entries()).map(([id, solution]) => ({
uuid: prev.solutions.find(sol => sol.id === id)?.uuid || uuidv4(),
id,
solution
}))
}));
};
const handleEditOption = (mcOptionIndex: number, key: keyof FillBlanksMCOption['options'], value: string) => {
if (!editing) setEditing(true);
setLocal(prev => {
const newWords = [...prev.words] as FillBlanksMCOption[];
const mcOption = newWords[mcOptionIndex] as FillBlanksMCOption;
const newOptions = { ...mcOption.options, [key]: value };
newWords[mcOptionIndex] = { ...mcOption, options: newOptions };
const oldValue = (mcOption.options as any)[key];
const newAnswers = new Map(answers);
for (const [blankId, answer] of newAnswers.entries()) {
if (answer === oldValue) {
newAnswers.set(blankId, value);
}
}
setAnswers(newAnswers);
return {
...prev,
words: newWords,
solutions: Array.from(newAnswers.entries()).map(([id, solution]) => ({
uuid: prev.solutions.find(sol => sol.id === id)?.uuid || uuidv4(),
id,
solution
}))
};
});
};
useEffect(() => {
validateBlanks(blanksState.blanks, answers, alerts, setAlerts);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [answers, blanksState.blanks, blanksState.textMode]);
useEffect(() => {
setEditingAlert(editing, setAlerts);
}, [editing]);
useEffect(() => {
if (!editing) {
setLocal(exercise);
setAnswers(new Map(
exercise.solutions.map(({ id, solution }) => [
id.toString(),
solution
])
));
}
}, [exercise, editing]);
useEffect(() => {
setAnswers(new Map(
exercise.solutions.map(({ id, solution }) => [
id.toString(),
solution
])
));
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const handleBlankRemove = (blankId: number) => {
if (!editing) setEditing(true);
const newAnswers = new Map(answers);
newAnswers.delete(blankId.toString());
setAnswers(newAnswers);
setLocal(prev => ({
...prev,
words: (prev.words as FillBlanksMCOption[]).filter(w => w.id !== blankId.toString()),
solutions: Array.from(newAnswers.entries()).map(([id, solution]) => ({
uuid: prev.solutions.find(sol => sol.id === id)?.uuid || uuidv4(),
id,
solution
}))
}));
blanksDispatcher({ type: "REMOVE_BLANK", payload: blankId });
};
useEffect(() => {
const existingWordIds = new Set((local.words as FillBlanksMCOption[]).map(word => word.id));
const blanksMissingWords = blanksState.blanks.filter(blank => !existingWordIds.has(blank.id.toString()));
if (blanksMissingWords.length > 0) {
setLocal(prev => {
const newWords = [...prev.words] as FillBlanksMCOption[];
blanksMissingWords.forEach(blank => {
const newMCOption: FillBlanksMCOption = {
uuid: uuidv4(),
id: blank.id.toString(),
options: {
A: 'Option A',
B: 'Option B',
C: 'Option C',
D: 'Option D'
}
};
newWords.push(newMCOption);
});
return {
...prev,
words: newWords,
solutions: Array.from(answers.entries()).map(([id, solution]) => ({
uuid: prev.solutions.find(sol => sol.id === id)?.uuid || uuidv4(),
id,
solution
}))
};
});
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [blanksState.blanks]);
const saveDifficulty = useCallback((diff: Difficulty) => {
if (!difficulty.includes(diff)) {
dispatch({ type: 'UPDATE_MODULE', payload: { updates: { difficulty: [...difficulty, diff]} } });
}
const updatedExercise = { ...exercise, difficulty: diff };
const newState = { ...section };
newState.exercises = newState.exercises.map((ex) => ex.id === exercise.id ? updatedExercise : ex );
dispatch({ type: 'UPDATE_SECTION_STATE', payload: { sectionId, update: newState, module: currentModule } });
}, [currentModule, difficulty, dispatch, exercise, section, sectionId]);
return (
<div className="space-y-4">
<BlanksEditor
alerts={alerts}
editing={editing}
state={blanksState}
difficulty={exercise.difficulty}
saveDifficulty={saveDifficulty}
blanksDispatcher={blanksDispatcher}
description="Place blanks and select the correct answer from multiple choice options"
initialText={local.text}
module={currentModule}
showBlankBank={true}
onBlankSelect={(blankId) => setSelectedBlankId(blankId?.toString() || null)}
onSave={handleSave}
onDiscard={handleDiscard}
onDelete={handleDelete}
onPractice={handlePractice}
setEditing={setEditing}
onBlankRemove={handleBlankRemove}
isEvaluationEnabled={!local.isPractice}
prompt={local.prompt}
updatePrompt={(prompt: string) => updateLocal({ ...local, prompt })}
>
{!blanksState.textMode && selectedBlankId && (
<Card className="p-4">
<CardContent>
<div className="flex justify-between items-center mb-4">
<div className="text-lg font-semibold">Multiple Choice Options</div>
<button
onClick={() => setIsEditMode(!isEditMode)}
className="p-2 hover:bg-gray-100 rounded-lg transition-colors"
>
{isEditMode ?
<MdEditOff size={20} className="text-gray-500" /> :
<MdEdit size={20} className="text-gray-500" />
}
</button>
</div>
{(local.words as FillBlanksMCOption[]).map((mcOption) => {
if (mcOption.id.toString() !== selectedBlankId) return null;
return (
<MCOption
key={mcOption.id}
id={mcOption.id}
options={mcOption.options}
selectedOption={answers.get(selectedBlankId)}
onSelect={(option) => handleOptionSelect(option)}
isEditMode={isEditMode}
onEdit={(key, value) => handleEditOption(
(local.words as FillBlanksMCOption[]).findIndex(w => w.id === mcOption.id),
key as "A" | "B" | "C" | "D",
value
)}
/>
);
})}
</CardContent>
</Card>
)}
</BlanksEditor>
</div>
);
};
export default FillBlanksMC;

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,263 @@
import React, { useState, useMemo, useEffect, useCallback } from 'react';
import {
MdAdd,
MdVisibility,
MdVisibilityOff
} from 'react-icons/md';
import { Difficulty, MatchSentencesExercise, ReadingPart } from '@/interfaces/exam';
import Alert, { AlertItem } from '../Shared/Alert';
import ReferenceViewer from './ParagraphViewer';
import Header from '../../Shared/Header';
import SortableQuestion from '../Shared/SortableQuestion';
import QuestionsList from '../Shared/QuestionsList';
import useExamEditorStore from '@/stores/examEditor';
import useSectionEdit from '../../Hooks/useSectionEdit';
import validateMatchSentences from './validation';
import setEditingAlert from '../Shared/setEditingAlert';
import { toast } from 'react-toastify';
import { DragEndEvent } from '@dnd-kit/core';
import { handleMatchSentencesReorder } from '@/stores/examEditor/reorder/local';
import PromptEdit from '../Shared/PromptEdit';
import { uuidv4 } from '@firebase/util';
const MatchSentences: React.FC<{ exercise: MatchSentencesExercise, sectionId: number }> = ({ exercise, sectionId }) => {
const { currentModule, dispatch } = useExamEditorStore();
const difficulty = useExamEditorStore((state) => state.modules[currentModule].difficulty);
const { state } = useExamEditorStore(
(state) => state.modules[currentModule].sections.find((section) => section.sectionId === sectionId)!
);
const section = state as ReadingPart;
const [local, setLocal] = useState(exercise);
const [selectedParagraph, setSelectedParagraph] = useState<string | null>(null);
const [showReference, setShowReference] = useState(false);
const [alerts, setAlerts] = useState<AlertItem[]>([]);
const updateLocal = (exercise: MatchSentencesExercise) => {
setLocal(exercise);
setEditing(true);
};
const { editing, setEditing, handleSave, handleDiscard, handleDelete, handlePractice } = useSectionEdit({
sectionId,
onSave: () => {
const isValid = validateMatchSentences(local.sentences, setAlerts);
if (!isValid) {
toast.error("Please fix the errors before saving!");
return;
}
setEditing(false);
setAlerts([]);
const newState = { ...section };
newState.exercises = newState.exercises.map((ex) => ex.id === exercise.id ? local : ex);
dispatch({ type: 'UPDATE_SECTION_STATE', payload: { sectionId, update: newState, module: currentModule } });
},
onDiscard: () => {
setLocal(exercise);
setSelectedParagraph(null);
setShowReference(false);
},
onDelete: () => {
const newSection = {
...section,
exercises: section.exercises.filter((ex) => ex.id !== local.id)
};
dispatch({ type: "UPDATE_SECTION_STATE", payload: { sectionId, update: newSection, module: currentModule } });
},
onPractice: () => {
const updatedExercise = {
...local,
isPractice: !local.isPractice
};
const newState = { ...section };
newState.exercises = newState.exercises.map((ex) =>
ex.id === exercise.id ? updatedExercise : ex
);
setLocal((prev) => ({...prev, isPractice: !local.isPractice}))
dispatch({ type: 'UPDATE_SECTION_STATE', payload: { sectionId, update: newState, module: currentModule } });
}
});
const usedOptions = useMemo(() => {
return local.sentences.reduce((acc, sentence) => {
if (sentence.solution) {
acc.add(sentence.solution);
}
return acc;
}, new Set<string>());
}, [local.sentences]);
const addHeading = () => {
const newId = (parseInt(local.sentences[local.sentences.length - 1].id) + 1).toString();
updateLocal({
...local,
sentences: [
...local.sentences,
{
uuid: uuidv4(),
id: newId,
sentence: "",
solution: ""
}
]
});
};
const updateHeading = (index: number, field: string, value: string) => {
const newSentences = [...local.sentences];
if (field === 'solution') {
const oldSolution = newSentences[index].solution;
if (oldSolution) {
usedOptions.delete(oldSolution);
}
}
newSentences[index] = { ...newSentences[index], [field]: value };
updateLocal({ ...local, sentences: newSentences });
};
const deleteHeading = (index: number) => {
if (local.sentences.length <= 1) {
toast.error(`There needs to be at least one ${exercise.variant && exercise.variant == "ideaMatch" ? "idea/opinion" : "heading"}!`);
return;
}
const deletedSolution = local.sentences[index].solution;
if (deletedSolution) {
usedOptions.delete(deletedSolution);
}
const newSentences = local.sentences.filter((_, i) => i !== index);
updateLocal({ ...local, sentences: newSentences });
};
useEffect(() => {
validateMatchSentences(local.sentences, setAlerts);
}, [local.sentences]);
useEffect(() => {
setEditingAlert(editing, setAlerts);
}, [editing]);
const handleDragEnd = (event: DragEndEvent) => {
updateLocal(handleMatchSentencesReorder(event, local));
}
const saveDifficulty = useCallback((diff: Difficulty) => {
if (!difficulty.includes(diff)) {
dispatch({ type: 'UPDATE_MODULE', payload: { updates: { difficulty: [...difficulty, diff]} } });
}
const updatedExercise = { ...exercise, difficulty: diff };
const newState = { ...section };
newState.exercises = newState.exercises.map((ex) => ex.id === exercise.id ? updatedExercise : ex );
dispatch({ type: 'UPDATE_SECTION_STATE', payload: { sectionId, update: newState, module: currentModule } });
}, [currentModule, difficulty, dispatch, exercise, section, sectionId]);
return (
<div className="flex flex-col mx-auto p-2">
<Header
title={exercise.variant && exercise.variant == "ideaMatch" ? "Idea Match" : "Paragraph Match"}
description={`Edit ${exercise.variant && exercise.variant == "ideaMatch" ? "ideas/opinions" : "headings"} and their matches`}
editing={editing}
difficulty={exercise.difficulty}
saveDifficulty={saveDifficulty}
handleSave={handleSave}
handleDelete={handleDelete}
handleDiscard={handleDiscard}
handlePractice={handlePractice}
isEvaluationEnabled={!local.isPractice}
>
<button
onClick={() => setShowReference(!showReference)}
className="px-4 py-2 bg-gray-100 hover:bg-gray-200 text-gray-700 rounded-lg transition-colors flex items-center gap-2"
>
{showReference ? <MdVisibilityOff size={18} /> : <MdVisibility size={18} />}
{showReference ? 'Hide Reference' : 'Show Reference'}
</button>
</Header>
<div className="space-y-4">
{alerts.length > 0 && <Alert alerts={alerts} />}
<PromptEdit
value={local.prompt}
onChange={(text) => updateLocal({ ...local, prompt: text })}
/>
<QuestionsList
ids={local.sentences.map(s => s.id)}
handleDragEnd={handleDragEnd}
>
{local.sentences.map((sentence, index) => (
<SortableQuestion
key={sentence.id}
id={sentence.id}
index={index}
deleteQuestion={() => deleteHeading(index)}
onFocus={() => setSelectedParagraph(sentence.solution)}
>
<>
<input
type="text"
value={sentence.sentence}
onChange={(e) => updateHeading(index, 'sentence', e.target.value)}
className="w-full p-3 border rounded-lg focus:ring-2 focus:ring-blue-500 focus:outline-none text-mti-gray-dim"
placeholder={`Enter ${exercise.variant && exercise.variant == "ideaMatch" ? "idea/opinion" : "heading"} ...`}
/>
<div className="flex items-center gap-3">
<select
value={sentence.solution}
onChange={(e) => {
updateHeading(index, 'solution', e.target.value);
setSelectedParagraph(e.target.value);
}}
className="w-full p-3 border rounded-lg focus:ring-2 focus:ring-blue-500 focus:outline-none bg-white text-mti-gray-dim"
>
<option value="">Select matching {exercise.variant == "ideaMatch" ? "author" : "paragraph"}...</option>
{local.options.map((option) => {
const isUsed = usedOptions.has(option.id);
const isCurrentSelection = sentence.solution === option.id;
return (
<option
key={option.id}
value={option.id}
disabled={isUsed && !isCurrentSelection}
>
{exercise.variant == "ideaMatch" ? "Author" : "Paragraph"} {option.id}
</option>
);
})}
</select>
</div>
</>
</SortableQuestion>
))}
</QuestionsList>
{(section.text !== undefined && section.text.content.split("\n\n").length - 1) === local.sentences.length && (
<button
onClick={addHeading}
className="w-full p-4 border-2 border-dashed border-gray-300 rounded-lg hover:border-blue-500 hover:bg-blue-50 transition-colors flex items-center justify-center gap-2 text-gray-600 hover:text-blue-600"
>
<MdAdd size={18} />
Add New Match
</button>
)}
</div>
<ReferenceViewer
headings={exercise.variant !== "ideaMatch"}
showReference={showReference}
selectedReference={selectedParagraph}
options={local.options}
setShowReference={setShowReference}
/>
</div>
);
};
export default MatchSentences;

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,179 @@
import Header from "@/components/ExamEditor/Shared/Header";
import QuestionsList from "../../Shared/QuestionsList";
import SortableQuestion from "../../Shared/SortableQuestion";
import UnderlineQuestion from "./UnderlineQuestion";
import useSectionEdit from "@/components/ExamEditor/Hooks/useSectionEdit";
import { toast } from "react-toastify";
import setEditingAlert from "../../Shared/setEditingAlert";
import { Difficulty, LevelPart, ListeningPart, MultipleChoiceExercise, MultipleChoiceQuestion, ReadingPart } from "@/interfaces/exam";
import useExamEditorStore from "@/stores/examEditor";
import { useCallback, useEffect, useState } from "react";
import { MdAdd } from "react-icons/md";
import Alert, { AlertItem } from "../../Shared/Alert";
import PromptEdit from "../../Shared/PromptEdit";
import { uuidv4 } from "@firebase/util";
const UnderlineMultipleChoice: React.FC<{exercise: MultipleChoiceExercise, sectionId: number}> = ({
exercise,
sectionId,
}) => {
const { currentModule, dispatch } = useExamEditorStore();
const difficulty = useExamEditorStore((state) => state.modules[currentModule].difficulty);
const { state } = useExamEditorStore(
(state) => state.modules[currentModule].sections.find((section) => section.sectionId === sectionId)!
);
const section = state as ReadingPart | ListeningPart | LevelPart;
const [local, setLocal] = useState(exercise);
const [alerts, setAlerts] = useState<AlertItem[]>([]);
useEffect(() => {
setLocal(exercise);
}, [exercise]);
const updateLocal = (exercise: MultipleChoiceExercise) => {
setLocal(exercise);
setEditingAlert(true, setAlerts);
setEditing(true);
};
const handleQuestionChange = (questionIndex: number, updatedQuestion: MultipleChoiceQuestion) => {
const newQuestions = [...local.questions];
newQuestions[questionIndex] = updatedQuestion;
updateLocal({ ...local, questions: newQuestions });
};
const addQuestion = () => {
const newId = (parseInt(local.questions[local.questions.length - 1].id) + 1).toString();
const options = Array.from({ length: 4 }, (_, i) => ({
id: String.fromCharCode(65 + i),
text: ''
}));
updateLocal({
...local,
questions: [
...local.questions,
{
prompt: "",
solution: "",
uuid: uuidv4(),
id: newId,
options,
variant: "text"
},
]
});
};
const deleteQuestion = (index: number) => {
if (local.questions.length === 1) {
toast.error("There needs to be at least one question!");
return;
}
const newQuestions = local.questions.filter((_, i) => i !== index);
updateLocal({ ...local, questions: newQuestions });
};
const { editing, handleSave, handleDiscard, handleDelete, handlePractice, setEditing } = useSectionEdit({
sectionId,
onSave: () => {
setEditing(false);
setAlerts([]);
const newSection = {
...section,
exercises: section.exercises.map((ex) =>
ex.id === local.id ? local : ex
)
};
dispatch({ type: "UPDATE_SECTION_STATE", payload: { sectionId, update: newSection, module: currentModule } });
},
onDiscard: () => {
setAlerts([]);
setLocal(exercise);
setEditing(false);
},
onDelete: () => {
const newSection = {
...section,
exercises: section.exercises.filter((ex) => ex.id !== local.id)
};
dispatch({ type: "UPDATE_SECTION_STATE", payload: { sectionId, update: newSection, module: currentModule } });
},
onPractice: () => {
const updatedExercise = {
...local,
isPractice: !local.isPractice
};
const newState = { ...section };
newState.exercises = newState.exercises.map((ex) =>
ex.id === exercise.id ? updatedExercise : ex
);
setLocal((prev) => ({...prev, isPractice: !local.isPractice}))
dispatch({ type: 'UPDATE_SECTION_STATE', payload: { sectionId, update: newState, module: currentModule } });
}
});
const saveDifficulty = useCallback((diff: Difficulty) => {
if (!difficulty.includes(diff)) {
dispatch({ type: 'UPDATE_MODULE', payload: { updates: { difficulty: [...difficulty, diff]} } });
}
const updatedExercise = { ...exercise, difficulty: diff };
const newState = { ...section };
newState.exercises = newState.exercises.map((ex) => ex.id === exercise.id ? updatedExercise : ex );
dispatch({ type: 'UPDATE_SECTION_STATE', payload: { sectionId, update: newState, module: currentModule } });
}, [currentModule, difficulty, dispatch, exercise, section, sectionId]);
return (
<div className="p-4">
<Header
title='Underline Multiple Choice Exercise'
description="Edit questions with 4 underline options each"
editing={editing}
difficulty={exercise.difficulty}
saveDifficulty={saveDifficulty}
handleSave={handleSave}
handleDelete={handleDelete}
handlePractice={handlePractice}
handleDiscard={handleDiscard}
isEvaluationEnabled={!local.isPractice}
/>
{alerts.length > 0 && <Alert className="mb-6" alerts={alerts} />}
<PromptEdit value={local.prompt} onChange={(prompt: string) => updateLocal({...local, prompt})} />
<div className="space-y-4">
<QuestionsList
ids={local.questions.map(q => q.id)}
handleDragEnd={()=> {}}
>
{local.questions.map((question, questionIndex) => (
<SortableQuestion
key={question.id}
id={question.id}
index={questionIndex}
deleteQuestion={() => deleteQuestion(questionIndex)}
>
<UnderlineQuestion
question={question}
onQuestionChange={(updatedQuestion) =>
handleQuestionChange(questionIndex, updatedQuestion)
}
/>
</SortableQuestion>
))}
</QuestionsList>
<button
onClick={addQuestion}
className="w-full p-4 border-2 border-dashed border-gray-300 rounded-lg hover:border-blue-500 hover:bg-blue-50 transition-colors flex items-center justify-center gap-2 text-gray-600 hover:text-blue-600"
>
<MdAdd size={18} />
Add New Question
</button>
</div>
</div>
);
};
export default UnderlineMultipleChoice;

View File

@@ -0,0 +1,302 @@
import React, { useCallback, useEffect, useState } from 'react';
import { Card, CardContent } from '@/components/ui/card';
import {
MdAdd,
MdEdit,
MdEditOff,
} from 'react-icons/md';
import { ReadingPart, MultipleChoiceExercise, MultipleChoiceQuestion, LevelPart, ListeningPart, Difficulty } from '@/interfaces/exam';
import clsx from 'clsx';
import useExamEditorStore from '@/stores/examEditor';
import { toast } from 'react-toastify';
import { DragEndEvent } from '@dnd-kit/core';
import useSectionEdit from '@/components/ExamEditor/Hooks/useSectionEdit';
import Header from '@/components/ExamEditor/Shared/Header';
import Alert, { AlertItem } from '../../Shared/Alert';
import QuestionsList from '../../Shared/QuestionsList';
import SortableQuestion from '../../Shared/SortableQuestion';
import setEditingAlert from '../../Shared/setEditingAlert';
import { handleMultipleChoiceReorder } from '@/stores/examEditor/reorder/local';
import PromptEdit from '../../Shared/PromptEdit';
import { uuidv4 } from '@firebase/util';
interface MultipleChoiceProps {
exercise: MultipleChoiceExercise;
sectionId: number;
optionsQuantity: number;
}
const validateMultipleChoiceQuestions = (
questions: MultipleChoiceQuestion[],
optionsQuantity: number,
setAlerts: React.Dispatch<React.SetStateAction<AlertItem[]>>
) => {
const validationAlerts: AlertItem[] = [];
questions.forEach((question, index) => {
if (!question.prompt.trim()) {
validationAlerts.push({
variant: 'error',
tag: `missing-prompt-${index}`,
description: `Question ${index + 1} is missing a prompt`
});
}
if (!question.solution) {
validationAlerts.push({
variant: 'error',
tag: `missing-solution-${index}`,
description: `Question ${index + 1} is missing a solution`
});
}
if (question.options.length !== optionsQuantity) {
validationAlerts.push({
variant: 'error',
tag: `invalid-options-${index}`,
description: `Question ${index + 1} must have exactly ${optionsQuantity} options`
});
}
question.options.forEach((option, optionIndex) => {
if (option.text && option.text.trim() === "") {
validationAlerts.push({
variant: 'error',
tag: `empty-option-${index}-${optionIndex}`,
description: `Question ${index + 1} has an empty option`
});
}
});
});
setAlerts(prev => {
const editingAlert = prev.find(alert => alert.tag === 'editing');
return [...validationAlerts, ...(editingAlert ? [editingAlert] : [])];
});
return validationAlerts.length === 0;
};
const MultipleChoice: React.FC<MultipleChoiceProps> = ({ exercise, sectionId, optionsQuantity }) => {
const { currentModule, dispatch } = useExamEditorStore();
const difficulty = useExamEditorStore((state) => state.modules[currentModule].difficulty);
const { state } = useExamEditorStore(
(state) => state.modules[currentModule].sections.find((section) => section.sectionId === sectionId)!
);
const section = state as ReadingPart | ListeningPart| LevelPart;
const [local, setLocal] = useState(exercise);
const [editingPrompt, setEditingPrompt] = useState(false);
const [alerts, setAlerts] = useState<AlertItem[]>([]);
const updateLocal = (exercise: MultipleChoiceExercise) => {
setLocal(exercise);
setEditingAlert(true, setAlerts);
setEditing(true);
};
const updateQuestion = (index: number, field: string, value: string) => {
const newQuestions = [...local.questions];
newQuestions[index] = { ...newQuestions[index], [field]: value };
updateLocal({ ...local, questions: newQuestions });
};
const updateOption = (questionIndex: number, optionIndex: number, value: string) => {
const newQuestions = [...local.questions];
const newOptions = [...newQuestions[questionIndex].options];
newOptions[optionIndex] = { ...newOptions[optionIndex], text: value };
newQuestions[questionIndex] = { ...newQuestions[questionIndex], options: newOptions };
updateLocal({ ...local, questions: newQuestions });
};
const addQuestion = () => {
const newId = (parseInt(local.questions[local.questions.length - 1].id) + 1).toString();
const options = Array.from({ length: optionsQuantity }, (_, i) => ({
id: String.fromCharCode(65 + i),
text: ''
}));
updateLocal({
...local,
questions: [
...local.questions,
{
prompt: "",
solution: "",
uuid: uuidv4(),
id: newId,
options,
variant: "text"
},
]
});
};
const deleteQuestion = (index: number) => {
if (local.questions.length === 1) {
toast.error("There needs to be at least one question!");
return;
}
const newQuestions = local.questions.filter((_, i) => i !== index);
const minId = Math.min(...newQuestions.map(q => parseInt(q.id)));
const updatedQuestions = newQuestions.map((question, i) => ({
...question,
id: String(minId + i)
}));
updateLocal({ ...local, questions: updatedQuestions });
};
const { editing, handleSave, handleDiscard, handleDelete, handlePractice, setEditing } = useSectionEdit({
sectionId,
onSave: () => {
const isValid = validateMultipleChoiceQuestions(
local.questions,
optionsQuantity,
setAlerts
);
if (!isValid) {
toast.error("Please fix the errors before saving!");
return;
}
setEditing(false);
setAlerts([]);
const newSection = {
...section,
exercises: section.exercises.map((ex) => ex.id === local.id ? local : ex)
};
dispatch({ type: "UPDATE_SECTION_STATE", payload: { sectionId, update: newSection, module: currentModule } });
},
onDiscard: () => {
setEditing(false);
setAlerts([]);
setLocal(exercise);
},
onDelete: () => {
const newSection = {
...section,
exercises: section.exercises.filter((ex) => ex.id !== local.id)
};
dispatch({ type: "UPDATE_SECTION_STATE", payload: { sectionId, update: newSection, module: currentModule } });
},
onPractice: () => {
const updatedExercise = {
...local,
isPractice: !local.isPractice
};
const newState = { ...section };
newState.exercises = newState.exercises.map((ex) =>
ex.id === exercise.id ? updatedExercise : ex
);
setLocal((prev) => ({...prev, isPractice: !local.isPractice}))
dispatch({ type: 'UPDATE_SECTION_STATE', payload: { sectionId, update: newState, module: currentModule } });
}
});
useEffect(() => {
validateMultipleChoiceQuestions(local.questions, optionsQuantity, setAlerts);
}, [local.questions, optionsQuantity]);
const handleDragEnd = (event: DragEndEvent) => {
setEditingAlert(true, setAlerts);
setEditing(true);
setLocal(handleMultipleChoiceReorder(event, local));
};
const saveDifficulty = useCallback((diff: Difficulty) => {
if (!difficulty.includes(diff)) {
dispatch({ type: 'UPDATE_MODULE', payload: { updates: { difficulty: [...difficulty, diff]} } });
}
const updatedExercise = { ...exercise, difficulty: diff };
const newState = { ...section };
newState.exercises = newState.exercises.map((ex) => ex.id === exercise.id ? updatedExercise : ex );
dispatch({ type: 'UPDATE_SECTION_STATE', payload: { sectionId, update: newState, module: currentModule } });
}, [currentModule, difficulty, dispatch, exercise, section, sectionId]);
return (
<div className="p-4">
<Header
title='Multiple Choice Exercise'
description={`Edit questions with ${optionsQuantity} options each`}
editing={editing}
difficulty={exercise.difficulty}
saveDifficulty={saveDifficulty}
handleSave={handleSave}
handleDelete={handleDelete}
handleDiscard={handleDiscard}
handlePractice={handlePractice}
isEvaluationEnabled={!local.isPractice}
/>
{alerts.length > 0 && <Alert className="mb-6" alerts={alerts} />}
<PromptEdit value={local.prompt} onChange={(prompt: string) => updateLocal({...local, prompt})} />
<div className="space-y-4">
<QuestionsList
ids={local.questions.map(q => q.id)}
handleDragEnd={handleDragEnd}
>
{local.questions.map((question, questionIndex) => (
<SortableQuestion
key={question.id}
id={question.id}
index={questionIndex}
deleteQuestion={deleteQuestion}
>
<div className="space-y-4">
<input
type="text"
value={question.prompt}
onChange={(e) => updateQuestion(questionIndex, 'prompt', e.target.value)}
className="w-full p-3 border rounded-lg focus:ring-2 focus:ring-blue-500 focus:outline-none"
placeholder="Enter question..."
/>
<div className="space-y-2">
{question.options.map((option, optionIndex) => (
<div key={option.id} className="flex gap-2">
<label
className={clsx(
"flex-none w-12 p-3 text-center rounded-lg border-2 transition-all cursor-pointer",
question.solution === option.id
? 'border-blue-500 bg-blue-50 text-blue-700'
: 'border-gray-200 hover:border-gray-300'
)}
>
<input
type="radio"
name={`solution-${question.id}`}
value={option.id}
checked={question.solution === option.id}
onChange={(e) => updateQuestion(questionIndex, 'solution', e.target.value)}
className="sr-only"
/>
{option.id}
</label>
<input
type="text"
value={option.text}
onChange={(e) => updateOption(questionIndex, optionIndex, e.target.value)}
className="flex-1 p-3 border rounded-lg focus:ring-2 focus:ring-blue-500 focus:outline-none"
placeholder={`Option ${option.id}...`}
/>
</div>
))}
</div>
</div>
</SortableQuestion>
))}
</QuestionsList>
<button
onClick={addQuestion}
className="w-full p-4 border-2 border-dashed border-gray-300 rounded-lg hover:border-blue-500 hover:bg-blue-50 transition-colors flex items-center justify-center gap-2 text-gray-600 hover:text-blue-600"
>
<MdAdd size={18} />
Add New Question
</button>
</div>
</div>
);
};
export default MultipleChoice;

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,232 @@
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';
import { uuidv4 } from '@firebase/util';
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,
uuid: uuidv4(),
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,347 @@
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';
import { uuidv4 } from '@firebase/util';
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 = {
uuid: uuidv4(),
id: newId,
questionText: "New question"
};
const updatedQuestions = [...parsedQuestions, newQuestion];
const updatedText = reconstructText(updatedQuestions);
const updatedSolutions = [...local.solutions, {
uuid: uuidv4(),
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,305 @@
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";
import { uuidv4 } from "@firebase/util";
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, {
uuid: uuidv4(),
id: newId,
parts: parseLine(newLine),
editingPlaceholders: true
}];
const newText = updatedQuestions
.map(q => reconstructLine(q.parts))
.join('\\n') + '\\n';
const updatedSolutions = [...local.solutions, {
uuid: uuidv4(),
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,197 @@
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: (requiresApproval: boolean) => 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 -2xl:w-full`}>
<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-col gap-3 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(true)}
disabled={!canSubmit}
>
<FaFileUpload className="mr-2" size={18} />
Submit module as exam for approval
</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={() => {
if (!confirm(`Are you sure you want to skip the approval process for this exam?`)) return;
submitModule(false);
}}
disabled={!canSubmit}
>
<FaFileUpload className="mr-2" size={18} />
Submit module as exam and skip approval process
</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;

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