├── .gitbook.yaml ├── .github └── ISSUE_TEMPLATE │ ├── bug_report.md │ └── feature_request.md ├── .gitignore ├── .travis.yml ├── CHANGELOG.md ├── LICENSE ├── appveyor.yml ├── docs ├── .gitbook │ └── assets │ │ ├── annotation-2019-03-12-141810.png │ │ ├── annotation-2019-03-12-144527.png │ │ ├── annotation-2019-03-12-144917.png │ │ ├── annotation-2019-03-12-145826.png │ │ ├── annotation-2019-03-12-151034.png │ │ ├── annotation-2019-03-12-151051.png │ │ └── annotation-2019-03-14-003628.png ├── README.md ├── SUMMARY.md ├── getting-started.md ├── installation.md └── schema.md ├── package-lock.json ├── package.json ├── src ├── common │ ├── mainWinContent.js │ ├── mainWinDimensions.js │ ├── mainWinIcon.js │ └── mainWinMenu.js ├── main │ └── index.js └── renderer │ ├── App.js │ ├── components │ ├── Confirm.js │ ├── Content │ │ ├── AddRemoteExam │ │ │ ├── SearchItem.js │ │ │ └── index.js │ │ ├── Cover.js │ │ ├── Exam │ │ │ ├── Explanation.js │ │ │ ├── FillIn.js │ │ │ ├── ListOrder.js │ │ │ ├── ListOrderItem.js │ │ │ ├── MultipleAnswer.js │ │ │ ├── MultipleChoice.js │ │ │ ├── Question.js │ │ │ ├── TopDisplay.js │ │ │ └── index.js │ │ ├── Main │ │ │ ├── Bar.js │ │ │ ├── ExamItem.js │ │ │ ├── Exams.js │ │ │ ├── History.js │ │ │ ├── HistoryGroup.js │ │ │ ├── HistoryItem.js │ │ │ ├── NoData.js │ │ │ ├── SessionItem.js │ │ │ └── Sessions.js │ │ ├── Options │ │ │ └── index.js │ │ ├── Review │ │ │ ├── Notes │ │ │ │ ├── Input.js │ │ │ │ ├── NodeInput.js │ │ │ │ └── index.js │ │ │ ├── ReviewExam.js │ │ │ ├── StaticList.js │ │ │ ├── Summary.js │ │ │ ├── TopDisplay.js │ │ │ └── index.js │ │ └── index.js │ ├── GlobalStyle.js │ ├── Loading.js │ ├── LoadingMain.js │ ├── Modal.js │ └── Navigation │ │ ├── Drawer │ │ ├── Grid.js │ │ ├── ReviewGrid.js │ │ ├── Stats.js │ │ └── index.js │ │ ├── Footer │ │ ├── ExamFooter.js │ │ ├── ReviewFooter.js │ │ └── index.js │ │ ├── Header │ │ ├── ExamHeader.js │ │ ├── MainHeader.js │ │ ├── ReviewHeader.js │ │ └── index.js │ │ └── index.js │ ├── index.js │ ├── styles │ ├── InnerModal.js │ ├── Main.js │ ├── Media.js │ ├── Slide.js │ └── theme.js │ └── utils │ ├── analyzeAnswers.js │ ├── analyzeGridItem.js │ ├── analyzeReviewGridItem.js │ ├── createExplanation.js │ ├── createFileSystem.js │ ├── createHistoryGroups.js │ ├── createSession.js │ ├── deleteExam.js │ ├── deleteFile.js │ ├── examDataStuctures.js │ ├── filepaths.js │ ├── formatAnswerLabel.js │ ├── formatCreatedAt.js │ ├── formatDate.js │ ├── formatTimer.js │ ├── processRemoteExam.js │ ├── questionHelper.js │ ├── randomizeArray.js │ ├── readExamsDir.js │ ├── readHistoryFile.js │ ├── readOptionsFile.js │ ├── readSessionsFile.js │ ├── showAboutDialog.js │ ├── showFileDialog.js │ ├── validateExam.js │ └── writeData.js ├── static ├── exams │ └── demo.json ├── icon.icns ├── icon.ico └── options.json └── test ├── hooks.js ├── mocha.opts └── spec.js /.gitbook.yaml: -------------------------------------------------------------------------------- 1 | root: ./docs 2 | 3 | structure: 4 | readme: README.md 5 | summary: SUMMARY.md 6 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Let us know what went wrong. 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Describe the bug** 11 | A clear and concise description of what the bug is. 12 | 13 | **To Reproduce** 14 | Steps to reproduce the behavior: 15 | 1. Go to '...' 16 | 2. Click on '....' 17 | 3. Scroll down to '....' 18 | 4. See error 19 | 20 | **Expected behavior** 21 | A clear and concise description of what you expected to happen. 22 | 23 | **Screenshots** 24 | If applicable, add screenshots to help explain your problem. 25 | 26 | **Desktop (please complete the following information):** 27 | - OS: [e.g. iOS, Windows] 28 | - Version [e.g. 22] 29 | 30 | **Additional context** 31 | Add any other context about the problem here. 32 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Is your feature request related to a problem? Please describe.** 11 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 12 | 13 | **Describe the solution you'd like** 14 | A clear and concise description of what you want to happen. 15 | 16 | **Describe alternatives you've considered** 17 | A clear and concise description of any alternative solutions or features you've considered. 18 | 19 | **Additional context** 20 | Add any other context or screenshots about the feature request here. 21 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | #dependencies 2 | node_modules 3 | 4 | #build files 5 | dist 6 | release 7 | 8 | #vscode settings 9 | .vscode 10 | 11 | # prettier settings 12 | .prettierignore -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | os: osx 2 | osx_image: xcode9.4 3 | language: node_js 4 | node_js: "10" 5 | cache: 6 | directories: 7 | - node_modules 8 | install: 9 | - npm install 10 | script: 11 | - npm run compile 12 | - npm run release 13 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | - All changes to this project will be recorded in this document 4 | 5 | ## [Unreleased] 6 | 7 | ### Added 8 | 9 | - **Testing** - testing is done with `spectron` using `mocha` as a test runner and `chai` & `chai-as-promised` assertion libraries 10 | - **Notes** component, user can add custom notes to each question 11 | 12 | ### Changed 13 | 14 | ### Fixed 15 | 16 | ## 1.0.0-alpha.1 17 | 18 | ### Added 19 | 20 | - `electron-updater` to perform automatic updates on `Windows`, (`MacOS` requires code signature) 21 | - `app.requestSingleInstanceLock` to prevent running more than one instance of application 22 | - **LoadingMain** component with title and spinner 23 | 24 | ### Changed 25 | 26 | - **Exam** folder optimized with `shouldComponentUpdate` and `React.memo` 27 | - **Confirm** styles 28 | - Removed **AddRemoteExam** component 29 | 30 | ### Fixed 31 | 32 | - **AddRemoteExam** component set endpoints to production api 33 | - **Question** component lifecycle pauses question timer when main timer is paused 34 | 35 | ## 1.0.0-alpha 03-12-19 36 | 37 | - Initial Release 38 | 39 | [unreleased]: https://github.com/exam-simulator/simulator/compare/v1.0.0-alpha.1...HEAD 40 | [1.0.0-alpha.1]: https://github.com/exam-simulator/simulator/compare/v1.0.0-alpha...v1.0.0-alpha.1 41 | [1.0.0-alpha]: https://github.com/exam-simulator/simulator/tag/v1.0.0-alpha 42 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 Exam Simulator 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /appveyor.yml: -------------------------------------------------------------------------------- 1 | version: 1.0.{build} 2 | image: Visual Studio 2017 3 | environment: 4 | GH_TOKEN: 5 | secure: FjBCe/1FoJYp3W+abIm79Pm7W3mUfA4B9tnHXDzle6wOdimJqAPtz88Y81FT7lm8 6 | install: 7 | - cmd: npm install 8 | build_script: 9 | - cmd: npm run compile 10 | deploy_script: 11 | - cmd: npm run release 12 | -------------------------------------------------------------------------------- /docs/.gitbook/assets/annotation-2019-03-12-141810.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/exam-simulator/simulator/ff2529ca967ee69790701967525564a13442fa44/docs/.gitbook/assets/annotation-2019-03-12-141810.png -------------------------------------------------------------------------------- /docs/.gitbook/assets/annotation-2019-03-12-144527.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/exam-simulator/simulator/ff2529ca967ee69790701967525564a13442fa44/docs/.gitbook/assets/annotation-2019-03-12-144527.png -------------------------------------------------------------------------------- /docs/.gitbook/assets/annotation-2019-03-12-144917.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/exam-simulator/simulator/ff2529ca967ee69790701967525564a13442fa44/docs/.gitbook/assets/annotation-2019-03-12-144917.png -------------------------------------------------------------------------------- /docs/.gitbook/assets/annotation-2019-03-12-145826.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/exam-simulator/simulator/ff2529ca967ee69790701967525564a13442fa44/docs/.gitbook/assets/annotation-2019-03-12-145826.png -------------------------------------------------------------------------------- /docs/.gitbook/assets/annotation-2019-03-12-151034.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/exam-simulator/simulator/ff2529ca967ee69790701967525564a13442fa44/docs/.gitbook/assets/annotation-2019-03-12-151034.png -------------------------------------------------------------------------------- /docs/.gitbook/assets/annotation-2019-03-12-151051.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/exam-simulator/simulator/ff2529ca967ee69790701967525564a13442fa44/docs/.gitbook/assets/annotation-2019-03-12-151051.png -------------------------------------------------------------------------------- /docs/.gitbook/assets/annotation-2019-03-14-003628.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/exam-simulator/simulator/ff2529ca967ee69790701967525564a13442fa44/docs/.gitbook/assets/annotation-2019-03-14-003628.png -------------------------------------------------------------------------------- /docs/README.md: -------------------------------------------------------------------------------- 1 | # Exam Simulator JS 2 | 3 | JSON based, open source and free exam simulator. 4 | 5 | ![Windows](https://img.shields.io/badge/platform-windows-lightgrey.svg) ![AppVeyor](https://img.shields.io/appveyor/ci/exam-simulator/simulator.svg?style=popout) 6 | 7 | ![Macos](https://img.shields.io/badge/platform-macos-lightgrey.svg) ![Travis (.org)](https://img.shields.io/travis/exam-simulator/simulator.svg?style=popout) 8 | 9 | ![](https://img.shields.io/github/downloads/exam-simulator/simulator/total.svg?style=popout) 10 | 11 | ### [Documentation](https://exam-simulator.gitbook.io/exam-simulator) 12 | 13 | ## Notes 14 | 15 | This project is in alpha release. Stay tuned for lots of improvements and updates. 16 | 17 | ## Donate 18 | 19 | The app is free, but I won't turn down a donation. 🤑 20 | 21 | [![](https://www.paypalobjects.com/en_US/i/btn/btn_donateCC_LG.gif)](https://www.paypal.com/cgi-bin/webscr?cmd=_s-xclick&hosted_button_id=BKMDFU4LLE6NU&source=url) 22 | -------------------------------------------------------------------------------- /docs/SUMMARY.md: -------------------------------------------------------------------------------- 1 | # Table of contents 2 | 3 | * [Exam Simulator JS](README.md) 4 | * [Installation](installation.md) 5 | * [Getting Started](getting-started.md) 6 | * [Schema](schema.md) 7 | 8 | -------------------------------------------------------------------------------- /docs/getting-started.md: -------------------------------------------------------------------------------- 1 | # Getting Started 2 | 3 | ### Demo Exam 4 | 5 | The quickest way to get started is to try the _Demo Exam_ that ships with the software. Click the _Demo Exam_ item and you should see its cover page. The demo is based on the popular documentary _Marking a Murderer_. 6 | 7 | ![Demo Exam Cover](.gitbook/assets/annotation-2019-03-12-141810.png) 8 | 9 | To begin the exam click _Start Exam_. 10 | 11 | Taking a test is intuitive and something most of us have done our entire lives. In the user interface you will find various controls to navigate through the exam. 12 | 13 | ![List Order Question](.gitbook/assets/annotation-2019-03-12-144527.png) 14 | 15 | ### Exam Controls 16 | 17 | #### Question Grid 18 | 19 | The left portion of the screen contains the menu drawer and question grid. Navigate to any question by clicking the corresponding square. Question status is quickly visible by color. _Bookmark_ a question if you aren't sure of the answer or want to review it before ending the exam. Do this my clicking the bookmark icon. 20 | 21 | ![Question Grid](.gitbook/assets/annotation-2019-03-12-144917.png) 22 | 23 | #### Timer 24 | 25 | Many exams are timed and budgeting available the clock can be an important skill. The exam timer is located in the footer. When time is low the display will turn red. 26 | 27 | ![15 Seconds on Timer](.gitbook/assets/annotation-2019-03-12-145826.png) 28 | 29 | #### Arrows 30 | 31 | Use the navigation arrows to cycle through questions. The interior buttons move to the previous or next question, while the exterior buttons move to the first or last question. 32 | 33 | ![Question Arrow Buttons](.gitbook/assets/annotation-2019-03-12-151051.png) 34 | 35 | #### Question Modes 36 | 37 | Switch between _All Questions_ and _Bookmarked Questions_ from the menu drawer to change the group of questions being cycled. 38 | 39 | ![Bookmarked Question in Bookmarked Mode](.gitbook/assets/annotation-2019-03-12-151034.png) 40 | 41 | #### Show Answer 42 | 43 | Show are correct answer by clicking _Show Answer_ from the menu drawer. An _Explanation_ box will appear and be scrolled into view. The correct answer, answer status \(Correct/Incorrect\), explanation and notes, if any will appear here. 44 | 45 | ![Expanation](.gitbook/assets/annotation-2019-03-14-003628.png) 46 | 47 | #### Pause Exam 48 | 49 | Pause the exam by clicking _Pause Exam_ from the menu drawer. The timer will stop, and interaction with the exam is disabled. Click _Resume Exam_ to continue. 50 | 51 | #### End Exam 52 | 53 | End the exam by clicking _End Exam_ from the menu drawer. A dialog will ask for confirmation and then display a summary of exam results. 54 | 55 | -------------------------------------------------------------------------------- /docs/installation.md: -------------------------------------------------------------------------------- 1 | # Installation 2 | 3 | The application is currently in _alpha_ release. There is no _Code Signing_, therefore, your operating system/browser may display warnings when downloading the executable. The _Windows_ version will automatically update when a new releases occur. Automatic updates are not currently supported on _MacOS._ 4 | 5 | ### [Download](https://github.com/exam-simulator/simulator/releases) 6 | 7 | ### Windows 8 | 9 | Download the latest `exe` file. 10 | 11 | ### MacOS 12 | 13 | Download the latest `dmg` file. 14 | 15 | -------------------------------------------------------------------------------- /docs/schema.md: -------------------------------------------------------------------------------- 1 | --- 2 | description: Description of exam schema. 3 | --- 4 | 5 | # Schema 6 | 7 | Exam files use the `.json` extension. _JSON_, or _JavaScript Object Notation_ is a data format that is supported by many programming languages. Files must use this extension and adhere to the schema defined below. 8 | 9 | [Exam Maker](https://exam-maker.herokuapp.com/) allows users to create and share exams without knowing this schema. However, exams can be created in any text editor. 10 | 11 | ### Example Exam File 12 | 13 | {% code-tabs %} 14 | {% code-tabs-item title="exam.json" %} 15 | ```javascript 16 | { 17 | "id": "12345", 18 | "title":"Example Exam", 19 | "description": "An example exam", 20 | "author": { 21 | "id": "67890", 22 | "name": "Benjaminadk", 23 | "image": "http://www.example.com/image.png" 24 | }, 25 | "code": "123-abc", 26 | "time": 60, 27 | "pass": 75, 28 | "image": "http://www.example.com/image.png", 29 | "cover": [ 30 | {"variant": 2, "text": "Large Text"}, 31 | {"variant": 1, "text": "Normal Text"} 32 | ], 33 | "test": [ 34 | "variant": 0, 35 | "question": [ 36 | {"variant": 1, "text": "Normal text"}, 37 | {"variant": 0, "text": "Image URL"} 38 | ], 39 | "choices": [ 40 | {"label": "A", "text": "Option A"}, 41 | {"label": "B", "text": "Option B"}, 42 | {"label": "C", "text": "Option C"}, 43 | {"label": "D", "text": "Option D"}, 44 | ], 45 | "answer": [true, false, false, false], 46 | "explanation": [ 47 | {"variant": 1, "text": "Normal text"}, 48 | {"variant": 0, "text": "Image URL"} 49 | ] 50 | ] 51 | } 52 | ``` 53 | {% endcode-tabs-item %} 54 | {% endcode-tabs %} 55 | 56 | 57 | 58 | ### Exam 59 | 60 | | Property | Description | Type | 61 | | :--- | :--- | :---: | 62 | | **id** | unique identifier | `string` | 63 | | **title** | exam title | `string` | 64 | | **description** | exam description | `string` | 65 | | **author** | exam author | `Author` | 66 | | **code** | certification/exam code | `string` | 67 | | **pass** | minimum passing score percentage | `number` | 68 | | **time** | time limit in minutes | `number` | 69 | | **image** | URL of exam logo 1:1 size is best | `string` | 70 | | **cover** | first screen of exam | `Node[]` | 71 | | **test** | exam content | `Question[]` | 72 | 73 | #### 74 | 75 | ### Author 76 | 77 | | Property | Description | Type | 78 | | :--- | :--- | :--- | 79 | | **id** | unique identifier | `string` | 80 | | **name** | author name | `string` | 81 | | **image** | author image URL | `string` | 82 | 83 | #### 84 | 85 | ### Question 86 | 87 | | Property | Description | Type | 88 | | :--- | :--- | :--- | 89 | | **variant** | type of question | `number` | 90 | | **question** | question content | `Node[]` | 91 | | **choices** | answer content | `Choice[]` | 92 | | **answer** | answer key | `boolean/string[]` | 93 | | **explanation** | explanation content | `Node[]` | 94 | 95 | 96 | 97 | ### Question Variants 98 | 99 | | Variant | Question Type | Answer Example | 100 | | :--- | :--- | :--- | 101 | | **0** | multiple choice | `[true,false,false,false]` | 102 | | **1** | multiple answer | `[true,true,false,false]` | 103 | | **2** | fill in the blank | `[answer,variation,another]` | 104 | | **3** | list order | `[]` | 105 | 106 | 107 | 108 | ### Node 109 | 110 | | Property | Description | Type | 111 | | :--- | :--- | :--- | 112 | | **variant** | type of node | `number` | 113 | | **text** | content of node | `string` | 114 | 115 | 116 | 117 | ### Node Variants 118 | 119 | | Variant | Node Type | Text | 120 | | :--- | :--- | :--- | 121 | | **0** | image | URL of an image | 122 | | **1** | normal text | Normal sized text, most commonly used variant | 123 | | **2** | large text | Large header text | 124 | 125 | 126 | 127 | ### Choice 128 | 129 | | Property | Description | Type | 130 | | :--- | :--- | :--- | 131 | | **label** | choice label text | `string` | 132 | | **text** | content of choice | `string` | 133 | 134 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "exam-simulator", 3 | "version": "1.0.0-alpha.2", 4 | "description": "A JSON based, open source, exam simulator built with electron.", 5 | "main": "dist/main/main.js", 6 | "keywords": [ 7 | "exam", 8 | "simulator" 9 | ], 10 | "author": { 11 | "name": "benjaminadk", 12 | "email": "jsonexamsimulator@gmail.com", 13 | "url": "https://github.com/exam-simulator" 14 | }, 15 | "repository": { 16 | "type": "git", 17 | "url": "https://github.com/exam-simulator/simulator.git" 18 | }, 19 | "homepage": "https://github.com/exam-simulator/simulator", 20 | "license": "ISC", 21 | "scripts": { 22 | "test": "mocha", 23 | "dev": "electron-webpack dev", 24 | "prod": "electron ./dist/main/main.js", 25 | "compile": "electron-webpack", 26 | "release": "electron-builder -p always" 27 | }, 28 | "dependencies": { 29 | "ajv": "^6.10.0", 30 | "axios": "^0.18.0", 31 | "date-fns": "^2.0.0-alpha.27", 32 | "electron-devtools-installer": "^2.2.4", 33 | "electron-updater": "^4.0.6", 34 | "immutability-helper": "^3.0.0", 35 | "lodash.isequal": "^4.5.0", 36 | "polished": "^3.0.3", 37 | "prop-types": "^15.7.2", 38 | "react": "^16.8.3", 39 | "react-dnd": "^7.1.0", 40 | "react-dnd-html5-backend": "^7.1.0", 41 | "react-dom": "^16.8.3", 42 | "source-map-support": "^0.5.10", 43 | "styled-components": "^4.1.3", 44 | "styled-icons": "^7.4.0" 45 | }, 46 | "devDependencies": { 47 | "@babel/plugin-proposal-class-properties": "^7.3.4", 48 | "@babel/preset-react": "^7.0.0", 49 | "babel-plugin-styled-components": "^1.10.0", 50 | "chai": "^4.2.0", 51 | "chai-as-promised": "^7.1.1", 52 | "electron": "^4.0.6", 53 | "electron-builder": "^20.38.5", 54 | "electron-webpack": "^2.6.2", 55 | "mocha": "^6.0.2", 56 | "spectron": "^5.0.0", 57 | "webpack": "^4.29.6" 58 | }, 59 | "build": { 60 | "appId": "Benjaminadk.ExamSimulator", 61 | "productName": "Exam Simulator", 62 | "copyright": "Copyright © 2019 Benjaminadk", 63 | "win": { 64 | "icon": "static/icon.ico", 65 | "publish": { 66 | "provider": "github", 67 | "owner": "exam-simulator" 68 | }, 69 | "target": [ 70 | "nsis" 71 | ] 72 | }, 73 | "mac": { 74 | "category": "public.app-category.education", 75 | "icon": "static/icon.icns", 76 | "publish": { 77 | "provider": "github", 78 | "owner": "exam-simulator" 79 | }, 80 | "target": [ 81 | "dmg" 82 | ] 83 | }, 84 | "directories": { 85 | "output": "release" 86 | } 87 | } 88 | } 89 | -------------------------------------------------------------------------------- /src/common/mainWinContent.js: -------------------------------------------------------------------------------- 1 | import path from 'path' 2 | import url from 'url' 3 | 4 | export default inDev => { 5 | return inDev 6 | ? `http://localhost:${process.env.ELECTRON_WEBPACK_WDS_PORT}` 7 | : url.format({ 8 | pathname: path.resolve(__dirname, '../renderer', 'index.html'), 9 | protocol: 'file', 10 | slashes: true 11 | }) 12 | } 13 | -------------------------------------------------------------------------------- /src/common/mainWinDimensions.js: -------------------------------------------------------------------------------- 1 | import { screen } from 'electron' 2 | 3 | export default () => { 4 | const { width, height } = screen.getPrimaryDisplay().size 5 | let mainWidth = Math.round(width * 0.9) 6 | let mainHeight = Math.round(height * 0.9) 7 | return [mainWidth, mainHeight] 8 | } 9 | -------------------------------------------------------------------------------- /src/common/mainWinIcon.js: -------------------------------------------------------------------------------- 1 | import path from 'path' 2 | 3 | export default path.resolve(__static, process.platform === 'darwin' ? 'icon.icns' : 'icon.ico') 4 | -------------------------------------------------------------------------------- /src/common/mainWinMenu.js: -------------------------------------------------------------------------------- 1 | import { Menu, shell } from 'electron' 2 | 3 | const template = [ 4 | { label: 'File', submenu: [{ role: 'quit' }] }, 5 | { 6 | label: 'Edit', 7 | submenu: [{ role: 'cut' }, { role: 'copy' }, { role: 'paste' }] 8 | }, 9 | { 10 | label: 'View', 11 | submenu: [ 12 | { role: 'reload' }, 13 | { role: 'forcereload' }, 14 | { type: 'separator' }, 15 | { role: 'resetzoom' }, 16 | { role: 'zoomin' }, 17 | { role: 'zoomout' }, 18 | { type: 'separator' }, 19 | { role: 'togglefullscreen' }, 20 | { role: 'minimize' } 21 | ] 22 | }, 23 | { 24 | label: 'Help', 25 | submenu: [ 26 | { 27 | label: 'Documentation', 28 | click: () => shell.openExternal('https://exam-simulator.gitbook.io/exam-simulator/') 29 | } 30 | ] 31 | } 32 | ] 33 | 34 | export default () => Menu.buildFromTemplate(template) 35 | -------------------------------------------------------------------------------- /src/main/index.js: -------------------------------------------------------------------------------- 1 | import { app, BrowserWindow } from 'electron' 2 | import { autoUpdater } from 'electron-updater' 3 | import mainWinDimensions from 'common/mainWinDimensions' 4 | import mainWinContent from 'common/mainWinContent' 5 | import mainWinIcon from 'common/mainWinIcon' 6 | import mainWinMenu from 'common/mainWinMenu' 7 | 8 | let mainWin 9 | 10 | const gotTheLock = app.requestSingleInstanceLock() 11 | 12 | const inDev = process.env.NODE_ENV === 'development' 13 | 14 | autoUpdater.checkForUpdatesAndNotify() 15 | 16 | function createMainWin() { 17 | const [width, height] = mainWinDimensions() 18 | 19 | mainWin = new BrowserWindow({ 20 | width, 21 | height, 22 | title: 'Exam Simulator', 23 | icon: mainWinIcon, 24 | webPreferences: { 25 | nodeIntegration: true 26 | } 27 | }) 28 | 29 | mainWin.loadURL(mainWinContent(inDev)) 30 | 31 | mainWin.setMenu(mainWinMenu()) 32 | 33 | installReactDevtools(inDev) 34 | 35 | mainWin.on('close', () => { 36 | mainWin = null 37 | }) 38 | } 39 | 40 | function installReactDevtools(inDev) { 41 | if (inDev) { 42 | const { 43 | default: installExtension, 44 | REACT_DEVELOPER_TOOLS 45 | } = require('electron-devtools-installer') 46 | mainWin.webContents.openDevTools({ mode: 'detach' }) 47 | 48 | installExtension(REACT_DEVELOPER_TOOLS) 49 | .then(name => console.log(`Installed --> ${name}`)) 50 | .catch(console.log) 51 | } 52 | } 53 | 54 | if (!gotTheLock) { 55 | app.quit() 56 | } else { 57 | app.on('second-instance', () => { 58 | if (mainWin) { 59 | if (mainWin.isMinimized()) { 60 | mainWin.restore() 61 | } 62 | mainWin.focus() 63 | } 64 | }) 65 | app.on('ready', createMainWin) 66 | } 67 | 68 | app.on('window-all-closed', () => app.quit()) 69 | -------------------------------------------------------------------------------- /src/renderer/App.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { remote } from 'electron' 3 | import isequal from 'lodash.isequal' 4 | import fs from 'fs' 5 | import createFileSystem from './utils/createFileSystem' 6 | import readExamsDir from './utils/readExamsDir' 7 | import readHistoryFile from './utils/readHistoryFile' 8 | import readSessionsFile from './utils/readSessionsFile' 9 | import readOptionsFile from './utils/readOptionsFile' 10 | import writeData from './utils/writeData' 11 | import deleteExam from './utils/deleteExam' 12 | import showFileDialog from './utils/showFileDialog' 13 | import processRemoteExam from './utils/processRemoteExam' 14 | import analyzeAnswers from './utils/analyzeAnswers' 15 | import examDataStuctures from './utils/examDataStuctures' 16 | import createSession from './utils/createSession' 17 | import questionHelper from './utils/questionHelper' 18 | import { DATA_DIR_PATH } from './utils/filepaths' 19 | import Navigation from './components/Navigation' 20 | import Content from './components/Content' 21 | import LoadingMain from './components/LoadingMain' 22 | 23 | const mainWin = remote.BrowserWindow.fromId(1) 24 | 25 | export default class App extends React.Component { 26 | state = { 27 | loading: true, 28 | mode: 0, 29 | mainMode: 0, 30 | exams: [], 31 | history: [], 32 | sessions: [], 33 | options: null, 34 | indexExam: null, 35 | indexHistory: null, 36 | indexSession: null, 37 | examMode: 0, 38 | exam: null, 39 | answers: [], 40 | fillIns: [], 41 | orders: [], 42 | marked: [], 43 | question: 0, 44 | time: 0, 45 | explanation: false, 46 | report: null, 47 | reviewMode: 0, 48 | reviewType: 0, 49 | reviewQuestion: 0 50 | } 51 | 52 | explanation = React.createRef() 53 | 54 | componentDidMount() { 55 | this.createOrLoadApplicationData() 56 | } 57 | 58 | /** 59 | * Create or load all application data 60 | */ 61 | createOrLoadApplicationData = async () => { 62 | if (fs.existsSync(DATA_DIR_PATH)) { 63 | this.setExams() 64 | this.setHistory() 65 | this.setSessions() 66 | this.setOptions() 67 | } else { 68 | await createFileSystem() 69 | this.setExams() 70 | this.setHistory() 71 | this.setSessions() 72 | this.setOptions() 73 | } 74 | } 75 | 76 | /** 77 | * Read exam directory and set exam files to state 78 | */ 79 | setExams = async () => this.setState({ loading: false, exams: await readExamsDir() }) 80 | 81 | /** 82 | * Read history file and set history to state 83 | */ 84 | setHistory = async () => this.setState({ history: await readHistoryFile() }) 85 | 86 | /** 87 | * Read sessions file and set sessions to state 88 | */ 89 | setSessions = async () => this.setState({ sessions: await readSessionsFile() }) 90 | 91 | /** 92 | * Read options file and set options to state 93 | */ 94 | setOptions = async () => this.setState({ options: await readOptionsFile() }) 95 | 96 | /** 97 | * Show file dialog to select a local JSON exam file, validates (returns errors if invalid) and sets new exam state 98 | */ 99 | loadLocalExam = async () => { 100 | const result = await showFileDialog(mainWin) 101 | if (result === true) { 102 | this.setExams() 103 | this.setMainMode(0) 104 | } else { 105 | console.log(result) // array of error messages 106 | } 107 | } 108 | 109 | loadRemoteExam = async (filename, exam) => { 110 | const result = await processRemoteExam(filename, exam) 111 | if (result === true) { 112 | this.setExams() 113 | this.setMainMode(0) 114 | } else { 115 | console.log(result) // array of error messages 116 | } 117 | } 118 | 119 | /** 120 | * Delete a single exam file, prevents demo exam from deletion 121 | */ 122 | deleteExam = async () => { 123 | const { exams, indexExam, sessions, history } = this.state 124 | const success = await deleteExam(exams, indexExam, sessions, history) 125 | if (success) { 126 | this.setExams() 127 | this.setHistory() 128 | this.setSessions() 129 | } 130 | this.setState({ indexExam: null }) 131 | } 132 | 133 | /** 134 | * Delete a single history item 135 | */ 136 | deleteHistory = async () => { 137 | const { history, indexHistory } = this.state 138 | const newHistory = history.filter((el, i) => i !== indexHistory) 139 | await writeData('history', newHistory) 140 | this.setState({ history: newHistory, indexHistory: null }) 141 | } 142 | 143 | /** 144 | * Delete a single session item 145 | */ 146 | deleteSession = async () => { 147 | const { sessions, indexSession } = this.state 148 | const newSessions = sessions.filter((el, i) => i !== indexSession) 149 | await writeData('session', newSessions) 150 | this.setState({ sessions: newSessions, indexSession: null }) 151 | } 152 | 153 | /** 154 | * Set mode of application 155 | * @param mode {number} - new mode - 0 = main | 1 = cover | 2 = exam | 3 = review 156 | */ 157 | setMode = mode => this.setState({ mode }) 158 | 159 | /** 160 | * Set mode of main screen 161 | * @param mainMode {number} - new main mode - 0 = exams | 1 = history | 2 = sessions | 3 = options | 4 = add remote exam 162 | */ 163 | setMainMode = mainMode => this.setState({ mainMode }) 164 | 165 | /** 166 | * Set index of selected exam file 167 | * @param indexExam {number} - the new index 168 | */ 169 | setIndexExam = indexExam => this.setState({ indexExam }) 170 | 171 | /** 172 | * Set index of selected history file 173 | * @param indexHistory {number} - the new index 174 | */ 175 | setIndexHistory = indexHistory => this.setState({ indexHistory }) 176 | 177 | /** 178 | * Set index of selected session file 179 | * @param indexSession {number} - the new index 180 | */ 181 | setIndexSession = indexSession => this.setState({ indexSession }) 182 | 183 | /** 184 | * Sets the question index 185 | * @param question {integer} - index to set question to 186 | * @param source {string|number} - 'grid' = direct question click | 0 = skip to start | 1 = prev | 2 = next | 3 = skip to end 187 | */ 188 | setQuestion = (question, source) => { 189 | const { 190 | exam: { test }, 191 | examMode, 192 | marked 193 | } = this.state 194 | // direct question click 195 | if (source === 'grid') { 196 | return this.setState({ question, explanation: false }) 197 | } 198 | if (question < 0 || question > test.length - 1) { 199 | return 200 | } 201 | // parse question from subset 202 | // all questions mode 203 | if (examMode === 0) { 204 | this.setState({ question, explanation: false }) 205 | // bookmark mode 206 | } else { 207 | if (marked.length === 1) return 208 | const newQuestion = questionHelper(source, marked, question) 209 | if (newQuestion === false) { 210 | return 211 | } 212 | this.setState({ question: newQuestion }) 213 | } 214 | } 215 | 216 | /** 217 | * Set exam mode 0 - all questions | 1 - bookmarked questions 218 | * @param examMode {number} - the new mode 219 | */ 220 | setExamMode = examMode => { 221 | if (examMode === 1) { 222 | const { marked } = this.state 223 | if (!marked.length) { 224 | return 225 | } 226 | this.setState({ question: marked[0], examMode }) 227 | } else { 228 | this.setState({ examMode }) 229 | } 230 | } 231 | 232 | /** 233 | * Prepare data structures for exam, shows cover screen 234 | * @param i {number} - index of exam 235 | */ 236 | initExam = i => { 237 | const exam = this.state.exams[i] 238 | const [answers, fillIns, orders, intervals] = examDataStuctures(exam) 239 | const marked = [] 240 | const time = exam.time * 60 241 | this.setState({ 242 | mode: 1, 243 | question: 0, 244 | exam, 245 | answers, 246 | fillIns, 247 | orders, 248 | intervals, 249 | marked, 250 | time, 251 | indexExam: i 252 | }) 253 | } 254 | 255 | /** 256 | * Start the exam timer countdown 257 | */ 258 | initTimer = () => { 259 | this.timer = setInterval(() => { 260 | const { time } = this.state 261 | if (time === 0) { 262 | clearInterval(this.timer) 263 | return 264 | } 265 | this.setState({ time: time - 1 }) 266 | }, 1000) 267 | } 268 | 269 | /** 270 | * Pause the exam timer countdown 271 | */ 272 | pauseTimer = () => { 273 | clearInterval(this.timer) 274 | } 275 | 276 | /** 277 | * Set the intervals value - time spent on each question 278 | * @param intervals - {numbers[]} - new value of intervals 279 | */ 280 | setIntervals = intervals => this.setState({ intervals }) 281 | 282 | /** 283 | * End the exam, stop timer, create a history report, open review mode, save history 284 | */ 285 | endExam = () => { 286 | this.pauseTimer() 287 | const { exam, answers, fillIns, orders, intervals, time, history } = this.state 288 | const report = analyzeAnswers(exam, answers, fillIns, orders, time, intervals) 289 | history.push(report) 290 | this.setState( 291 | { 292 | mode: 3, 293 | reviewMode: 0, 294 | question: 0, 295 | indexExam: null, 296 | history, 297 | report 298 | }, 299 | () => writeData('history', history) 300 | ) 301 | } 302 | 303 | /** 304 | * Bookmark a question 305 | * @param i {number} - index of question 306 | * @param add {boolean} - add or remove bookmark 307 | */ 308 | onBookmarkQuestion = (i, add) => { 309 | const { examMode, marked } = this.state 310 | let newMarked = marked.slice(0) 311 | if (add) { 312 | newMarked.push(i) 313 | } else { 314 | newMarked = marked.filter(el => el !== i) 315 | if (examMode === 1) { 316 | if (!newMarked.length) { 317 | this.setExamMode(0) 318 | } else { 319 | this.setState({ question: newMarked[0] }) 320 | } 321 | } 322 | } 323 | newMarked.sort((a, b) => a - b) 324 | this.setState({ marked: newMarked }) 325 | } 326 | 327 | /** 328 | * Answer a multiple choice question, check against correct answers, update answers 329 | * @param answer {number} - index of answer 330 | */ 331 | onMultipleChoice = answer => { 332 | let { question, answers } = this.state 333 | answers[question] = answers[question].map((el, i) => i === answer) 334 | this.setState({ answers }) 335 | } 336 | 337 | /** 338 | * Answer a multiple answer question, check against correct answers, update answers 339 | * @param answer {number} - index of answer 340 | */ 341 | onMultipleAnswer = answer => { 342 | let { question, answers } = this.state 343 | answers[question] = answer 344 | this.setState({ answers }) 345 | } 346 | 347 | /** 348 | * Answer a fill in the blank question, check against correct answers, update answers, add string to fill ins 349 | * @param answer {string} - text input of answer 350 | */ 351 | onFillIn = answer => { 352 | let { exam, question, answers, fillIns } = this.state 353 | const correct = exam.test[question].choices.reduce((acc, val) => { 354 | acc.push(val.text.toLowerCase()) 355 | return acc 356 | }, []) 357 | if (correct.indexOf(answer) !== -1) { 358 | answers[question] = [true] 359 | } else { 360 | answers[question] = [false] 361 | } 362 | fillIns[question] = answer 363 | this.setState({ answers, fillIns }) 364 | } 365 | 366 | /** 367 | * Answer a list order question, check against correct answer, update answers, add order to orders 368 | * @param order {number[]} - array of indexes represents user answer 369 | * @param i {number} - index of question 370 | */ 371 | onListOrder = (order, i) => { 372 | let { answers, orders } = this.state 373 | const correct = order.map((el, j) => j) 374 | answers[i] = [isequal(correct, order)] 375 | orders[i] = order 376 | this.setState({ answers, orders }) 377 | } 378 | 379 | /** 380 | * Reveal answer and scroll to it 381 | */ 382 | onShowExplanation = () => { 383 | this.setState( 384 | ({ explanation }) => ({ explanation: !explanation }), 385 | () => { 386 | if (this.state.explanation) { 387 | setTimeout(() => { 388 | this.explanation.current.scrollIntoView({ 389 | behavior: 'smooth', 390 | block: 'start', 391 | inline: 'end' 392 | }) 393 | }, 500) 394 | } 395 | } 396 | ) 397 | } 398 | 399 | /** 400 | * Write exam state to disk to resume later 401 | */ 402 | saveSession = () => { 403 | clearInterval(this.timer) 404 | const { sessions } = this.state 405 | const session = createSession(this.state) 406 | sessions.push(session) 407 | this.setState({ mode: 0, sessions, indexExam: null }, () => { 408 | writeData('session', sessions) 409 | }) 410 | } 411 | 412 | /** 413 | * Initialize a saved session 414 | */ 415 | initSession = () => { 416 | const { sessions, indexSession, exams } = this.state 417 | const session = sessions[indexSession] 418 | const exam = exams.find(el => el.filename === session.filename) 419 | 420 | this.setState( 421 | { 422 | mode: 2, 423 | mainMode: 0, 424 | exam, 425 | time: session.time, 426 | question: session.question, 427 | answers: session.answers, 428 | marked: session.marked, 429 | fillIns: session.fillIns, 430 | orders: session.orders 431 | }, 432 | () => { 433 | this.initTimer() 434 | } 435 | ) 436 | } 437 | 438 | /** 439 | * Initialize review mode and fetch report 440 | */ 441 | initReview = () => { 442 | const { exams, history, indexHistory } = this.state 443 | const report = history[indexHistory] 444 | const exam = exams.find(el => el.filename === report.filename) 445 | this.setState({ 446 | mode: 3, 447 | exam, 448 | report, 449 | reviewMode: 0, 450 | reviewType: 0, 451 | reviewQuestion: 0 452 | }) 453 | } 454 | 455 | /** 456 | * Set content of review screen - 0 = report summary | 1 = exam 457 | * @param reviewMode {number} - the new mode 458 | */ 459 | setReviewMode = reviewMode => this.setState({ reviewMode }) 460 | 461 | /** 462 | * Set type of review and first question of that type - 0 = all | 1 = incorrect | 2 = incomplete 463 | * @param reviewType {number} - the new type 464 | */ 465 | setReviewType = reviewType => { 466 | const { 467 | report: { incomplete, incorrect } 468 | } = this.state 469 | if (reviewType === 0) { 470 | this.setState({ reviewType, reviewQuestion: 0 }) 471 | } else if (reviewType === 1) { 472 | if (!incorrect.length) { 473 | return 474 | } 475 | this.setState({ reviewType, reviewQuestion: incorrect[0] }) 476 | } else if (reviewType === 2) { 477 | if (!incomplete.length) { 478 | return 479 | } 480 | this.setState({ reviewType, reviewQuestion: incomplete[0] }) 481 | } 482 | } 483 | 484 | /** 485 | * Sets the question index 486 | * @param reviewQuestion {integer} - index to set review question to 487 | * @param source {string|number} - 'grid' = direct review question click | 0 = skip to start | 1 = prev | 2 = next | 3 = skip to end 488 | */ 489 | setReviewQuestion = (reviewQuestion, source) => { 490 | const { 491 | report: { incorrect, incomplete, testLength }, 492 | reviewType 493 | } = this.state 494 | // direct question click 495 | if (source === 'grid') { 496 | return this.setState({ reviewQuestion }) 497 | } 498 | if (reviewQuestion < 0 || reviewQuestion > testLength - 1) { 499 | return 500 | } 501 | // parse question from subset 502 | // all questions mode 503 | if (reviewType === 0) { 504 | this.setState({ reviewQuestion }) 505 | } else if (reviewType === 1) { 506 | let newQuestion = questionHelper(source, incorrect, reviewQuestion) 507 | if (!newQuestion) { 508 | return 509 | } 510 | this.setState({ reviewQuestion: newQuestion }) 511 | } else if (reviewType === 2) { 512 | let newQuestion = questionHelper(source, incomplete, reviewQuestion) 513 | if (!newQuestion) { 514 | return 515 | } 516 | this.setState({ reviewQuestion: newQuestion }) 517 | } 518 | } 519 | 520 | /** 521 | * Notes feature to rewrite the explanation for a question 522 | * @param explanation {object[]} - array of nodes 523 | */ 524 | setExamExplanation = async explanation => { 525 | const { exam, reviewQuestion } = this.state 526 | exam.test[reviewQuestion].explanation = explanation 527 | await writeData('exam', exam, exam.filename) 528 | await this.setExams() 529 | await this.setState({ exam }) 530 | } 531 | 532 | render() { 533 | const { loading, ...rest } = this.state 534 | if (loading) { 535 | return 536 | } 537 | return ( 538 | 560 | 576 | 577 | ) 578 | } 579 | } 580 | -------------------------------------------------------------------------------- /src/renderer/components/Confirm.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import Modal from './Modal' 3 | import { RED_LOGO_PATH } from '../utils/filepaths' 4 | import { InnerModal } from '../styles/InnerModal' 5 | 6 | const Confirm = ({ show, title, message, buttons, onConfirm, onClose }) => ( 7 | 8 | 9 |
10 | 11 | {title} 12 |
13 |
{message}
14 |
15 |
16 | {buttons[0]} 17 |
18 | {buttons.length === 2 ? ( 19 |
20 | {buttons[1]} 21 |
22 | ) : null} 23 |
24 |
25 |
26 | ) 27 | 28 | Confirm.defaultProps = { 29 | title: '', 30 | message: '', 31 | buttons: ['Okay', 'Cancel'] 32 | } 33 | 34 | export default Confirm 35 | -------------------------------------------------------------------------------- /src/renderer/components/Content/AddRemoteExam/SearchItem.js: -------------------------------------------------------------------------------- 1 | import React, { useState } from 'react' 2 | import { ChevronDown } from 'styled-icons/boxicons-regular/ChevronDown' 3 | import { ChevronUp } from 'styled-icons/boxicons-regular/ChevronUp' 4 | import { FileDownload } from 'styled-icons/material/FileDownload' 5 | import { BLUE_LOGO_PATH } from '../../../utils/filepaths' 6 | import formatCreatedAt from '../../../utils/formatCreatedAt' 7 | import { ExamItemStyles } from '../Main/ExamItem' 8 | 9 | export default ({ exam, onClick }) => { 10 | const [expand, setExpand] = useState(false) 11 | 12 | const toggleExpand = e => { 13 | e.stopPropagation() 14 | setExpand(!expand) 15 | } 16 | 17 | return ( 18 | 19 |
20 | {exam.title} 21 |
{exam.title}
22 |
{exam.code ? `Code: ${exam.code}` : ''}
23 |
Created {formatCreatedAt(exam.createdAt)} ago
24 |
25 | 26 |
27 |
28 | {expand ? : } 29 |
30 |
31 |
32 |
{exam.description}
33 |
34 |
Time: {exam.time} Min
35 |
Passing: {exam.pass}%
36 |
37 |
38 |
39 | 40 | ) 41 | } 42 | -------------------------------------------------------------------------------- /src/renderer/components/Content/AddRemoteExam/index.js: -------------------------------------------------------------------------------- 1 | import React, { useState, useRef } from 'react' 2 | import styled from 'styled-components' 3 | import axios from 'axios' 4 | import { FillInStyles } from '../Exam/FillIn' 5 | import { NoDataStyles } from '../Main/NoData' 6 | import SearchItem from './SearchItem' 7 | import Loading from '../../Loading' 8 | 9 | const NoResultsStyles = styled(NoDataStyles)` 10 | margin-top: 0; 11 | ` 12 | 13 | const AddRemoteExamStyles = styled.div` 14 | width: 100%; 15 | height: calc(100vh - 13rem); 16 | overflow-x: hidden; 17 | overflow-y: auto; 18 | display: grid; 19 | grid-template-rows: 15rem 1fr; 20 | .search { 21 | justify-self: center; 22 | align-self: center; 23 | display: flex; 24 | align-items: center; 25 | .search-button { 26 | color: white; 27 | background: ${props => props.theme.secondary}; 28 | text-transform: uppercase; 29 | border-radius: ${props => props.theme.borderRadius}; 30 | font: 1rem 'Open Sans'; 31 | font-weight: 700; 32 | margin-left: 1rem; 33 | padding: 1.4rem 2rem; 34 | cursor: pointer; 35 | } 36 | } 37 | .no-results { 38 | display: grid; 39 | justify-items: center; 40 | } 41 | ` 42 | 43 | export default ({ loadRemoteExam }) => { 44 | const inputRef = useRef(null) 45 | const [focus, setFocus] = useState(false) 46 | const [value, setValue] = useState('') 47 | const [loading, setLoading] = useState(false) 48 | const [exams, setExams] = useState([]) 49 | 50 | const onChange = ({ target: { value } }) => setValue(value) 51 | 52 | const onKeyDown = ({ keyCode }) => { 53 | if (keyCode === 13) { 54 | this.onSearch() 55 | } 56 | } 57 | 58 | const onSearch = async () => { 59 | await setLoading(true) 60 | const res = await axios({ 61 | method: 'GET', 62 | url: 'https://exam-maker-backend.herokuapp.com/api/search', 63 | params: { 64 | term: value 65 | } 66 | }) 67 | const exams = res.data.exams 68 | if (exams && exams.length) { 69 | setExams(exams) 70 | setLoading(false) 71 | } else { 72 | setExams([]) 73 | setLoading(false) 74 | } 75 | } 76 | 77 | const onExamClick = async id => { 78 | const res = await axios({ 79 | method: 'GET', 80 | url: 'https://exam-maker-backend.herokuapp.com/api/download', 81 | params: { 82 | id 83 | } 84 | }) 85 | const { filename, exam } = res.data 86 | await loadRemoteExam(filename, exam) 87 | } 88 | 89 | return ( 90 | 91 |
92 | 93 | Enter Search Term 94 | setFocus(true)} 103 | onBlur={() => setFocus(false)} 104 | /> 105 | 106 |
107 | Search 108 |
109 |
110 | {loading ? ( 111 | 112 | ) : ( 113 |
114 | {exams.length ? ( 115 | exams.map((el, i) => ( 116 | onExamClick(el.id)} /> 117 | )) 118 | ) : ( 119 |
120 | No Results 121 |
122 | )} 123 |
124 | )} 125 |
126 | ) 127 | } 128 | -------------------------------------------------------------------------------- /src/renderer/components/Content/Cover.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import styled from 'styled-components' 3 | 4 | const CoverStyles = styled.div` 5 | width: 50vw; 6 | height: calc(100vh - 14rem); 7 | display: flex; 8 | flex-direction: column; 9 | align-items: center; 10 | justify-content: center; 11 | background: ${props => props.theme.grey[0]}; 12 | border: 1px solid ${props => props.theme.grey[2]}; 13 | ` 14 | 15 | const Image = styled.img` 16 | max-height: 40vh; 17 | margin-bottom: 0.5rem; 18 | border: 1px solid ${props => props.theme.grey[2]}; 19 | ` 20 | 21 | const NormalText = styled.div` 22 | font: 1.25rem 'Open Sans'; 23 | ` 24 | 25 | const BigText = styled.div` 26 | font: 3rem 'Open Sans'; 27 | font-weight: 700; 28 | margin-bottom: 0.5rem; 29 | color: ${props => props.theme.black}; 30 | ` 31 | 32 | export default React.memo(({ cover }) => ( 33 | 34 | {cover.map((el, i) => { 35 | if (el.variant === 0) { 36 | return ( 37 | 38 | 39 |
40 |
41 | ) 42 | } else if (el.variant === 1) { 43 | return {el.text} 44 | } else if (el.variant === 2) { 45 | return {el.text} 46 | } else { 47 | return null 48 | } 49 | })} 50 |
51 | )) 52 | -------------------------------------------------------------------------------- /src/renderer/components/Content/Exam/Explanation.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import styled from 'styled-components' 3 | import { lighten, darken } from 'polished' 4 | import isequal from 'lodash.isequal' 5 | import createExplanation from '../../../utils/createExplanation' 6 | 7 | const ExplanationStyles = styled.div` 8 | background: ${props => lighten(0.25, props.theme.quatro)}; 9 | border: 1px solid ${props => props.theme.grey[2]}; 10 | margin-top: 5rem; 11 | padding: 1rem; 12 | font: 1.4rem 'Open Sans'; 13 | .status { 14 | text-transform: uppercase; 15 | font-weight: 700; 16 | color: ${props => (props.status ? darken(0.1, props.theme.quatro) : props.theme.secondary)}; 17 | } 18 | .correct { 19 | font-weight: 700; 20 | } 21 | .explanation { 22 | font-weight: 700; 23 | margin-top: 1rem; 24 | } 25 | ` 26 | 27 | const Image = styled.img` 28 | max-width: 75vw; 29 | max-height: 60vh; 30 | margin-top: 1rem; 31 | margin-bottom: 1rem; 32 | border: 1px solid ${props => props.theme.grey[1]}; 33 | ` 34 | 35 | const NormalText = styled.div` 36 | font: 1.4rem 'Open Sans'; 37 | margin-bottom: 0.5rem; 38 | ` 39 | 40 | const BigText = styled.div` 41 | font: 3rem 'Open Sans'; 42 | font-weight: 700; 43 | margin-bottom: 0.5rem; 44 | color: ${props => props.theme.black}; 45 | ` 46 | 47 | export default ({ explanationRef, question, answers }) => { 48 | const variant = question.variant 49 | const correctAnswers = variant === 0 || variant === 1 ? question.answer : question.choices 50 | const status = variant === 0 || variant === 1 ? isequal(answers, question.answer) : answers[0] 51 | return ( 52 | 53 |
54 | Your answer is {status ? 'correct' : 'incorrect'} 55 |
56 |
57 | The correct answer is{' '} 58 | {createExplanation(variant, correctAnswers)} 59 |
60 |
61 |
Explanation
62 |
63 | {question.explanation.map((el, i) => { 64 | if (el.variant === 0) { 65 | return 66 | } else if (el.variant === 1) { 67 | return {el.text} 68 | } else if (el.variant === 2) { 69 | return {el.text} 70 | } else { 71 | return null 72 | } 73 | })} 74 |
75 |
76 |
77 | ) 78 | } 79 | -------------------------------------------------------------------------------- /src/renderer/components/Content/Exam/FillIn.js: -------------------------------------------------------------------------------- 1 | import React, { useState, useEffect, useRef } from 'react' 2 | import styled from 'styled-components' 3 | 4 | export const FillInStyles = styled.div` 5 | position: relative; 6 | width: 40rem; 7 | padding: 1rem; 8 | border: 2px solid ${props => (props.focus ? props.theme.grey[10] : props.theme.grey[3])}; 9 | border-radius: ${props => props.theme.borderRadius}; 10 | span { 11 | position: absolute; 12 | top: -1.2rem; 13 | left: 2rem; 14 | background: white; 15 | color: ${props => (props.focus ? props.theme.grey[10] : props.theme.grey[3])}; 16 | font: 1rem 'Open Sans'; 17 | font-weight: 600; 18 | padding: 0.25rem 0.35rem; 19 | } 20 | input { 21 | width: 40rem; 22 | outline: 0; 23 | border: 0; 24 | font: 1.4rem 'Open Sans'; 25 | } 26 | ` 27 | 28 | export default React.memo(({ review, fillIn, onFillIn }) => { 29 | const inputRef = useRef(null) 30 | const [focus, setFocus] = useState(false) 31 | const [value, setValue] = useState('') 32 | 33 | useEffect(() => { 34 | setValue(fillIn) 35 | }, []) 36 | 37 | useEffect(() => { 38 | if (review) { 39 | return 40 | } 41 | setTimeout(() => { 42 | inputRef.current.focus() 43 | setFocus(true) 44 | }, 500) 45 | }, []) 46 | 47 | const onChange = e => { 48 | if (review) { 49 | return 50 | } 51 | setValue(e.target.value) 52 | onFillIn(e.target.value) 53 | } 54 | 55 | return ( 56 | 57 | Fill In The Blank 58 | setFocus(true)} 65 | onBlur={() => setFocus(false)} 66 | readOnly={review} 67 | /> 68 | 69 | ) 70 | }) 71 | -------------------------------------------------------------------------------- /src/renderer/components/Content/Exam/ListOrder.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { DragDropContext } from 'react-dnd' 3 | import HTML5Backend from 'react-dnd-html5-backend' 4 | import ListOrderItem from './ListOrderItem' 5 | import randomizeArray from '../../../utils/randomizeArray' 6 | const update = require('immutability-helper') 7 | 8 | class ListOrder extends React.Component { 9 | state = { 10 | choices: null 11 | } 12 | 13 | componentDidMount() { 14 | const { choices, order } = this.props 15 | let newChoices 16 | if (order) { 17 | newChoices = order.map(o => choices[o]) 18 | } else { 19 | newChoices = randomizeArray(choices.slice(0)) 20 | } 21 | this.setState({ choices: newChoices }) 22 | } 23 | 24 | renderChoices = () => { 25 | const { choices } = this.state 26 | return ( 27 | choices && 28 | choices.map((c, i) => ( 29 | 37 | )) 38 | ) 39 | } 40 | 41 | moveAnswer = (dragIndex, hoverIndex) => { 42 | const { choices } = this.state 43 | const dragChoice = choices[dragIndex] 44 | this.setState( 45 | update(this.state, { 46 | choices: { 47 | $splice: [[dragIndex, 1], [hoverIndex, 0, dragChoice]] 48 | } 49 | }) 50 | ) 51 | } 52 | 53 | onDragEnd = () => { 54 | const { index, onListOrder } = this.props 55 | const order = this.state.choices.map(c => c.label) 56 | onListOrder(order, index) 57 | } 58 | 59 | render() { 60 | return
{this.renderChoices()}
61 | } 62 | } 63 | 64 | export default DragDropContext(HTML5Backend)(ListOrder) 65 | -------------------------------------------------------------------------------- /src/renderer/components/Content/Exam/ListOrderItem.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { findDOMNode } from 'react-dom' 3 | import { DragSource, DropTarget } from 'react-dnd' 4 | import { compose } from 'recompose' 5 | import { lighten } from 'polished' 6 | 7 | const ItemTypes = { 8 | DOC: 'drag-order-choice' 9 | } 10 | 11 | const choiceSource = { 12 | beginDrag(props) { 13 | return { 14 | id: props.label, 15 | index: props.index 16 | } 17 | }, 18 | endDrag(props, monitor) { 19 | if (!monitor.didDrop()) { 20 | return 21 | } 22 | props.onDragEnd() 23 | } 24 | } 25 | 26 | const choiceTarget = { 27 | hover(props, monitor, component) { 28 | if (!component) { 29 | return null 30 | } 31 | 32 | const dragIndex = monitor.getItem().index 33 | const hoverIndex = props.index 34 | 35 | if (dragIndex === hoverIndex) { 36 | return 37 | } 38 | 39 | const hoverBoundingRect = findDOMNode(component).getBoundingClientRect() 40 | const hoverMiddleY = (hoverBoundingRect.bottom - hoverBoundingRect.top) / 2 41 | const clientOffset = monitor.getClientOffset() 42 | const hoverClientY = clientOffset.y - hoverBoundingRect.top 43 | 44 | if (dragIndex < hoverIndex && hoverClientY < hoverMiddleY) { 45 | return 46 | } 47 | 48 | if (dragIndex > hoverIndex && hoverClientY > hoverMiddleY) { 49 | return 50 | } 51 | 52 | props.moveAnswer(dragIndex, hoverIndex) 53 | monitor.getItem().index = hoverIndex 54 | } 55 | } 56 | 57 | class ListItem extends React.Component { 58 | render() { 59 | const { text, isDragging, connectDragSource, connectDropTarget } = this.props 60 | return ( 61 | connectDragSource && 62 | connectDropTarget && 63 | connectDragSource( 64 | connectDropTarget( 65 |
66 |
lighten(0.2, props.theme.primary)}; 73 | color: ${props => props.theme.grey[10]}; 74 | border: 2px dashed ${props => props.theme.grey[5]}; 75 | font: 1.25rem 'Open Sans'; 76 | font-weight: 700; 77 | cursor: move; 78 | `} 79 | > 80 | {text} 81 |
82 |
83 | ) 84 | ) 85 | ) 86 | } 87 | } 88 | 89 | function collectSource(connect, monitor) { 90 | return { 91 | connectDragSource: connect.dragSource(), 92 | isDragging: monitor.isDragging() 93 | } 94 | } 95 | 96 | function collectTarget(connect, monitor) { 97 | return { 98 | connectDropTarget: connect.dropTarget() 99 | } 100 | } 101 | 102 | export default compose( 103 | DragSource(ItemTypes.DOC, choiceSource, collectSource), 104 | DropTarget(ItemTypes.DOC, choiceTarget, collectTarget) 105 | )(ListItem) 106 | -------------------------------------------------------------------------------- /src/renderer/components/Content/Exam/MultipleAnswer.js: -------------------------------------------------------------------------------- 1 | import React, { useState, useEffect } from 'react' 2 | import { CheckBox } from 'styled-icons/material/CheckBox' 3 | import { CheckBoxOutlineBlank } from 'styled-icons/material/CheckBoxOutlineBlank' 4 | import { MultipleStyles } from './MultipleChoice' 5 | import formatAnswerLabel from '../../../utils/formatAnswerLabel' 6 | 7 | export default React.memo(({ review, question, answers, onMultipleAnswer }) => { 8 | const [values, setValues] = useState([]) 9 | 10 | useEffect(() => { 11 | const values = [] 12 | answers.forEach((el, i) => { 13 | if (!!el) { 14 | values.push(i) 15 | } 16 | setValues(values) 17 | }) 18 | }, []) 19 | 20 | const onClick = i => { 21 | if (review) { 22 | return 23 | } 24 | let newValues 25 | if (values.includes(i)) { 26 | newValues = values.filter(el => el !== i) 27 | } else { 28 | newValues = values.concat(i) 29 | } 30 | const newAnswers = answers.map((el, i) => newValues.includes(i)) 31 | setValues(newValues) 32 | onMultipleAnswer(newAnswers) 33 | } 34 | 35 | return ( 36 |
37 | {question.choices.map((el, i) => ( 38 | onClick(i)} 43 | > 44 | {values.includes(i) ? : } 45 |
46 |
{formatAnswerLabel(i)}.
47 |
{el.text}
48 |
49 |
50 | ))} 51 |
52 | ) 53 | }) 54 | -------------------------------------------------------------------------------- /src/renderer/components/Content/Exam/MultipleChoice.js: -------------------------------------------------------------------------------- 1 | import React, { useState, useEffect } from 'react' 2 | import styled from 'styled-components' 3 | import { darken } from 'polished' 4 | import { RadioButtonChecked } from 'styled-icons/material/RadioButtonChecked' 5 | import { RadioButtonUnchecked } from 'styled-icons/material/RadioButtonUnchecked' 6 | import formatAnswerLabel from '../../../utils/formatAnswerLabel' 7 | 8 | export const MultipleStyles = styled.div` 9 | display: grid; 10 | grid-template-columns: 3rem 1fr; 11 | margin-bottom: 0.5rem; 12 | cursor: pointer; 13 | svg { 14 | color: ${props => 15 | props.review && props.correct 16 | ? darken(0.3, props.theme.quatro) 17 | : props.review && !props.correct 18 | ? props.theme.grey[5] 19 | : props.theme.grey[10]}; 20 | margin-right: 0.5rem; 21 | } 22 | .text { 23 | display: flex; 24 | font: 1.4rem 'Open Sans'; 25 | color: ${props => 26 | props.review && props.correct 27 | ? darken(0.3, props.theme.quatro) 28 | : props.review && !props.correct 29 | ? props.theme.grey[5] 30 | : props.theme.black}; 31 | & > :first-child { 32 | font-weight: 600; 33 | margin-right: 0.5rem; 34 | } 35 | } 36 | ` 37 | 38 | export default React.memo(({ review, question, answers, onMultipleChoice }) => { 39 | const [value, setValue] = useState(null) 40 | 41 | // sets value when component mounts only 42 | useEffect(() => { 43 | answers.forEach((el, i) => { 44 | if (!!el) { 45 | setValue(i) 46 | } 47 | }) 48 | }, []) 49 | 50 | // sets value when user clicks choice 51 | const onClick = i => { 52 | if (review) { 53 | return 54 | } 55 | setValue(i) 56 | onMultipleChoice(i) 57 | } 58 | 59 | return ( 60 |
61 | {question.choices.map((el, i) => ( 62 | onClick(i)} 67 | > 68 | {value === i ? : } 69 |
70 |
{formatAnswerLabel(i)}.
71 |
{el.text}
72 |
73 |
74 | ))} 75 |
76 | ) 77 | }) 78 | -------------------------------------------------------------------------------- /src/renderer/components/Content/Exam/Question.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import styled from 'styled-components' 3 | 4 | const QuestionStyles = styled.div` 5 | & > :last-child { 6 | margin-bottom: 2rem; 7 | } 8 | ` 9 | 10 | const Image = styled.img` 11 | max-width: 75vw; 12 | max-height: 60vh; 13 | margin-top: 1rem; 14 | margin-bottom: 1rem; 15 | border: 1px solid ${props => props.theme.grey[1]}; 16 | ` 17 | 18 | const NormalText = styled.div` 19 | font: 1.4rem 'Open Sans'; 20 | margin-bottom: 0.5rem; 21 | ` 22 | 23 | const BigText = styled.div` 24 | font: 3rem 'Open Sans'; 25 | font-weight: 700; 26 | margin-bottom: 0.5rem; 27 | color: ${props => props.theme.black}; 28 | ` 29 | 30 | export default class Question extends React.Component { 31 | state = { 32 | time: 0 33 | } 34 | 35 | componentDidMount() { 36 | if (this.props.review) { 37 | return 38 | } 39 | this.initTimer() 40 | } 41 | 42 | shouldComponentUpdate(nextProps, nextState) { 43 | return false 44 | } 45 | 46 | componentDidUpdate(prevProps) { 47 | if (!prevProps.confirmPauseTimer && this.props.confirmPauseTimer) { 48 | clearInterval(this.timer) 49 | } 50 | if (prevProps.confirmPauseTimer && !this.props.confirmPauseTimer) { 51 | this.initTimer() 52 | } 53 | } 54 | 55 | componentWillUnmount() { 56 | if (this.props.review) { 57 | return 58 | } 59 | clearInterval(this.timer) 60 | let { 61 | props: { intervals, index }, 62 | state: { time } 63 | } = this 64 | intervals[index] += time 65 | this.props.setIntervals(intervals) 66 | } 67 | 68 | initTimer = () => { 69 | this.timer = setInterval(() => { 70 | const { time } = this.state 71 | this.setState({ time: time + 1 }) 72 | }, 1000) 73 | } 74 | 75 | render() { 76 | const { question } = this.props 77 | return ( 78 | 79 | {question.map((el, i) => { 80 | if (el.variant === 0) { 81 | return ( 82 | 83 | 84 |
85 |
86 | ) 87 | } else if (el.variant === 1) { 88 | return {el.text} 89 | } else if (el.variant === 2) { 90 | return {el.text} 91 | } else { 92 | return null 93 | } 94 | })} 95 |
96 | ) 97 | } 98 | } 99 | -------------------------------------------------------------------------------- /src/renderer/components/Content/Exam/TopDisplay.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import styled from 'styled-components' 3 | import { Bookmark } from 'styled-icons/material/Bookmark' 4 | import { BookmarkBorder } from 'styled-icons/material/BookmarkBorder' 5 | 6 | const TopDisplayStyles = styled.div` 7 | display: flex; 8 | align-items: center; 9 | justify-content: space-between; 10 | margin-bottom: 2rem; 11 | & > :first-child { 12 | display: flex; 13 | align-items: center; 14 | font: 2.5rem 'Open Sans'; 15 | font-weight: 700; 16 | color: ${props => props.theme.grey[10]}; 17 | .bookmarked { 18 | font: 1.1rem 'Open Sans'; 19 | font-weight: 700; 20 | color: ${props => props.theme.grey[10]}; 21 | margin-left: 0.75rem; 22 | margin-top: 0.75rem; 23 | } 24 | } 25 | & > :last-child { 26 | margin-right: 5rem; 27 | color: ${props => (props.bookmarked ? props.theme.tertiary : props.theme.grey[10])}; 28 | transition: 0.3s; 29 | cursor: pointer; 30 | &:hover { 31 | color: ${props => props.theme.tertiary}; 32 | } 33 | } 34 | ` 35 | 36 | export default React.memo(({ question, length, marked, examMode, onBookmarkQuestion }) => { 37 | const bookmarked = marked.includes(question) 38 | return ( 39 | 40 |
41 |
42 | Question {question + 1} of {length} 43 |
44 |
{examMode === 1 ? '[ Bookmarked Questions ]' : null}
45 |
46 | {bookmarked ? ( 47 | onBookmarkQuestion(question, false)} /> 48 | ) : ( 49 | onBookmarkQuestion(question, true)} /> 50 | )} 51 |
52 | ) 53 | }) 54 | -------------------------------------------------------------------------------- /src/renderer/components/Content/Exam/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import styled from 'styled-components' 3 | import { Slide } from '../../../styles/Slide' 4 | import TopDisplay from './TopDisplay' 5 | import Question from './Question' 6 | import MultipleChoice from './MultipleChoice' 7 | import MultipleAnswer from './MultipleAnswer' 8 | import FillIn from './FillIn' 9 | import ListOrder from './ListOrder' 10 | import Explanation from './Explanation' 11 | 12 | const TestStyles = styled.div` 13 | width: 100%; 14 | height: calc(100vh - 14rem); 15 | overflow-x: hidden; 16 | overflow-y: auto; 17 | ` 18 | 19 | export default ({ 20 | explanationRef, 21 | explanation, 22 | exam, 23 | examMode, 24 | question, 25 | answers, 26 | fillIns, 27 | orders, 28 | intervals, 29 | marked, 30 | confirmPauseTimer, 31 | onBookmarkQuestion, 32 | onMultipleChoice, 33 | onMultipleAnswer, 34 | onFillIn, 35 | onListOrder, 36 | setIntervals 37 | }) => { 38 | return ( 39 | 40 | 47 | {exam.test.map((el, i) => { 48 | if (i === question) { 49 | const { variant } = el 50 | return ( 51 | 52 | 60 | {variant === 0 ? ( 61 | 67 | ) : variant === 1 ? ( 68 | 74 | ) : variant === 2 ? ( 75 | 76 | ) : variant === 3 ? ( 77 | 84 | ) : null} 85 | {explanation ? ( 86 | 87 | 88 | 89 | ) : null} 90 | 91 | ) 92 | } 93 | })} 94 | 95 | ) 96 | } 97 | -------------------------------------------------------------------------------- /src/renderer/components/Content/Main/Bar.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import styled from 'styled-components' 3 | import propTypes from 'prop-types' 4 | 5 | const BarStyles = styled.div` 6 | display: flex; 7 | align-items: center; 8 | .bar { 9 | position: relative; 10 | width: 10rem; 11 | height: 1.25rem; 12 | background: ${props => props.theme.grey[4]}; 13 | margin-left: 1rem; 14 | margin-right: 1rem; 15 | .fill { 16 | position: absolute; 17 | top: 0; 18 | left: 0; 19 | width: ${props => props.value}%; 20 | height: 1.25rem; 21 | background: ${props => 22 | (props.type === 'score' && props.value >= props.threshold) || 23 | (props.type === 'time' && props.value < props.threshold) 24 | ? props.theme.quatro 25 | : props.theme.secondary}; 26 | } 27 | } 28 | & > :first-child, 29 | & > :last-child { 30 | font: 1.1rem 'Open Sans'; 31 | font-weight: 700; 32 | color: ${props => props.theme.black}; 33 | } 34 | ` 35 | 36 | const Bar = ({ type, value, threshold, label1, label2 }) => { 37 | return ( 38 | 39 |
{label1}
40 |
41 |
42 |
43 |
{type === 'score' ? `${value}%` : label2}
44 | 45 | ) 46 | } 47 | 48 | Bar.propTypes = { 49 | type: propTypes.oneOf(['score', 'time']).isRequired, 50 | value: propTypes.number.isRequired, 51 | threshold: propTypes.number.isRequired, 52 | label1: propTypes.string.isRequired, 53 | label2: propTypes.string 54 | } 55 | 56 | export default Bar 57 | -------------------------------------------------------------------------------- /src/renderer/components/Content/Main/ExamItem.js: -------------------------------------------------------------------------------- 1 | import React, { useState } from 'react' 2 | import styled from 'styled-components' 3 | import { Delete } from 'styled-icons/material/Delete' 4 | import { ChevronDown } from 'styled-icons/boxicons-regular/ChevronDown' 5 | import { ChevronUp } from 'styled-icons/boxicons-regular/ChevronUp' 6 | import { BLUE_LOGO_PATH } from '../../../utils/filepaths' 7 | import formatCreatedAt from '../../../utils/formatCreatedAt' 8 | 9 | export const ExamItemStyles = styled.div` 10 | min-width: 75rem; 11 | height: ${props => (props.expand ? '12rem' : '6rem')}; 12 | display: grid; 13 | grid-template-rows: ${props => (props.expand ? '1fr 1fr' : '1fr')}; 14 | border: 1px solid ${props => props.theme.grey[2]}; 15 | border-radius: ${props => props.theme.borderRadius}; 16 | cursor: pointer; 17 | margin-right: 2rem; 18 | margin-bottom: 2rem; 19 | user-select: none; 20 | &:hover { 21 | background: ${props => props.theme.grey[0]}; 22 | } 23 | .main { 24 | display: grid; 25 | grid-template-columns: 6rem 1fr 10rem 14rem 6rem 6rem; 26 | justify-items: center; 27 | align-items: center; 28 | .image { 29 | justify-self: center; 30 | width: 3rem; 31 | height: 3rem; 32 | cursor: pointer; 33 | } 34 | .title { 35 | justify-self: flex-start; 36 | width: 100%; 37 | overflow: hidden; 38 | text-overflow: hidden; 39 | font: 2rem 'Open Sans'; 40 | font-weight: 700; 41 | color: ${props => props.theme.grey[12]}; 42 | } 43 | .stat { 44 | justify-self: flex-start; 45 | font: 1.1rem 'Open Sans'; 46 | font-weight: 700; 47 | color: ${props => props.theme.black}; 48 | } 49 | .actions { 50 | display: grid; 51 | justify-items: center; 52 | align-items: center; 53 | width: 4rem; 54 | height: 100%; 55 | } 56 | .delete { 57 | transition: 0.3s; 58 | cursor: pointer; 59 | color: ${props => props.theme.grey[12]}; 60 | &:hover { 61 | color: ${props => props.theme.secondary}; 62 | } 63 | } 64 | .more { 65 | color: ${props => props.theme.grey[12]}; 66 | } 67 | } 68 | .extra { 69 | display: ${props => (props.expand ? 'grid' : 'none')}; 70 | grid-template-columns: 50rem 1fr 10rem 14rem 6rem 6rem; 71 | justify-items: center; 72 | align-items: center; 73 | transition: all 0.3s; 74 | .description { 75 | font: 1rem 'Open Sans'; 76 | font-weight: 600; 77 | text-align: justify; 78 | padding: 1rem; 79 | color: ${props => props.theme.grey[10]}; 80 | } 81 | .stat { 82 | justify-self: flex-start; 83 | font: 1.1rem 'Open Sans'; 84 | font-weight: 700; 85 | color: ${props => props.theme.black}; 86 | } 87 | } 88 | ` 89 | 90 | export default ({ exam, setIndexExam, initExam, setConfirmDeleteExam }) => { 91 | const [expand, setExpand] = useState(false) 92 | 93 | const toggleExpand = e => { 94 | e.stopPropagation() 95 | setExpand(!expand) 96 | } 97 | 98 | const onDeleteClick = e => { 99 | e.stopPropagation() 100 | setConfirmDeleteExam() 101 | setIndexExam() 102 | } 103 | 104 | return ( 105 | 106 |
107 | {exam.title} 108 |
{exam.title}
109 |
{exam.code ? `Code: ${exam.code}` : ''}
110 |
Created {formatCreatedAt(exam.createdAt)} ago
111 |
112 | 113 |
114 |
115 | {expand ? : } 116 |
117 |
118 |
119 |
{exam.description}
120 |
121 |
Time: {exam.time} Min
122 |
Passing: {exam.pass}%
123 |
124 |
125 |
126 | 127 | ) 128 | } 129 | -------------------------------------------------------------------------------- /src/renderer/components/Content/Main/Exams.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import styled from 'styled-components' 3 | import ExamItem from './ExamItem' 4 | 5 | const ExamStyles = styled.div` 6 | width: 100%; 7 | height: calc(100vh - 13rem); 8 | overflow-x: hidden; 9 | overflow-y: auto; 10 | ` 11 | 12 | export default ({ exams, setIndexExam, initExam, setConfirmDeleteExam }) => ( 13 | 14 | {exams.map((el, i) => ( 15 | initExam(i)} 20 | setIndexExam={() => setIndexExam(i)} 21 | setConfirmDeleteExam={setConfirmDeleteExam} 22 | /> 23 | ))} 24 | 25 | ) 26 | -------------------------------------------------------------------------------- /src/renderer/components/Content/Main/History.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import styled from 'styled-components' 3 | import createHistoryGroups from '../../../utils/createHistoryGroups' 4 | import HistoryGroup from './HistoryGroup' 5 | import NoData from './NoData' 6 | 7 | const HistoryStyles = styled.div` 8 | width: 100%; 9 | height: calc(100vh - 13rem); 10 | overflow-x: hidden; 11 | overflow-y: auto; 12 | ` 13 | 14 | export default ({ history, setIndexHistory, setConfirmReviewExam, setConfirmDeleteHistory }) => { 15 | const onOpenConfirmReview = i => { 16 | setIndexHistory(i) 17 | setConfirmReviewExam() 18 | } 19 | 20 | const onDeleteClick = (e, i) => { 21 | e.stopPropagation() 22 | setIndexHistory(i) 23 | setConfirmDeleteHistory() 24 | } 25 | 26 | if (history.length) { 27 | const [groupedByFilename, uniqueFilenames, averageScores, averageTimes] = createHistoryGroups( 28 | history 29 | ) 30 | return ( 31 | 32 | {uniqueFilenames.map((uf, i) => { 33 | const reports = groupedByFilename[uf] 34 | const averageTime = Math.round(averageTimes[i] / reports[0].time) 35 | return ( 36 | 44 | ) 45 | })} 46 | 47 | ) 48 | } else { 49 | return 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /src/renderer/components/Content/Main/HistoryGroup.js: -------------------------------------------------------------------------------- 1 | import React, { useState } from 'react' 2 | import styled from 'styled-components' 3 | import { ChevronDown } from 'styled-icons/boxicons-regular/ChevronDown' 4 | import { ChevronUp } from 'styled-icons/boxicons-regular/ChevronUp' 5 | import HistoryItem from './HistoryItem' 6 | import Bar from './Bar' 7 | import { BLUE_LOGO_PATH } from '../../../utils/filepaths' 8 | 9 | const HistoryGroupStyles = styled.div` 10 | width: calc(100% - 10px); 11 | overflow: hidden; 12 | border: 1px solid ${props => props.theme.grey[2]}; 13 | border-radius: ${props => props.theme.borderRadius}; 14 | margin-bottom: 3rem; 15 | transition: 0.3s; 16 | cursor: pointer; 17 | .group-data { 18 | height: 5rem; 19 | display: grid; 20 | grid-template-columns: 4rem 1fr 1fr 1fr 4rem; 21 | align-items: center; 22 | background: ${props => props.theme.grey[0]}; 23 | img { 24 | justify-self: center; 25 | width: 2rem; 26 | height: 2rem; 27 | } 28 | .title { 29 | width: 28rem; 30 | overflow: hidden; 31 | text-overflow: ellipsis; 32 | white-space: nowrap; 33 | font: 1.8rem 'Open Sans'; 34 | font-weight: 700; 35 | color: ${props => props.theme.grey[12]}; 36 | } 37 | .expand { 38 | width: 4rem; 39 | justify-self: center; 40 | color: ${props => props.theme.black}; 41 | cursor: pointer; 42 | } 43 | } 44 | .items { 45 | display: ${props => (props.expand ? 'block' : 'none')}; 46 | padding: 1rem; 47 | user-select: none; 48 | } 49 | ` 50 | 51 | export default ({ reports, averageScore, averageTime, onOpenConfirmReview, onDeleteClick }) => { 52 | const [expand, setExpand] = useState(false) 53 | return ( 54 | 55 |
setExpand(!expand)}> 56 | 57 |
58 | {reports[0].title} 59 |
60 | 66 | 73 | {expand ? ( 74 |
setExpand(false)}> 75 | 76 |
77 | ) : ( 78 |
setExpand(true)}> 79 | 80 |
81 | )} 82 |
83 |
84 | {reports.map((el, i) => ( 85 | 92 | ))} 93 |
94 |
95 | ) 96 | } 97 | -------------------------------------------------------------------------------- /src/renderer/components/Content/Main/HistoryItem.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import styled from 'styled-components' 3 | import { darken } from 'polished' 4 | import { Like } from 'styled-icons/boxicons-solid/Like' 5 | import { Dislike } from 'styled-icons/boxicons-solid/Dislike' 6 | import { Delete } from 'styled-icons/material/Delete' 7 | import { ArrowUp } from 'styled-icons/octicons/ArrowUp' 8 | import { ArrowDown } from 'styled-icons/octicons/ArrowDown' 9 | import formatDate from '../../../utils/formatDate' 10 | import Bar from './Bar' 11 | 12 | const HistoryItemStyles = styled.div` 13 | width: 100%; 14 | height: 5rem; 15 | display: grid; 16 | grid-template-columns: 8rem 4rem 6rem 25rem 25rem 1fr 4rem; 17 | align-items: center; 18 | justify-items: center; 19 | border: 1px solid ${props => props.theme.grey[2]}; 20 | border-radius: ${props => props.theme.borderRadius}; 21 | margin-bottom: 1rem; 22 | transition: 0.3s; 23 | cursor: pointer; 24 | &:hover { 25 | background: ${props => props.theme.grey[0]}; 26 | } 27 | .date { 28 | font: 1rem 'Open Sans'; 29 | font-weight: 700; 30 | color: ${props => props.theme.grey[10]}; 31 | } 32 | .status { 33 | text-transform: uppercase; 34 | font: 1.5rem 'Open Sans'; 35 | font-weight: 700; 36 | } 37 | .pass { 38 | color: ${props => darken(0.1, props.theme.quatro)}; 39 | } 40 | .fail { 41 | color: ${props => props.theme.secondary}; 42 | } 43 | .stats { 44 | display: flex; 45 | align-items: center; 46 | .stat { 47 | display: flex; 48 | align-items: center; 49 | margin-right: 1rem; 50 | svg { 51 | margin-right: 0.2rem; 52 | } 53 | & > :last-child { 54 | font: 1.1rem 'Open Sans'; 55 | font-weight: 700; 56 | color: ${props => props.theme.black}; 57 | } 58 | } 59 | } 60 | .delete { 61 | color: ${props => props.theme.black}; 62 | &:hover { 63 | color: ${props => props.theme.secondary}; 64 | } 65 | } 66 | ` 67 | 68 | export default ({ report, index, onOpenConfirmReview, onDeleteClick }) => { 69 | return ( 70 | onOpenConfirmReview(report.indexHistory)}> 71 |
{formatDate(report.date)}
72 | {report.status ? : } 73 | {report.status ? ( 74 |
pass
75 | ) : ( 76 |
fail
77 | )} 78 | 79 | 86 |
87 |
88 | 89 |
{report.correct.length}
90 |
91 |
92 | 93 |
{report.incorrect.length + report.incomplete.length}
94 |
95 |
96 |
onDeleteClick(e, index)}> 97 | 98 |
99 |
100 | ) 101 | } 102 | -------------------------------------------------------------------------------- /src/renderer/components/Content/Main/NoData.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import styled from 'styled-components' 3 | 4 | export const NoDataStyles = styled.div` 5 | height: 5rem; 6 | display: grid; 7 | justify-items: center; 8 | align-items: center; 9 | font: 2.5rem 'Open Sans'; 10 | font-weight: 700; 11 | color: ${props => props.theme.grey[10]}; 12 | background: ${props => props.theme.grey[0]}; 13 | border: 1px solid ${props => props.theme.grey[2]}; 14 | border-radius: ${props => props.theme.borderRadius}; 15 | padding: 0.5rem 2rem; 16 | margin-top: calc(50vh - 14rem); 17 | ` 18 | 19 | export default ({ label }) => {label} 20 | -------------------------------------------------------------------------------- /src/renderer/components/Content/Main/SessionItem.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import styled from 'styled-components' 3 | import { Delete } from 'styled-icons/material/Delete' 4 | import { BLUE_LOGO_PATH } from '../../../utils/filepaths' 5 | import formatDate from '../../../utils/formatDate' 6 | import formatTimer from '../../../utils/formatTimer' 7 | 8 | const SessionItemStyles = styled.div` 9 | min-width: 80rem; 10 | height: 6rem; 11 | display: grid; 12 | border: 1px solid ${props => props.theme.grey[2]}; 13 | border-radius: ${props => props.theme.borderRadius}; 14 | cursor: pointer; 15 | margin-bottom: 2rem; 16 | user-select: none; 17 | &:hover { 18 | background: ${props => props.theme.grey[0]}; 19 | } 20 | .main { 21 | display: grid; 22 | grid-template-columns: 6rem 1fr 8rem 12rem 12rem 4rem; 23 | justify-items: center; 24 | align-items: center; 25 | .image { 26 | width: 3rem; 27 | height: 3rem; 28 | } 29 | .title { 30 | justify-self: flex-start; 31 | font: 2rem 'Open Sans'; 32 | font-weight: 700; 33 | color: ${props => props.theme.grey[12]}; 34 | } 35 | .date { 36 | font: 1rem 'Open Sans'; 37 | font-weight: 700; 38 | color: ${props => props.theme.grey[10]}; 39 | } 40 | .stat { 41 | font: 1.1rem 'Open Sans'; 42 | font-weight: 700; 43 | color: ${props => props.theme.black}; 44 | } 45 | .delete { 46 | color: ${props => props.theme.black}; 47 | &:hover { 48 | color: ${props => props.theme.secondary}; 49 | } 50 | } 51 | } 52 | ` 53 | 54 | export default ({ session, setIndexSession, setConfirmStartSession, setConfirmDeleteSession }) => { 55 | const onSessionClick = () => { 56 | setIndexSession() 57 | setConfirmStartSession() 58 | } 59 | 60 | const onDeleteClick = e => { 61 | e.stopPropagation() 62 | setIndexSession() 63 | setConfirmDeleteSession() 64 | } 65 | return ( 66 | 67 |
68 | 69 |
{session.title}
70 |
{formatDate(session.date)}
71 |
Time Left: {formatTimer(session.time)}
72 |
73 | Completed: {session.completed}/{session.testLength} 74 |
75 |
76 | 77 |
78 |
79 |
80 | ) 81 | } 82 | -------------------------------------------------------------------------------- /src/renderer/components/Content/Main/Sessions.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import styled from 'styled-components' 3 | import SessionItem from './SessionItem' 4 | import NoData from './NoData' 5 | 6 | const SessionStyles = styled.div` 7 | width: 100%; 8 | height: calc(100vh - 13rem); 9 | overflow-x: hidden; 10 | overflow-y: auto; 11 | ` 12 | 13 | export default ({ sessions, setIndexSession, setConfirmStartSession, setConfirmDeleteSession }) => { 14 | if (sessions.length) { 15 | return ( 16 | 17 | {sessions.map((el, i) => ( 18 | setIndexSession(i)} 22 | setConfirmStartSession={setConfirmStartSession} 23 | setConfirmDeleteSession={setConfirmDeleteSession} 24 | /> 25 | ))} 26 | 27 | ) 28 | } else { 29 | return 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /src/renderer/components/Content/Options/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import styled from 'styled-components' 3 | 4 | const OptionsStyles = styled.div` 5 | width: 100%; 6 | height: calc(100vh - 14rem); 7 | overflow-x: hidden; 8 | overflow-y: auto; 9 | ` 10 | 11 | export default ({ options }) => 🚧 Under Construction 🚧 12 | -------------------------------------------------------------------------------- /src/renderer/components/Content/Review/Notes/Input.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import styled from 'styled-components' 3 | 4 | const InputStyles = styled.div` 5 | position: relative; 6 | width: ${props => (props.width ? `${props.width}px` : '200px')}; 7 | margin-bottom: 4rem; 8 | input, 9 | textarea { 10 | width: 100%; 11 | border: 0; 12 | outline: 0; 13 | font: 1.3rem 'Open Sans'; 14 | color: ${props => props.theme.black}; 15 | resize: none; 16 | overflow: hidden; 17 | padding: 0; 18 | padding-bottom: 0.1rem; 19 | margin: 0; 20 | } 21 | .underline { 22 | position: relative; 23 | height: 2px; 24 | background: ${props => props.theme.grey[5]}; 25 | } 26 | ` 27 | 28 | const Label = styled.span.attrs(props => ({ 29 | style: { 30 | top: props.focus || props.value ? '-2rem' : '-.3rem', 31 | font: props.focus || props.value ? '1.1rem "Open Sans"' : '1.5rem "Open Sans"', 32 | fontWeight: props.focus || props.value ? '600' : '400', 33 | color: props.focus ? props.theme.black : props.theme.grey[5] 34 | } 35 | }))` 36 | position: absolute; 37 | transition: all 0.2s; 38 | ` 39 | 40 | const Underline = styled.div.attrs(props => ({ 41 | style: { 42 | width: props.focus ? '100%' : '0%', 43 | height: '2px' 44 | } 45 | }))` 46 | position: absolute; 47 | background: ${props => props.theme.black}; 48 | transition: 0.1s; 49 | ` 50 | 51 | const Hint = styled.div.attrs(props => ({ 52 | style: { 53 | display: props.show ? 'block' : 'none', 54 | color: props.focus ? props.theme.black : props.theme.grey[5] 55 | } 56 | }))` 57 | position: absolute; 58 | bottom: -1.3rem; 59 | left: 0; 60 | font: 0.9rem 'Open Sans'; 61 | font-weight: 600; 62 | ` 63 | 64 | const Length = styled.div.attrs(props => ({ 65 | style: { 66 | display: props.show ? 'block' : 'none', 67 | color: props.focus ? props.theme.black : props.theme.grey[5] 68 | } 69 | }))` 70 | position: absolute; 71 | bottom: -1.3rem; 72 | right: 0; 73 | font: 0.9rem 'Open Sans'; 74 | font-weight: 600; 75 | ` 76 | 77 | export default class Input extends React.Component { 78 | state = { 79 | focus: false 80 | } 81 | 82 | componentDidMount() { 83 | this.text.addEventListener('input', this.resize) 84 | if (this.text.scrollHeight > 20) { 85 | this.resize() 86 | } 87 | } 88 | 89 | componentDidUpdate(prevProps) { 90 | if (!prevProps.value && this.props.value && this.text.scrollHeight > 20) { 91 | this.resize() 92 | } 93 | } 94 | 95 | componentWillUnmount() { 96 | this.text.removeEventListener('input', this.resize) 97 | } 98 | 99 | resize = () => { 100 | this.text.style.height = 'auto' 101 | this.text.style.height = 102 | this.text.scrollHeight > 20 103 | ? this.text.scrollHeight + 2 + 'px' 104 | : this.text.scrollHeight + 'px' 105 | } 106 | 107 | onFocus = () => this.setState({ focus: true }, () => this.text.focus()) 108 | 109 | onBlur = () => this.setState({ focus: false }) 110 | 111 | render() { 112 | const { 113 | props: { type, width, value, label, hint, inputProps, onChange }, 114 | state: { focus } 115 | } = this 116 | return ( 117 | 124 | 127 | {type === 'textarea' ? ( 128 |