├── .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 |  
6 |
7 |  
8 |
9 | 
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.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 | 
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 | 
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 | 
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 | 
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 | 
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 | 
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 | 
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 |

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

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 |
158 | )
159 | }
160 | }
161 |
--------------------------------------------------------------------------------
/src/renderer/components/Content/Review/Notes/NodeInput.js:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import styled from 'styled-components'
3 | import { Image } from 'styled-icons/material/Image'
4 | import { Title } from 'styled-icons/material/Title'
5 | import { Delete } from 'styled-icons/material/Delete'
6 | import Input from './Input'
7 |
8 | const NodeInputStyles = styled.div`
9 | display: grid;
10 | grid-template-columns: 9rem 1fr 4rem;
11 | .variants {
12 | width: 7.5rem;
13 | height: 2rem;
14 | display: flex;
15 | border: 1px solid ${props => props.theme.grey[2]};
16 | }
17 | & > :last-child {
18 | justify-self: center;
19 | margin-top: 0.5rem;
20 | color: ${props => props.theme.grey[10]};
21 | transition: 0.3s;
22 | cursor: pointer;
23 | &:hover {
24 | color: ${props => props.theme.secondary};
25 | }
26 | }
27 | `
28 |
29 | const Option = styled.div`
30 | width: 3rem;
31 | display: grid;
32 | justify-items: center;
33 | align-items: center;
34 | background: ${props => (props.highlight ? props.theme.primary : props.theme.grey[0])};
35 | color: ${props => props.theme.grey[10]};
36 | transition: 0.3s;
37 | cursor: pointer;
38 | &:hover {
39 | background: ${props => props.theme.primary};
40 | }
41 | svg {
42 | color: inherit;
43 | }
44 | `
45 |
46 | export default class NodeInput extends React.Component {
47 | state = {
48 | variant: 1,
49 | text: ''
50 | }
51 |
52 | componentDidMount() {
53 | this.setNodeState()
54 | }
55 |
56 | setNodeState = () => {
57 | const { variant, text, href } = this.props
58 | this.setState({ variant, text, href })
59 | }
60 |
61 | onChange = ({ target: { name, value } }) => {
62 | this.setState({ [name]: value })
63 | this.props.updateNodeText(this.props.index, value)
64 | }
65 |
66 | onClick = variant => {
67 | this.setState({ variant })
68 | this.props.updateNodeVariant(this.props.index, variant)
69 | }
70 |
71 | render() {
72 | const {
73 | props: { onDelete },
74 | state: { variant, text }
75 | } = this
76 | return (
77 |
78 |
79 |
82 |
85 |
88 |
89 |
97 |
98 |
99 | )
100 | }
101 | }
102 |
--------------------------------------------------------------------------------
/src/renderer/components/Content/Review/Notes/index.js:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import styled from 'styled-components'
3 | import { darken } from 'polished'
4 | import Modal from '../../../Modal'
5 | import { InnerModal } from '../../../../styles/InnerModal'
6 | import { RED_LOGO_PATH } from '../../../../utils/filepaths'
7 | import NodeInput from './NodeInput'
8 |
9 | const NotesStyles = styled(InnerModal)`
10 | width: 60vw;
11 | height: 40rem;
12 | overflow: hidden;
13 | .add-note {
14 | max-height: 30rem;
15 | overflow: auto;
16 | display: flex;
17 | flex-direction: column;
18 | justify-content: center;
19 | align-items: center;
20 | background: white;
21 | padding: 4rem 1rem;
22 | }
23 | .add {
24 | color: white;
25 | background: ${props => darken(0.2, props.theme.quatro)};
26 | &:hover {
27 | background: ${props => darken(0.3, props.theme.quatro)};
28 | }
29 | }
30 | `
31 |
32 | export default class Notes extends React.Component {
33 | state = {
34 | explanation: []
35 | }
36 |
37 | componentDidUpdate(prevProps) {
38 | if (!prevProps.show && this.props.show) {
39 | this.setExplanation()
40 | }
41 | }
42 |
43 | setExplanation = () => {
44 | const { test, reviewQuestion } = this.props
45 | this.setState({ explanation: test[reviewQuestion].explanation })
46 | }
47 |
48 | addNoteNode = () =>
49 | this.setState(({ explanation }) => ({
50 | explanation: explanation.concat({ variant: 1, text: '', href: '' })
51 | }))
52 |
53 | updateNodeText = (index, text) => {
54 | const { explanation } = this.state
55 | explanation[index].text = text
56 | }
57 |
58 | updateNodeVariant = (index, variant) => {
59 | const { explanation } = this.state
60 | explanation[index].variant = variant
61 | }
62 |
63 | deleteNode = index =>
64 | this.setState(({ explanation }) => ({
65 | explanation: explanation.filter((el, i) => i !== index)
66 | }))
67 |
68 | onSave = async () => {
69 | const { explanation } = this.state
70 | await this.props.setExamExplanation(explanation)
71 | this.props.onClose()
72 | }
73 |
74 | render() {
75 | const {
76 | props: { show, onClose },
77 | state: { explanation }
78 | } = this
79 | return (
80 |
81 |
82 |
83 |

84 |
Add Notes
85 |
86 |
87 | {explanation &&
88 | explanation.map((el, i) => (
89 | this.deleteNode(i)}
98 | />
99 | ))}
100 |
101 |
102 |
103 | Add Note Node
104 |
105 |
106 | Save Notes
107 |
108 |
109 | Cancel
110 |
111 |
112 |
113 |
114 | )
115 | }
116 | }
117 |
--------------------------------------------------------------------------------
/src/renderer/components/Content/Review/ReviewExam.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 '../Exam/Question'
6 | import MultipleChoice from '../Exam/MultipleChoice'
7 | import MultipleAnswer from '../Exam/MultipleAnswer'
8 | import FillIn from '../Exam/FillIn'
9 | import StaticList from './StaticList'
10 | import Explanation from '../Exam/Explanation'
11 |
12 | const ReviewExamStyles = styled.div`
13 | width: 100%;
14 | height: calc(100vh - 14rem);
15 | overflow-x: hidden;
16 | overflow-y: auto;
17 | `
18 |
19 | export default ({ exam, report, reviewQuestion, reviewType }) => (
20 |
21 |
22 | {exam.test.map((el, i) => {
23 | if (i === reviewQuestion) {
24 | const { variant } = el
25 | return (
26 |
27 |
28 | {variant === 0 ? (
29 |
30 | ) : variant === 1 ? (
31 |
32 | ) : variant === 2 ? (
33 |
34 | ) : variant === 3 ? (
35 |
36 | ) : null}
37 |
38 |
39 |
40 |
41 | )
42 | } else {
43 | return null
44 | }
45 | })}
46 |
47 | )
48 |
--------------------------------------------------------------------------------
/src/renderer/components/Content/Review/StaticList.js:
--------------------------------------------------------------------------------
1 | import React, { useEffect, useState } from 'react'
2 | import styled from 'styled-components'
3 | import { lighten } from 'polished'
4 |
5 | const ListItem = styled.div`
6 | width: 50%;
7 | padding: 1rem 1rem;
8 | margin-bottom: 1rem;
9 | background: ${props =>
10 | props.correct ? lighten(0.2, props.theme.primary) : lighten(0.4, props.theme.secondary)};
11 | color: ${props => props.theme.grey[10]};
12 | border: 2px dashed ${props => props.theme.grey[5]};
13 | font: 1.25rem 'Open Sans';
14 | font-weight: 700;
15 | `
16 |
17 | export default ({ choices, order }) => {
18 | const [list, setList] = useState([])
19 |
20 | useEffect(() => {
21 | const initialList = []
22 | for (let i = 0; i < choices.length; i++) {
23 | let { text } = order ? choices[order[i]] : choices[i]
24 | let correct = order ? order[i] === i : false
25 | initialList.push({ text, correct })
26 | }
27 | setList(initialList)
28 | }, [])
29 | return (
30 |
31 | {list.map((el, i) => (
32 |
33 | {el.text}
34 |
35 | ))}
36 |
37 | )
38 | }
39 |
--------------------------------------------------------------------------------
/src/renderer/components/Content/Review/Summary.js:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import styled from 'styled-components'
3 | import { darken } from 'polished'
4 | import formatDate from '../../../utils/formatDate'
5 | import formatTimer from '../../../utils/formatTimer'
6 | import analyzeReviewGridItem from '../../../utils/analyzeReviewGridItem'
7 |
8 | const SummaryStyles = styled.div`
9 | width: 100%;
10 | height: calc(100vh - 14rem);
11 | display: grid;
12 | grid-template-rows: 3rem 1.5fr 3rem 2fr;
13 | .title {
14 | justify-self: center;
15 | font: 2rem 'Open Sans';
16 | font-weight: 700;
17 | color: ${props => props.theme.black};
18 | transform: translateX(-3rem);
19 | }
20 | .summary {
21 | justify-self: center;
22 | display: grid;
23 | grid-template-columns: 1fr 1fr;
24 | .column {
25 | align-self: center;
26 | display: grid;
27 | grid-template-rows: repeat(4, 3rem);
28 | .row {
29 | display: grid;
30 | grid-template-columns: 10rem 10rem;
31 | align-items: center;
32 | & > :first-child {
33 | font: 1.2rem 'Open Sans';
34 | font-weight: 700;
35 | color: ${props => props.theme.grey[10]};
36 | }
37 | .image {
38 | width: 4rem;
39 | height: 4rem;
40 | }
41 | .data {
42 | font: 1.25rem 'Open Sans';
43 | font-weight: 700;
44 | color: ${props => props.theme.black};
45 | }
46 | .status {
47 | color: ${props => (props.status ? props.theme.quatro : props.theme.secondary)};
48 | }
49 | }
50 | }
51 | }
52 | `
53 |
54 | const Chart = styled.div`
55 | align-self: flex-end;
56 | justify-self: center;
57 | width: ${props => `calc(${props.items}rem + calc(${props.items * 0.2})rem)`};
58 | max-width: calc(100vw - 30rem);
59 | height: 20rem;
60 | overflow: auto;
61 | display: grid;
62 | grid-template-columns: ${props => `repeat(${props.items}, 1rem)`};
63 | grid-gap: 0.2rem;
64 | padding-right: 3rem;
65 | .row {
66 | align-self: flex-end;
67 | display: flex;
68 | flex-direction: column;
69 | align-items: center;
70 | .label {
71 | width: 1rem;
72 | height: 1.5rem;
73 | font: 0.7rem 'Open Sans';
74 | font-weight: 700;
75 | text-align: center;
76 | color: ${props => props.theme.grey[5]};
77 | margin-right: 0.2rem;
78 | }
79 | }
80 | `
81 |
82 | const ChartBar = styled.div`
83 | position: relative;
84 | width: 0.75rem;
85 | height: ${props => props.width}px;
86 | background: ${props => darken(0.2, props.background)};
87 | .tooltip {
88 | position: absolute;
89 | top: -2rem;
90 | left: 80%;
91 | z-index: 1;
92 | display: none;
93 | font: 1rem 'Open Sans';
94 | font-weight: 700;
95 | border: 1px solid ${props => props.theme.grey[5]};
96 | border-radius: ${props => props.theme.borderRadius};
97 | background: ${props => props.theme.grey[0]};
98 | color: ${props => props.theme.grey[5]};
99 | padding: 0.1rem 0.5rem;
100 | }
101 | &:hover {
102 | outline: 1px solid ${props => props.theme.grey[5]};
103 | }
104 | &:hover .tooltip {
105 | display: block;
106 | }
107 | `
108 |
109 | export default ({ report }) => (
110 |
111 | Statistics
112 |
113 |
114 |
115 |
Status
116 |
{report.status ? 'PASS' : 'FAIL'}
117 |
118 |
119 |
Passing
120 |
{report.pass} %
121 |
122 |
123 |
Time
124 |
{formatTimer(report.elapsed)}
125 |
126 |
127 |
Date
128 |
{formatDate(report.date)}
129 |
130 |
131 |
132 |
133 |
Score
134 |
{report.score} %
135 |
136 |
137 |
Correct
138 |
139 | {report.correct.length}/{report.testLength}
140 |
141 |
142 |
143 |
Incorrect
144 |
145 | {report.incorrect.length}/{report.testLength}
146 |
147 |
148 |
149 |
Incomplete
150 |
151 | {report.incomplete.length}/{report.testLength}
152 |
153 |
154 |
155 |
156 | Question Analysis
157 |
158 | {report.intervals.map((el, i) => {
159 | const max = Math.max(...report.intervals)
160 | const ratio = 150 / max
161 | return (
162 |
163 |
164 | {formatTimer(el)}
165 |
166 |
{i + 1}
167 |
168 | )
169 | })}
170 |
171 |
172 | )
173 |
--------------------------------------------------------------------------------
/src/renderer/components/Content/Review/TopDisplay.js:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import styled from 'styled-components'
3 |
4 | const TopDisplayStyles = styled.div`
5 | display: flex;
6 | align-items: center;
7 | margin-bottom: 1rem;
8 | & > :first-child {
9 | font: 2.5rem 'Open Sans';
10 | font-weight: 700;
11 | color: ${props => props.theme.grey[10]};
12 | }
13 | & > :last-child {
14 | font: 1.1rem 'Open Sans';
15 | font-weight: 700;
16 | color: ${props => props.theme.grey[10]};
17 | margin-left: 0.75rem;
18 | margin-top: 0.75rem;
19 | }
20 | `
21 |
22 | export default ({ exam, reviewQuestion, reviewType }) => (
23 |
24 |
25 | Question {reviewQuestion + 1} of {exam ? exam.test.length : ''}
26 |
27 |
28 | [ {reviewType === 0 ? 'All ' : reviewType === 1 ? 'Incorrect ' : 'Incomplete '}Questions ]
29 |
30 |
31 | )
32 |
--------------------------------------------------------------------------------
/src/renderer/components/Content/Review/index.js:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import Summary from './Summary'
3 | import ReviewExam from './ReviewExam'
4 |
5 | export default class Review extends React.Component {
6 | state = {}
7 |
8 | render() {
9 | const {
10 | props: { reviewMode, report, ...rest }
11 | } = this
12 | if (reviewMode === 0) {
13 | return
14 | } else {
15 | return
16 | }
17 | }
18 | }
19 |
--------------------------------------------------------------------------------
/src/renderer/components/Content/index.js:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import styled from 'styled-components'
3 | import Exams from './Main/Exams'
4 | import History from './Main/History'
5 | import Sessions from './Main/Sessions'
6 | import Cover from './Cover'
7 | import Exam from './Exam'
8 | import Review from './Review'
9 | import Options from './Options'
10 | import AddRemoteExam from './AddRemoteExam'
11 |
12 | const ContentStyles = styled.div`
13 | display: grid;
14 | justify-items: center;
15 | align-items: center;
16 | padding: 2rem;
17 | padding-right: ${props => (props.open ? '28rem' : '7rem')};
18 | transition: 0.3s;
19 | `
20 |
21 | export default class Content extends React.Component {
22 | renderContent = () => {
23 | const p = this.props
24 | if (p.mode === 0) {
25 | if (p.mainMode === 0) {
26 | return (
27 |
33 | )
34 | } else if (p.mainMode === 1) {
35 | return (
36 |
42 | )
43 | } else if (p.mainMode === 2) {
44 | return (
45 |
51 | )
52 | } else if (p.mainMode === 3) {
53 | return
54 | } else if (p.mainMode === 4) {
55 | return
56 | }
57 | } else if (p.mode === 1) {
58 | return
59 | } else if (p.mode === 2) {
60 | return (
61 |
80 | )
81 | } else if (p.mode === 3) {
82 | return (
83 |
90 | )
91 | } else {
92 | return null
93 | }
94 | }
95 |
96 | render() {
97 | const {
98 | props: { open }
99 | } = this
100 | return {this.renderContent()}
101 | }
102 | }
103 |
--------------------------------------------------------------------------------
/src/renderer/components/GlobalStyle.js:
--------------------------------------------------------------------------------
1 | import { createGlobalStyle } from 'styled-components'
2 | import theme from '../styles/theme'
3 |
4 | export default createGlobalStyle`
5 | @import url('https://fonts.googleapis.com/css?family=Open+Sans:400,600,700');
6 | html {
7 | font-size: ${theme.fontSize}
8 | }
9 | body {
10 | padding: 0;
11 | margin: 0;
12 | }
13 | ::-webkit-scrollbar {
14 | width: ${theme.scrollbar};
15 | height: ${theme.scrollbar};
16 | }
17 | ::-webkit-scrollbar-thumb {
18 | background: ${theme.grey[5]};
19 | }
20 | ::-webkit-scrollbar-track {
21 | background-color: transparent;
22 | }
23 | `
24 |
--------------------------------------------------------------------------------
/src/renderer/components/Loading.js:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import { Spinner2 as Spinner } from 'styled-icons/icomoon/Spinner2'
3 | import { LoadingStyles } from './LoadingMain'
4 |
5 | export default ({ size, height }) => (
6 |
7 |
8 |
9 | )
10 |
--------------------------------------------------------------------------------
/src/renderer/components/LoadingMain.js:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import styled, { keyframes } from 'styled-components'
3 | import { Spinner2 as Spinner } from 'styled-icons/icomoon/Spinner2'
4 | import { RED_LOGO_PATH } from '../utils/filepaths'
5 |
6 | export const rotate = keyframes`
7 | from {
8 | transform: rotate(0deg);
9 | }
10 | to {
11 | transform: rotate(360deg);
12 | }
13 | `
14 |
15 | export const LoadingStyles = styled.div`
16 | width: 100%;
17 | height: ${props => props.height}vh;
18 | display: flex;
19 | flex-direction: column;
20 | justify-content: center;
21 | align-items: center;
22 | background: ${props => (props.color === 'grey' ? props.theme.grey[0] : 'white')};
23 | .title {
24 | display: flex;
25 | justify-content: center;
26 | align-items: center;
27 | font: 6rem 'Open Sans';
28 | font-weight: 700;
29 | color: ${props => props.theme.black};
30 | margin-bottom: 5rem;
31 | img {
32 | width: 5rem;
33 | height: 5rem;
34 | }
35 | }
36 | svg {
37 | color: ${props => props.theme.secondary};
38 | animation: ${rotate} 1s infinite;
39 | }
40 | `
41 |
42 | export default ({ size, height }) => (
43 |
44 |
45 |
Ex
46 |

47 |
m Simul
48 |

49 |
tor
50 |
51 |
52 |
53 | )
54 |
--------------------------------------------------------------------------------
/src/renderer/components/Modal.js:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import styled, { keyframes } from 'styled-components'
3 |
4 | const grow = keyframes`
5 | from {
6 | transform: scale(.25) translate(-50%, -50%);
7 | }
8 | to {
9 | transform: scale(1) translate(-50%, -50%);
10 | }
11 | `
12 |
13 | const ModalWindow = styled.div`
14 | display: ${props => (props.show ? 'block' : 'none')};
15 | position: fixed;
16 | top: 0;
17 | left: 0;
18 | z-index: 3;
19 | width: 100%;
20 | height: 100%;
21 | background: ${props =>
22 | props.color === 'light'
23 | ? 'rgba(255, 255, 255, 0.5)'
24 | : props.color === 'dark'
25 | ? 'rgba(0, 0, 0, 0.5)'
26 | : 'transparent'};
27 | `
28 |
29 | const ModalMain = styled.div`
30 | position: fixed;
31 | max-width: 100%;
32 | height: auto;
33 | top: 50%;
34 | left: 50%;
35 | z-index: 4;
36 | transform: translate(-50%, -50%);
37 | transform-origin: left center;
38 | animation: ${grow} 200ms ease;
39 | `
40 |
41 | export default class Modal extends React.Component {
42 | modal = React.createRef()
43 |
44 | componentDidUpdate(prevProps) {
45 | if (!prevProps.show && this.props.show) {
46 | document.body.addEventListener('click', this.onClickAway)
47 | }
48 | }
49 |
50 | componentWillUnmount() {
51 | document.body.removeEventListener('click', this.onClickAway)
52 | }
53 |
54 | onClickAway = e => {
55 | if (e.target === this.modal.current) {
56 | this.props.onClose()
57 | }
58 | }
59 |
60 | render() {
61 | const {
62 | props: { children, show, color }
63 | } = this
64 | return (
65 |
66 | {children}
67 |
68 | )
69 | }
70 | }
71 |
--------------------------------------------------------------------------------
/src/renderer/components/Navigation/Drawer/Grid.js:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import styled from 'styled-components'
3 | import { lighten } from 'polished'
4 | import analyzeGridItem from '../../../utils/analyzeGridItem'
5 |
6 | export const GridStyles = styled.div`
7 | height: calc(100vh - 35rem);
8 | border-top: 1px solid ${props => props.theme.grey[2]};
9 | border-bottom: 1px solid ${props => props.theme.grey[2]};
10 | .legend {
11 | height: 3rem;
12 | display: flex;
13 | justify-content: center;
14 | align-items: center;
15 | .item {
16 | display: flex;
17 | align-items: center;
18 | margin-right: 1rem;
19 | & > :first-child {
20 | width: 1rem;
21 | height: 1rem;
22 | margin-right: 0.25rem;
23 | border: 0.5px solid ${props => props.theme.grey[2]};
24 | }
25 | & > :last-child {
26 | font: 0.9rem 'Open Sans';
27 | font-weight: 600;
28 | }
29 | }
30 | .complete,
31 | .correct {
32 | background: ${props => lighten(0.1, props.theme.primary)};
33 | }
34 | .bookmarked {
35 | background: ${props => lighten(0.25, props.theme.tertiary)};
36 | }
37 | .incorrect {
38 | background: ${props => lighten(0.25, props.theme.secondary)};
39 | }
40 | .incomplete {
41 | background: ${props => props.theme.grey[2]};
42 | }
43 | }
44 | .grid {
45 | height: calc(100vh - 40rem);
46 | display: flex;
47 | flex-wrap: wrap;
48 | align-content: flex-start;
49 | padding: 1rem;
50 | overflow-y: auto;
51 | }
52 | `
53 |
54 | export const GridItem = styled.div`
55 | width: 4.5rem;
56 | height: 4.5rem;
57 | display: grid;
58 | justify-items: center;
59 | align-items: center;
60 | margin-right: 0.5rem;
61 | margin-bottom: 0.5rem;
62 | background: ${props => props.background};
63 | color: #333;
64 | border: 1px solid ${props => props.theme.grey[3]};
65 | font: 1rem 'Open Sans';
66 | font-weight: 700;
67 | outline: 3px solid ${props => (props.selected ? props.theme.grey[10] : 'transparent')};
68 | cursor: pointer;
69 | `
70 |
71 | export default ({ open, length, question, answers, fillIns, orders, marked, setQuestion }) => {
72 | if (open) {
73 | return (
74 |
75 |
76 |
80 |
81 |
82 |
Bookmarked
83 |
84 |
85 |
86 |
Incomplete
87 |
88 |
89 |
90 | {Array(length)
91 | .fill(null)
92 | .map((el, i) => {
93 | const background = analyzeGridItem(i, answers, fillIns, orders, marked)
94 | return (
95 | setQuestion(i, 'grid')}
101 | >
102 | {i + 1}
103 |
104 | )
105 | })}
106 |
107 |
108 | )
109 | } else {
110 | return null
111 | }
112 | }
113 |
--------------------------------------------------------------------------------
/src/renderer/components/Navigation/Drawer/ReviewGrid.js:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import styled from 'styled-components'
3 | import { GridStyles, GridItem } from './Grid'
4 | import analyzeReviewGridItem from '../../../utils/analyzeReviewGridItem'
5 |
6 | const ReviewGridStyles = styled(GridStyles)``
7 |
8 | export default ({
9 | open,
10 | report,
11 | reviewMode,
12 | reviewQuestion,
13 | setReviewMode,
14 | setReviewType,
15 | setReviewQuestion
16 | }) => {
17 | if (open) {
18 | return (
19 |
20 |
21 |
25 |
29 |
30 |
31 |
Incomplete
32 |
33 |
34 |
35 | {Array(report.testLength)
36 | .fill(null)
37 | .map((el, i) => {
38 | const background = analyzeReviewGridItem(i, report)
39 | return (
40 | {
45 | setReviewMode(1)
46 | setReviewType(0)
47 | setReviewQuestion(i, 'grid')
48 | }}
49 | >
50 | {i + 1}
51 |
52 | )
53 | })}
54 |
55 |
56 | )
57 | } else {
58 | return null
59 | }
60 | }
61 |
--------------------------------------------------------------------------------
/src/renderer/components/Navigation/Drawer/Stats.js:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import styled from 'styled-components'
3 |
4 | const StatsStyles = styled.div`
5 | height: calc(100vh - 17rem);
6 | border-top: 1px solid ${props => props.theme.grey[2]};
7 | border-bottom: 1px solid ${props => props.theme.grey[2]};
8 | padding: 1rem;
9 | & > * {
10 | font: 1.25rem 'Open Sans';
11 | }
12 | `
13 |
14 | export default ({ open, exam }) =>
15 |
--------------------------------------------------------------------------------
/src/renderer/components/Navigation/Drawer/index.js:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import styled from 'styled-components'
3 | import { shell } from 'electron'
4 | import { lighten } from 'polished'
5 | import { Menu } from 'styled-icons/material/Menu'
6 | import { ChevronLeft } from 'styled-icons/material/ChevronLeft'
7 | import { PlaylistAdd } from 'styled-icons/material/PlaylistAdd'
8 | import { CloudDownload } from 'styled-icons/material/CloudDownload'
9 | import { Folder } from 'styled-icons/material/Folder'
10 | import { History } from 'styled-icons/material/History'
11 | import { Help } from 'styled-icons/material/Help'
12 | import { Info } from 'styled-icons/material/Info'
13 | import { ExitToApp } from 'styled-icons/material/ExitToApp'
14 | import { Save } from 'styled-icons/material/Save'
15 | import { BugReport } from 'styled-icons/material/BugReport'
16 | import { Settings } from 'styled-icons/material/Settings'
17 | import { PlayArrow } from 'styled-icons/material/PlayArrow'
18 | import { ArrowBack } from 'styled-icons/material/ArrowBack'
19 | import { FormatListNumbered } from 'styled-icons/material/FormatListNumbered'
20 | import { Bookmark } from 'styled-icons/material/Bookmark'
21 | import { Check } from 'styled-icons/material/Check'
22 | import { Pause } from 'styled-icons/material/Pause'
23 | import { Stop } from 'styled-icons/material/Stop'
24 | import { Close } from 'styled-icons/material/Close'
25 | import { QuestionMark } from 'styled-icons/boxicons-regular/QuestionMark'
26 | import { Report } from 'styled-icons/boxicons-solid/Report'
27 | import { Edit } from 'styled-icons/boxicons-solid/Edit'
28 | import showAboutDialog from '../../../utils/showAboutDialog'
29 | import Grid from './Grid'
30 | import Stats from './Stats'
31 | import ReviewGrid from './ReviewGrid'
32 |
33 | const DrawerStyles = styled.div`
34 | position: fixed;
35 | left: 0;
36 | z-index: 1;
37 | width: 24rem;
38 | height: 100%;
39 | transition: 0.3s;
40 | background: ${props => props.theme.grey[0]};
41 | `
42 |
43 | const Control = styled.div`
44 | width: ${props => (props.open ? '24em' : '5rem')};
45 | height: 5rem;
46 | display: flex;
47 | justify-content: ${props => (props.open ? 'flex-end' : 'center')};
48 | align-items: center;
49 | border: 1px solid ${props => props.theme.grey[1]};
50 | border-left: 0;
51 | border-top: 0;
52 | transition: 0.3s;
53 | cursor: pointer;
54 | svg {
55 | color: ${props => props.theme.black};
56 | }
57 | .chevron {
58 | margin-right: 1rem;
59 | }
60 | `
61 |
62 | const MainMenu = styled.div`
63 | height: calc(100vh - 5rem);
64 | display: flex;
65 | flex-direction: column;
66 | border-right: 1px solid ${props => props.theme.grey[1]};
67 | `
68 |
69 | const MenuItem = styled.div`
70 | height: 5rem;
71 | display: grid;
72 | grid-template-columns: 5rem 1fr;
73 | align-items: center;
74 | justify-items: center;
75 | color: ${props => props.theme.black};
76 | cursor: pointer;
77 | &:hover {
78 | background: ${props => lighten(0.1, props.theme.primary)};
79 | }
80 | & > :first-child {
81 | color: inherit;
82 | }
83 | & > :last-child {
84 | justify-self: flex-start;
85 | font: 1.5rem 'Open Sans';
86 | font-weight: 600;
87 | padding-left: 2rem;
88 | color: inherit;
89 | }
90 | `
91 |
92 | const Spacer0 = styled.div`
93 | height: calc(100vh - 55rem);
94 | border-top: 1px solid ${props => props.theme.grey[2]};
95 | border-bottom: 1px solid ${props => props.theme.grey[2]};
96 | `
97 |
98 | export default ({
99 | open,
100 | mode,
101 | exam,
102 | question,
103 | answers,
104 | fillIns,
105 | orders,
106 | marked,
107 | report,
108 | reviewMode,
109 | reviewQuestion,
110 | toggleOpen,
111 | setMode,
112 | setMainMode,
113 | setQuestion,
114 | pauseExam,
115 | loadLocalExam,
116 | onShowExplanation,
117 | setExamMode,
118 | setReviewMode,
119 | setReviewType,
120 | setReviewQuestion,
121 | setConfirmBeginExam,
122 | setConfirmEndExam,
123 | setConfirmSaveSession,
124 | setShowNotes
125 | }) => {
126 | // Main Menu show when mode === 0
127 | const menu0 = [
128 | {
129 | type: 'menu',
130 | text: 'Add Local Exam',
131 | icon: ,
132 | onClick: loadLocalExam
133 | },
134 | {
135 | type: 'menu',
136 | text: 'Add Remote Exam',
137 | icon: ,
138 | onClick: () => setMainMode(4)
139 | },
140 | { type: 'menu', text: 'Exams', icon: , onClick: () => setMainMode(0) },
141 | { type: 'menu', text: 'History', icon: , onClick: () => setMainMode(1) },
142 | { type: 'menu', text: 'Sessions', icon: , onClick: () => setMainMode(2) },
143 | { type: 'spacer0' },
144 | {
145 | type: 'menu',
146 | text: 'Exam Maker',
147 | icon: ,
148 | onClick: () => shell.openExternal('https://exam-maker.herokuapp.com/')
149 | },
150 | {
151 | type: 'menu',
152 | text: 'Documentation',
153 | icon: ,
154 | onClick: () => shell.openExternal('https://exam-simulator.gitbook.io/exam-simulator/')
155 | },
156 | {
157 | type: 'menu',
158 | text: 'About',
159 | icon: ,
160 | onClick: showAboutDialog
161 | },
162 | {
163 | type: 'menu',
164 | text: 'Report a Bug',
165 | icon: ,
166 | onClick: () =>
167 | shell.openExternal(
168 | 'https://github.com/exam-simulator/simulator/issues/new?assignees=&labels=&template=bug_report.md&title='
169 | )
170 | },
171 | {
172 | type: 'menu',
173 | text: 'Settings',
174 | icon: ,
175 | onClick: () => setMainMode(3)
176 | }
177 | ]
178 |
179 | // Cover Menu show when mode === 1
180 | const menu1 = [
181 | {
182 | type: 'menu',
183 | text: 'Start Exam',
184 | icon: ,
185 | onClick: setConfirmBeginExam
186 | },
187 | { type: 'stats' },
188 | {
189 | type: 'menu',
190 | text: 'Back to Main',
191 | icon: ,
192 | onClick: () => {
193 | setMode(0)
194 | setMainMode(0)
195 | }
196 | }
197 | ]
198 |
199 | // Exam Menu show when mode === 2
200 | const menu2 = [
201 | {
202 | type: 'menu',
203 | text: 'All Questions',
204 | icon: ,
205 | onClick: () => setExamMode(0)
206 | },
207 | {
208 | type: 'menu',
209 | text: 'Marked Questions',
210 | icon: ,
211 | onClick: () => setExamMode(1)
212 | },
213 | { type: 'menu', text: 'Show Answer', icon: , onClick: onShowExplanation },
214 | { type: 'exam-grid' },
215 | {
216 | type: 'menu',
217 | text: 'Save Session',
218 | icon: ,
219 | onClick: setConfirmSaveSession
220 | },
221 | { type: 'menu', text: 'Pause Exam', icon: , onClick: pauseExam },
222 | { type: 'menu', text: 'End Exam', icon: , onClick: setConfirmEndExam }
223 | ]
224 |
225 | // Review Menu show when mode === 3
226 | const menu3 = [
227 | {
228 | type: 'menu',
229 | text: 'All Questions',
230 | icon: ,
231 | onClick: () => {
232 | setReviewMode(1)
233 | setReviewType(0)
234 | }
235 | },
236 | {
237 | type: 'menu',
238 | text: 'Incorrect Answers',
239 | icon: ,
240 | onClick: () => {
241 | setReviewMode(1)
242 | setReviewType(1)
243 | }
244 | },
245 | {
246 | type: 'menu',
247 | text: 'Incomplete',
248 | icon: ,
249 | onClick: () => {
250 | setReviewMode(1)
251 | setReviewType(2)
252 | }
253 | },
254 | { type: 'review-grid' },
255 | {
256 | type: 'menu',
257 | text: 'Report Summary',
258 | icon: ,
259 | onClick: () => setReviewMode(0)
260 | },
261 | {
262 | type: 'menu',
263 | text: 'Add Notes',
264 | icon: ,
265 | onClick: setShowNotes
266 | },
267 | {
268 | type: 'menu',
269 | text: 'Back to Main',
270 | icon: ,
271 | onClick: () => {
272 | setMode(0)
273 | setMainMode(0)
274 | }
275 | }
276 | ]
277 |
278 | const menu =
279 | mode === 0 ? menu0 : mode === 1 ? menu1 : mode === 2 ? menu2 : mode === 3 ? menu3 : []
280 | return (
281 |
282 |
283 | {open ? : }
284 |
285 |
286 | {menu.map((el, i) => {
287 | if (el.type === 'menu') {
288 | return (
289 |
293 | )
294 | } else if (el.type === 'stats') {
295 | return
296 | } else if (el.type === 'exam-grid') {
297 | return (
298 |
309 | )
310 | } else if (el.type === 'review-grid') {
311 | return (
312 |
322 | )
323 | } else if (el.type === 'spacer0') {
324 | return
325 | }
326 | })}
327 |
328 |
329 | )
330 | }
331 |
--------------------------------------------------------------------------------
/src/renderer/components/Navigation/Footer/ExamFooter.js:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import styled from 'styled-components'
3 | import { lighten } from 'polished'
4 | import { Timer } from 'styled-icons/material/Timer'
5 | import { SkipPrevious } from 'styled-icons/material/SkipPrevious'
6 | import { KeyboardArrowRight } from 'styled-icons/material/KeyboardArrowRight'
7 | import { KeyboardArrowLeft } from 'styled-icons/material/KeyboardArrowLeft'
8 | import { Calculator } from 'styled-icons/icomoon/Calculator'
9 | import { SkipNext } from 'styled-icons/material/SkipNext'
10 | import { execFile } from 'child_process'
11 | import formatTimer from '../../../utils/formatTimer'
12 |
13 | const ExamFooter = styled.div`
14 | width: ${props => (props.open ? 'calc(100% - 24rem)' : 'calc(100% - 5rem)')};
15 | height: 100%;
16 | display: grid;
17 | grid-template-columns: 10rem 1fr 5rem;
18 | align-items: center;
19 | transition: 0.3s;
20 | .timer {
21 | display: flex;
22 | align-items: center;
23 | justify-content: center;
24 | color: ${props => (props.warning ? props.theme.secondary : props.theme.black)};
25 | svg {
26 | color: inherit;
27 | margin-right: 0.5rem;
28 | }
29 | & > :last-child {
30 | font: 2rem 'Open Sans';
31 | font-weight: 700;
32 | }
33 | }
34 | .arrows {
35 | justify-self: center;
36 | display: grid;
37 | grid-template-columns: repeat(4, 5rem);
38 | }
39 | .arrow {
40 | height: 5rem;
41 | display: grid;
42 | justify-items: center;
43 | align-items: center;
44 | transition: 0.3s;
45 | cursor: pointer;
46 | &:hover {
47 | background: ${props => lighten(0.1, props.theme.primary)};
48 | }
49 | svg {
50 | color: ${props => props.theme.black};
51 | }
52 | }
53 | `
54 |
55 | const calculator = () => {
56 | if (process.platform === 'win32') {
57 | execFile(`C:/Windows/System32/calc.exe`)
58 | }
59 | }
60 |
61 | export default ({
62 | open,
63 | time,
64 | onFirstQuestion,
65 | onPrevQuestion,
66 | onNextQuestion,
67 | onLastQuestion
68 | }) => (
69 |
70 |
71 |
72 |
{formatTimer(time)}
73 |
74 |
75 |
76 |
77 |
78 |
79 |
80 |
81 |
82 |
83 |
84 |
85 |
86 |
87 |
88 |
89 |
90 |
91 |
92 | )
93 |
--------------------------------------------------------------------------------
/src/renderer/components/Navigation/Footer/ReviewFooter.js:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import styled from 'styled-components'
3 | import { lighten } from 'polished'
4 | import { SkipPrevious } from 'styled-icons/material/SkipPrevious'
5 | import { KeyboardArrowRight } from 'styled-icons/material/KeyboardArrowRight'
6 | import { KeyboardArrowLeft } from 'styled-icons/material/KeyboardArrowLeft'
7 | import { SkipNext } from 'styled-icons/material/SkipNext'
8 |
9 | const ReviewFooterStyles = styled.div`
10 | width: ${props => (props.open ? 'calc(100% - 24rem)' : 'calc(100% - 5rem)')};
11 | height: 100%;
12 | display: grid;
13 | grid-template-columns: 1fr;
14 | align-items: center;
15 | transition: 0.3s;
16 | .arrows {
17 | justify-self: center;
18 | display: grid;
19 | grid-template-columns: repeat(4, 5rem);
20 | .arrow {
21 | height: 5rem;
22 | display: grid;
23 | justify-items: center;
24 | align-items: center;
25 | transition: 0.3s;
26 | cursor: pointer;
27 | &:hover {
28 | background: ${props => lighten(0.1, props.theme.primary)};
29 | }
30 | svg {
31 | color: ${props => props.theme.black};
32 | }
33 | }
34 | }
35 | `
36 |
37 | export default ({ open, onFirstQuestion, onPrevQuestion, onNextQuestion, onLastQuestion }) => (
38 |
39 |
40 |
41 |
42 |
43 |
44 |
45 |
46 |
47 |
48 |
49 |
50 |
51 |
52 |
53 |
54 | )
55 |
--------------------------------------------------------------------------------
/src/renderer/components/Navigation/Footer/index.js:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import styled from 'styled-components'
3 | import ExamFooter from './ExamFooter'
4 | import ReviewFooter from './ReviewFooter'
5 |
6 | const FooterStyles = styled.div`
7 | position: fixed;
8 | width: 100%;
9 | height: 5rem;
10 | bottom: 0;
11 | left: ${props => (props.open ? '24rem' : '5rem')};
12 | z-index: 2;
13 | transition: 0.3s;
14 | background: ${props => props.theme.grey[0]};
15 | border-top: 1px solid ${props => props.theme.grey[1]};
16 | `
17 |
18 | export default ({
19 | open,
20 | mode,
21 | exam,
22 | question,
23 | reviewQuestion,
24 | time,
25 | setQuestion,
26 | setReviewQuestion
27 | }) => (
28 |
29 | {mode === 2 ? (
30 | setQuestion(0, 0)}
34 | onPrevQuestion={() => setQuestion(question - 1, 1)}
35 | onNextQuestion={() => setQuestion(question + 1, 2)}
36 | onLastQuestion={() => setQuestion(exam.test.length - 1, 3)}
37 | />
38 | ) : mode === 3 ? (
39 | setReviewQuestion(0, 0)}
42 | onPrevQuestion={() => setReviewQuestion(reviewQuestion - 1, 1)}
43 | onNextQuestion={() => setReviewQuestion(reviewQuestion + 1, 2)}
44 | onLastQuestion={() => setReviewQuestion(exam.test.length - 1, 3)}
45 | />
46 | ) : null}
47 |
48 | )
49 |
--------------------------------------------------------------------------------
/src/renderer/components/Navigation/Header/ExamHeader.js:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import styled from 'styled-components'
3 | import { BLUE_LOGO_PATH } from '../../../utils/filepaths'
4 | import { InnerHeader } from './MainHeader'
5 |
6 | const ExamHeader = styled(InnerHeader)`
7 | grid-template-columns: 6rem 1fr;
8 | `
9 |
10 | export default ({ exam }) => {
11 | return (
12 |
13 |
14 | {exam.title}
15 |
16 | )
17 | }
18 |
--------------------------------------------------------------------------------
/src/renderer/components/Navigation/Header/MainHeader.js:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import styled from 'styled-components'
3 |
4 | export const InnerHeader = styled.div`
5 | height: 5rem;
6 | display: grid;
7 | align-items: center;
8 | .title {
9 | font: 2rem 'Open Sans';
10 | font-weight: 700;
11 | color: ${props => props.theme.black};
12 | margin-left: 1rem;
13 | }
14 | .image {
15 | justify-self: center;
16 | width: 3rem;
17 | height: 3rem;
18 | }
19 | .subtitle {
20 | font: 1.1rem 'Open Sans';
21 | font-weight: 700;
22 | color: ${props => props.theme.grey[10]};
23 | margin-top: 0.5rem;
24 | margin-left: 1rem;
25 | }
26 | `
27 |
28 | export default ({ mainMode }) => {
29 | const title =
30 | mainMode === 0
31 | ? 'Exams'
32 | : mainMode === 1
33 | ? 'History'
34 | : mainMode === 2
35 | ? 'Sessions'
36 | : mainMode === 3
37 | ? 'Settings'
38 | : 'Add Remote Exam'
39 | return (
40 |
41 | {title}
42 |
43 | )
44 | }
45 |
--------------------------------------------------------------------------------
/src/renderer/components/Navigation/Header/ReviewHeader.js:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import styled from 'styled-components'
3 | import { BLUE_LOGO_PATH } from '../../../utils/filepaths'
4 | import { InnerHeader } from './MainHeader'
5 |
6 | const ReviewHeader = styled(InnerHeader)`
7 | grid-template-columns: 6rem auto 1fr;
8 | `
9 |
10 | export default ({ exam, reviewMode }) => {
11 | const title = reviewMode === 0 ? 'Summary' : 'Review'
12 | return (
13 |
14 |
15 | {exam.title}
16 | {title}
17 |
18 | )
19 | }
20 |
--------------------------------------------------------------------------------
/src/renderer/components/Navigation/Header/index.js:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import styled from 'styled-components'
3 | import { BLUE_LOGO_PATH } from '../../../utils/filepaths'
4 | import MainHeader from './MainHeader'
5 | import ExamHeader from './ExamHeader'
6 | import ReviewHeader from './ReviewHeader'
7 |
8 | const HeaderStyles = styled.div`
9 | position: fixed;
10 | width: 100%;
11 | height: 5rem;
12 | top: 0;
13 | left: ${props => (props.open ? '24rem' : '5rem')};
14 | z-index: 2;
15 | transition: 0.3s;
16 | background: ${props => props.theme.primary};
17 | `
18 |
19 | export default ({ open, mode, mainMode, exam, reviewMode }) => (
20 |
21 | {mode === 0 ? (
22 |
23 | ) : mode === 2 ? (
24 |
25 | ) : mode === 3 ? (
26 |
27 | ) : null}
28 |
29 | )
30 |
--------------------------------------------------------------------------------
/src/renderer/components/Navigation/index.js:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import Header from './Header'
3 | import Drawer from './Drawer'
4 | import Footer from './Footer'
5 | import Confirm from '../Confirm'
6 | import Notes from '../Content/Review/Notes'
7 | import { Main } from '../../styles/Main'
8 |
9 | export default class Navigation extends React.Component {
10 | state = {
11 | open: true,
12 | confirmBeginExam: false,
13 | confirmEndExam: false,
14 | confirmTimeExpired: false,
15 | confirmReviewExam: false,
16 | confirmSaveSession: false,
17 | confirmStartSession: false,
18 | confirmPauseTimer: false,
19 | confirmDeleteExam: false,
20 | confirmDeleteHistory: false,
21 | confirmDeleteSession: false,
22 | showNotes: false
23 | }
24 |
25 | componentDidUpdate(prevProps) {
26 | if (prevProps.time === 1 && this.props.time === 0) {
27 | this.setConfirmTimeExpired(true)
28 | }
29 | }
30 |
31 | toggleOpen = () => this.setState(({ open }) => ({ open: !open }))
32 |
33 | setConfirmBeginExam = confirmBeginExam => this.setState({ confirmBeginExam })
34 |
35 | setConfirmEndExam = confirmEndExam => this.setState({ confirmEndExam })
36 |
37 | setConfirmTimeExpired = confirmTimeExpired => this.setState({ confirmTimeExpired })
38 |
39 | setConfirmReviewExam = confirmReviewExam => this.setState({ confirmReviewExam })
40 |
41 | setConfirmSaveSession = confirmSaveSession => this.setState({ confirmSaveSession })
42 |
43 | setConfirmStartSession = confirmStartSession => this.setState({ confirmStartSession })
44 |
45 | setConfirmDeleteExam = confirmDeleteExam => this.setState({ confirmDeleteExam })
46 |
47 | setConfirmDeleteHistory = confirmDeleteHistory => this.setState({ confirmDeleteHistory })
48 |
49 | setConfirmDeleteSession = confirmDeleteSession => this.setState({ confirmDeleteSession })
50 |
51 | setShowNotes = showNotes => {
52 | if (this.props.reviewMode === 1) {
53 | this.setState({ showNotes })
54 | }
55 | }
56 |
57 | startExam = () => {
58 | this.setConfirmBeginExam(false)
59 | this.props.initTimer()
60 | this.props.setMode(2)
61 | }
62 |
63 | endExam = () => {
64 | this.setConfirmEndExam(false)
65 | this.props.endExam()
66 | }
67 |
68 | endExamExpired = () => {
69 | this.setConfirmTimeExpired(false)
70 | this.props.endExam()
71 | }
72 |
73 | reviewExam = () => {
74 | this.setConfirmReviewExam(false)
75 | this.props.initReview()
76 | }
77 |
78 | saveSession = () => {
79 | this.setConfirmSaveSession(false)
80 | this.props.saveSession()
81 | }
82 |
83 | startSession = () => {
84 | this.setConfirmStartSession(false)
85 | this.props.initSession()
86 | }
87 |
88 | pauseExam = () => {
89 | this.setState({ confirmPauseTimer: true })
90 | this.props.pauseTimer()
91 | }
92 |
93 | unPauseExam = () => {
94 | this.setState({ confirmPauseTimer: false })
95 | this.props.initTimer()
96 | }
97 |
98 | deleteExam = () => {
99 | this.setState({ confirmDeleteExam: false })
100 | this.props.deleteExam()
101 | }
102 |
103 | deleteHistory = () => {
104 | this.setState({ confirmDeleteHistory: false })
105 | this.props.deleteHistory()
106 | }
107 |
108 | deleteSession = () => {
109 | this.setState({ confirmDeleteSession: false })
110 | this.props.deleteSession()
111 | }
112 |
113 | render() {
114 | const {
115 | props: { children, onShowExplanation, ...rest },
116 | state: {
117 | open,
118 | confirmBeginExam,
119 | confirmEndExam,
120 | confirmTimeExpired,
121 | confirmReviewExam,
122 | confirmSaveSession,
123 | confirmStartSession,
124 | confirmPauseTimer,
125 | confirmDeleteExam,
126 | confirmDeleteHistory,
127 | confirmDeleteSession,
128 | showNotes
129 | }
130 | } = this
131 | return (
132 | <>
133 |
134 | this.setConfirmBeginExam(true)}
140 | setConfirmEndExam={() => this.setConfirmEndExam(true)}
141 | setConfirmSaveSession={() => this.setConfirmSaveSession(true)}
142 | setShowNotes={() => this.setShowNotes(true)}
143 | pauseExam={this.pauseExam}
144 | />
145 |
146 | {React.Children.map(children, child =>
147 | React.cloneElement(child, {
148 | open,
149 | confirmPauseTimer,
150 | setConfirmReviewExam: () => this.setConfirmReviewExam(true),
151 | setConfirmDeleteExam: () => this.setConfirmDeleteExam(true),
152 | setConfirmDeleteHistory: () => this.setConfirmDeleteHistory(true),
153 | setConfirmStartSession: () => this.setConfirmStartSession(true),
154 | setConfirmDeleteSession: () => this.setConfirmDeleteSession(true)
155 | })
156 | )}
157 |
158 |
159 | this.setConfirmBeginExam(false)}
166 | />
167 | this.setConfirmEndExam(false)}
174 | />
175 |
182 | this.setConfirmReviewExam(false)}
189 | />
190 | this.setConfirmSaveSession(false)}
197 | />
198 | this.setConfirmStartSession(false)}
205 | />
206 | {}}
213 | />
214 | this.setConfirmDeleteExam(false)}
221 | />
222 | this.setConfirmDeleteHistory(false)}
229 | />
230 | this.setConfirmDeleteSession(false)}
237 | />
238 | this.setShowNotes(false)}
243 | setExamExplanation={this.props.setExamExplanation}
244 | />
245 | >
246 | )
247 | }
248 | }
249 |
--------------------------------------------------------------------------------
/src/renderer/index.js:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import ReactDOM from 'react-dom'
3 | import { ThemeProvider } from 'styled-components'
4 | import GlobalStyle from './components/GlobalStyle'
5 | import App from './App'
6 | import theme from './styles/theme'
7 |
8 | ReactDOM.render(
9 |
10 | <>
11 |
12 |
13 | >
14 | ,
15 | document.getElementById('app')
16 | )
17 |
--------------------------------------------------------------------------------
/src/renderer/styles/InnerModal.js:
--------------------------------------------------------------------------------
1 | import styled from 'styled-components'
2 | import { darken } from 'polished'
3 |
4 | export const InnerModal = styled.div`
5 | width: 30vw;
6 | height: 25vh;
7 | display: grid;
8 | grid-template-rows: 3rem 1fr 5rem;
9 | background: white;
10 | box-shadow: ${props => props.theme.shadows[1]};
11 | .title {
12 | height: 3rem;
13 | display: flex;
14 | align-items: center;
15 | background: ${props => props.theme.primary};
16 | padding-left: 1rem;
17 | img {
18 | width: 1.6rem;
19 | height: 1.6rem;
20 | margin-right: 0.5rem;
21 | }
22 | span {
23 | font: 1.1rem 'Open Sans';
24 | font-weight: 600;
25 | }
26 | }
27 | .message {
28 | height: calc(25vh - 8rem);
29 | display: flex;
30 | align-items: center;
31 | justify-content: center;
32 | font: 1.3rem 'Open Sans';
33 | font-weight: 600;
34 | padding: 1rem;
35 | }
36 | .actions {
37 | height: 5rem;
38 | display: flex;
39 | align-items: center;
40 | justify-content: flex-end;
41 | border-top: 1px solid ${props => props.theme.grey[2]};
42 | background: ${props => props.theme.grey[0]};
43 | .action {
44 | display: flex;
45 | align-items: center;
46 | justify-content: center;
47 | font: 1rem 'Open Sans';
48 | font-weight: 700;
49 | text-transform: uppercase;
50 | padding: 0.75rem 1rem;
51 | margin-right: 1rem;
52 | border-radius: ${props => props.theme.borderRadius};
53 | transition: 0.3s;
54 | cursor: pointer;
55 | }
56 | .confirm {
57 | color: white;
58 | background: ${props => props.theme.secondary};
59 | &:hover {
60 | background: ${props => darken(0.1, props.theme.secondary)};
61 | }
62 | }
63 | .cancel {
64 | color: ${props => props.theme.grey[10]};
65 | background: ${props => props.theme.grey[1]};
66 | &:hover {
67 | background: ${props => props.theme.grey[2]};
68 | }
69 | }
70 | }
71 | `
72 |
--------------------------------------------------------------------------------
/src/renderer/styles/Main.js:
--------------------------------------------------------------------------------
1 | import styled from 'styled-components'
2 |
3 | export const Main = styled.main`
4 | position: fixed;
5 | top: 5rem;
6 | bottom: 5rem;
7 | right: ${props => (props.open ? '-24rem' : '-5rem')};
8 | z-index: 2;
9 | width: 100%;
10 | height: calc(100vh - 10rem);
11 | transition: 0.3s;
12 | background: white;
13 | `
14 |
--------------------------------------------------------------------------------
/src/renderer/styles/Media.js:
--------------------------------------------------------------------------------
1 | import { css } from 'styled-components'
2 |
3 | const sizes = {
4 | desktop: 992,
5 | tablet: 768,
6 | phone: 576
7 | }
8 |
9 | const media = Object.keys(sizes).reduce((acc, label) => {
10 | acc[label] = (...args) => css`
11 | @media (max-width: ${sizes[label] / 16}em) {
12 | ${css(...args)}
13 | }
14 | `
15 | return acc
16 | }, {})
17 |
18 | export default media
19 |
--------------------------------------------------------------------------------
/src/renderer/styles/Slide.js:
--------------------------------------------------------------------------------
1 | import styled, { keyframes } from 'styled-components'
2 |
3 | const slideFromRight = keyframes`
4 | from {
5 | transform: translateX(100vw);
6 | }
7 | to {
8 | transform: translateX(0)
9 | }
10 | `
11 |
12 | const slideFromBottom = keyframes`
13 | from {
14 | transform: translateY(100vw);
15 | }
16 | to {
17 | transform: translateY(0)
18 | }
19 | `
20 |
21 | export const Slide = styled.div`
22 | width: 100%;
23 | animation: ${props =>
24 | props.direction === 'right'
25 | ? slideFromRight
26 | : props.direction === 'bottom'
27 | ? slideFromBottom
28 | : null}
29 | 0.5s;
30 | `
31 |
--------------------------------------------------------------------------------
/src/renderer/styles/theme.js:
--------------------------------------------------------------------------------
1 | const grey = [
2 | '#FAFAFA',
3 | '#F2F2F2',
4 | '#E6E5E5',
5 | '#D9D8D8',
6 | '#CDCCCB',
7 | '#C0BFBF',
8 | '#B3B2B2',
9 | '#A7A5A5',
10 | '#9A9898',
11 | '#817E7E',
12 | '#747272',
13 | '#676565',
14 | '#5A5858',
15 | '#4D4C4C',
16 | '#403F3F'
17 | ]
18 |
19 | const shadows = [
20 | 'none',
21 | '0 1px 2px rgba(0,0,0,.1)',
22 | '0px 1px 3px 0px rgba(0, 0, 0, 0.2),0px 1px 1px 0px rgba(0, 0, 0, 0.14),0px 2px 1px -1px rgba(0, 0, 0, 0.12)',
23 | '0px 1px 5px 0px rgba(0, 0, 0, 0.2),0px 2px 2px 0px rgba(0, 0, 0, 0.14),0px 3px 1px -2px rgba(0, 0, 0, 0.12)',
24 | '0px 1px 8px 0px rgba(0, 0, 0, 0.2),0px 3px 4px 0px rgba(0, 0, 0, 0.14),0px 3px 3px -2px rgba(0, 0, 0, 0.12)',
25 | '0px 2px 4px -1px rgba(0, 0, 0, 0.2),0px 4px 5px 0px rgba(0, 0, 0, 0.14),0px 1px 10px 0px rgba(0, 0, 0, 0.12)',
26 | '0px 3px 5px -1px rgba(0, 0, 0, 0.2),0px 5px 8px 0px rgba(0, 0, 0, 0.14),0px 1px 14px 0px rgba(0, 0, 0, 0.12)',
27 | '0px 3px 5px -1px rgba(0, 0, 0, 0.2),0px 6px 10px 0px rgba(0, 0, 0, 0.14),0px 1px 18px 0px rgba(0, 0, 0, 0.12)',
28 | '0px 4px 5px -2px rgba(0, 0, 0, 0.2),0px 7px 10px 1px rgba(0, 0, 0, 0.14),0px 2px 16px 1px rgba(0, 0, 0, 0.12)',
29 | '0px 5px 5px -3px rgba(0, 0, 0, 0.2),0px 8px 10px 1px rgba(0, 0, 0, 0.14),0px 3px 14px 2px rgba(0, 0, 0, 0.12)',
30 | '0px 5px 6px -3px rgba(0, 0, 0, 0.2),0px 9px 12px 1px rgba(0, 0, 0, 0.14),0px 3px 16px 2px rgba(0, 0, 0, 0.12)',
31 | '0px 6px 6px -3px rgba(0, 0, 0, 0.2),0px 10px 14px 1px rgba(0, 0, 0, 0.14),0px 4px 18px 3px rgba(0, 0, 0, 0.12)',
32 | '0px 6px 7px -4px rgba(0, 0, 0, 0.2),0px 11px 15px 1px rgba(0, 0, 0, 0.14),0px 4px 20px 3px rgba(0, 0, 0, 0.12)',
33 | '0px 7px 8px -4px rgba(0, 0, 0, 0.2),0px 12px 17px 2px rgba(0, 0, 0, 0.14),0px 5px 22px 4px rgba(0, 0, 0, 0.12)',
34 | '0px 7px 8px -4px rgba(0, 0, 0, 0.2),0px 13px 19px 2px rgba(0, 0, 0, 0.14),0px 5px 24px 4px rgba(0, 0, 0, 0.12)',
35 | '0px 7px 9px -4px rgba(0, 0, 0, 0.2),0px 14px 21px 2px rgba(0, 0, 0, 0.14),0px 5px 26px 4px rgba(0, 0, 0, 0.12)',
36 | '0px 8px 9px -5px rgba(0, 0, 0, 0.2),0px 15px 22px 2px rgba(0, 0, 0, 0.14),0px 6px 28px 5px rgba(0, 0, 0, 0.12)',
37 | '0px 8px 10px -5px rgba(0, 0, 0, 0.2),0px 16px 24px 2px rgba(0, 0, 0, 0.14),0px 6px 30px 5px rgba(0, 0, 0, 0.12)',
38 | '0px 8px 11px -5px rgba(0, 0, 0, 0.2),0px 17px 26px 2px rgba(0, 0, 0, 0.14),0px 6px 32px 5px rgba(0, 0, 0, 0.12)',
39 | '0px 9px 11px -5px rgba(0, 0, 0, 0.2),0px 18px 28px 2px rgba(0, 0, 0, 0.14),0px 7px 34px 6px rgba(0, 0, 0, 0.12)',
40 | '0px 9px 12px -6px rgba(0, 0, 0, 0.2),0px 19px 29px 2px rgba(0, 0, 0, 0.14),0px 7px 36px 6px rgba(0, 0, 0, 0.12)',
41 | '0px 10px 13px -6px rgba(0, 0, 0, 0.2),0px 20px 31px 3px rgba(0, 0, 0, 0.14),0px 8px 38px 7px rgba(0, 0, 0, 0.12)',
42 | '0px 10px 13px -6px rgba(0, 0, 0, 0.2),0px 21px 33px 3px rgba(0, 0, 0, 0.14),0px 8px 40px 7px rgba(0, 0, 0, 0.12)',
43 | '0px 10px 14px -6px rgba(0, 0, 0, 0.2),0px 22px 35px 3px rgba(0, 0, 0, 0.14),0px 8px 42px 7px rgba(0, 0, 0, 0.12)',
44 | '0px 11px 14px -7px rgba(0, 0, 0, 0.2),0px 23px 36px 3px rgba(0, 0, 0, 0.14),0px 9px 44px 8px rgba(0, 0, 0, 0.12)',
45 | '0px 11px 15px -7px rgba(0, 0, 0, 0.2),0px 24px 38px 3px rgba(0, 0, 0, 0.14),0px 9px 46px 8px rgba(0, 0, 0, 0.12)'
46 | ]
47 |
48 | export default {
49 | grey,
50 | black: '#333333',
51 | primary: '#FFF28F',
52 | secondary: '#DE4545',
53 | tertiary: '#2484EB',
54 | quatro: '#7AFF6B',
55 | borderRadius: '2px',
56 | shadows,
57 | scrollbar: '8px',
58 | fontSize: '10px'
59 | }
60 |
--------------------------------------------------------------------------------
/src/renderer/utils/analyzeAnswers.js:
--------------------------------------------------------------------------------
1 | import isequal from 'lodash.isequal'
2 |
3 | /**
4 | * Aggegate a report summarizing exam performance
5 | * @param exam {object} - exam object
6 | * @param answers {boolean[][]} - 2 dimensional array answers to each question
7 | * @param fillIns {string[]} - answers to fill in the blank questions
8 | * @param orders {boolean[]} - answers to order list questions
9 | * @param time {number} - time in seconds remaining from exam time
10 | * @param intervals {number[]} - time spent on each question
11 | */
12 | export default (exam, answers, fillIns, orders, time, intervals) => {
13 | const correct = []
14 | const incorrect = []
15 | const incomplete = []
16 |
17 | answers.forEach((el, i) => {
18 | const { variant, answer } = exam.test[i]
19 | if (el.indexOf(true) === -1 && el.length > 1) {
20 | incomplete.push(i)
21 | } else if (variant === 2 && !fillIns[i]) {
22 | incomplete.push(i)
23 | } else if (variant === 3 && !orders[i]) {
24 | incomplete.push(i)
25 | } else if (isequal(el, answer) || (el.length === 1 && !!el)) {
26 | correct.push(i)
27 | } else {
28 | incorrect.push(i)
29 | }
30 | })
31 |
32 | const score = Math.round((correct.length / exam.test.length) * 100)
33 | const status = score >= exam.pass
34 | const date = new Date()
35 | const elapsed = exam.time * 60 - time
36 | const report = {
37 | filename: exam.filename,
38 | title: exam.title,
39 | code: exam.code,
40 | pass: exam.pass,
41 | time: exam.time,
42 | testLength: exam.test.length,
43 | image: exam.image,
44 | status,
45 | score,
46 | correct,
47 | incorrect,
48 | incomplete,
49 | answers,
50 | fillIns,
51 | orders,
52 | intervals,
53 | date,
54 | elapsed
55 | }
56 | return report
57 | }
58 |
--------------------------------------------------------------------------------
/src/renderer/utils/analyzeGridItem.js:
--------------------------------------------------------------------------------
1 | import theme from '../styles/theme'
2 | import { lighten } from 'polished'
3 |
4 | export default (question, answers, fillIns, orders, marked) => {
5 | const incomplete = answers.map((el, i) => {
6 | if (el.indexOf(true) === -1 && !fillIns[i] && !orders[i]) {
7 | return i
8 | }
9 | })
10 | if (marked.indexOf(question) !== -1) {
11 | return lighten(0.25, theme.tertiary)
12 | } else if (incomplete.indexOf(question) !== -1) {
13 | return theme.grey[1]
14 | } else {
15 | return lighten(0.1, theme.primary)
16 | }
17 | }
18 |
--------------------------------------------------------------------------------
/src/renderer/utils/analyzeReviewGridItem.js:
--------------------------------------------------------------------------------
1 | import theme from '../styles/theme'
2 | import { lighten } from 'polished'
3 |
4 | export default (i, { correct, incorrect }) => {
5 | if (correct.indexOf(i) !== -1) {
6 | return lighten(0.1, theme.primary)
7 | } else if (incorrect.indexOf(i) !== -1) {
8 | return lighten(0.25, theme.secondary)
9 | } else {
10 | return theme.grey[1]
11 | }
12 | }
13 |
--------------------------------------------------------------------------------
/src/renderer/utils/createExplanation.js:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 |
3 | export default (variant, correctAnswers) => {
4 | const alpha = []
5 | for (let i = 65; i <= 90; i++) {
6 | alpha.push(String.fromCharCode(i))
7 | }
8 | if (variant === 0 || variant === 1) {
9 | var str = correctAnswers.reduce((acc, val, i) => {
10 | if (val) {
11 | acc += `${alpha[i]}, `
12 | }
13 | return acc
14 | }, '')
15 | return str.trim().substring(0, str.length - 2)
16 | } else if (variant === 2) {
17 | var str = correctAnswers.reduce((acc, val) => {
18 | if (val) {
19 | acc += `${val.text}, `
20 | }
21 | return acc
22 | }, '')
23 | return str.trim().substring(0, str.length - 2)
24 | } else if (variant === 3) {
25 | return correctAnswers.reduce((acc, val, i) => {
26 | acc.push({`${i + 1}. ${val.text}`}
)
27 | return acc
28 | }, [])
29 | }
30 | }
31 |
--------------------------------------------------------------------------------
/src/renderer/utils/createFileSystem.js:
--------------------------------------------------------------------------------
1 | import fs from 'fs'
2 | import { promisify } from 'util'
3 | import * as filepaths from './filepaths'
4 |
5 | const mkdir = promisify(fs.mkdir)
6 | const readFile = promisify(fs.readFile)
7 | const writeFile = promisify(fs.writeFile)
8 |
9 | /**
10 | * Create directories and files to store application data
11 | */
12 | export default async () => {
13 | await mkdir(filepaths.DATA_DIR_PATH)
14 |
15 | await mkdir(filepaths.EXAM_DIR_PATH)
16 |
17 | const optionsData = await readFile(filepaths.OPTIONS_SRC_PATH)
18 | await writeFile(filepaths.OPTIONS_DST_PATH, optionsData)
19 |
20 | const demoExamData = await readFile(filepaths.DEMO_SRC_PATH)
21 | await writeFile(filepaths.DEMO_DST_PATH, demoExamData)
22 |
23 | await writeFile(filepaths.HISTORY_PATH, JSON.stringify([]))
24 |
25 | await writeFile(filepaths.SESSIONS_PATH, JSON.stringify([]))
26 | }
27 |
--------------------------------------------------------------------------------
/src/renderer/utils/createHistoryGroups.js:
--------------------------------------------------------------------------------
1 | /**
2 | * Group exam history by filename and find average score for each exam
3 | * Resturctures data to simplify rendering
4 | * @param history {object[]} - array of exam report objects
5 | */
6 | export default history => {
7 | // isolate unique filenames
8 | const uniqueFilenames = []
9 | history.forEach((el, i) => {
10 | el.indexHistory = i
11 | if (uniqueFilenames.indexOf(el.filename) === -1) {
12 | uniqueFilenames.push(el.filename)
13 | }
14 | })
15 |
16 | // restructure history into an object map
17 | // key = unique filename, value = array of reports for that filename
18 | const groupedByFilename = history.reduce((acc, val) => {
19 | if (acc[val.filename]) {
20 | acc[val.filename].push(val)
21 | } else {
22 | acc[val.filename] = [val]
23 | }
24 | return acc
25 | }, {})
26 |
27 | // calculate the average score and elapsed time for each group
28 | const averageScores = []
29 | const averageTimes = []
30 | for (let prop in groupedByFilename) {
31 | const totalScore = groupedByFilename[prop].reduce((acc, val) => (acc += val.score), 0)
32 | const averageScore = Math.ceil(totalScore / groupedByFilename[prop].length)
33 | averageScores.push(averageScore)
34 | const totalTime = groupedByFilename[prop].reduce((acc, val) => (acc += val.elapsed), 0)
35 | const averageTime = Math.ceil(totalTime / groupedByFilename[prop].length)
36 | averageTimes.push(averageTime)
37 | }
38 |
39 | return [groupedByFilename, uniqueFilenames, averageScores, averageTimes]
40 | }
41 |
--------------------------------------------------------------------------------
/src/renderer/utils/createSession.js:
--------------------------------------------------------------------------------
1 | export default ({
2 | exam: { title, code, filename, image, test },
3 | answers,
4 | question,
5 | time,
6 | fillIns,
7 | orders,
8 | marked,
9 | intervals
10 | }) => {
11 | const date = new Date()
12 | const completed = answers.reduce((acc, val, i) => {
13 | if (val.indexOf(true) !== -1 || fillIns[i] || orders[i]) {
14 | acc += 1
15 | }
16 | return acc
17 | }, 0)
18 |
19 | const session = {
20 | filename,
21 | image,
22 | title,
23 | code,
24 | testLength: test.length,
25 | completed,
26 | answers,
27 | fillIns,
28 | orders,
29 | marked,
30 | intervals,
31 | question,
32 | time,
33 | date
34 | }
35 | return session
36 | }
37 |
--------------------------------------------------------------------------------
/src/renderer/utils/deleteExam.js:
--------------------------------------------------------------------------------
1 | import path from 'path'
2 | import { EXAM_DIR_PATH } from './filepaths'
3 | import writeData from './writeData'
4 | import deleteFile from './deleteFile'
5 |
6 | export default (exams, indexExam, sessions, history) => {
7 | return new Promise((resolve, reject) => {
8 | const filename = exams[indexExam].filename
9 | if (filename === 'demo.json') {
10 | resolve(false)
11 | } else {
12 | const newHistory = history.filter(h => h.filename !== filename)
13 | const newSessions = sessions.filter(s => s.filename !== filename)
14 | deleteFile(path.join(EXAM_DIR_PATH, filename))
15 | writeData('history', newHistory)
16 | writeData('session', newSessions)
17 | resolve(true)
18 | }
19 | })
20 | }
21 |
--------------------------------------------------------------------------------
/src/renderer/utils/deleteFile.js:
--------------------------------------------------------------------------------
1 | import fs from 'fs'
2 | import { promisify } from 'util'
3 |
4 | const deleteFile = promisify(fs.unlink)
5 |
6 | export default async filepath => {
7 | await deleteFile(filepath)
8 | }
9 |
--------------------------------------------------------------------------------
/src/renderer/utils/examDataStuctures.js:
--------------------------------------------------------------------------------
1 | /**
2 | * Create data structures supporting exam
3 | * @param exam {object} - exam about to be taken
4 | * @returns {array} - multi-dimensional array
5 | */
6 | export default exam => {
7 | const answers = [] // arrays of booleans track user responses
8 | const fillIns = [] // strings answers fill in the blank
9 | const orders = [] // arrays of sequences for list order questions
10 | const intervals = [] // time spend on each question
11 | exam.test.forEach(el => {
12 | answers.push(Array(el.choices.length).fill(false))
13 | fillIns.push('')
14 | orders.push(null)
15 | intervals.push(0)
16 | })
17 | return [answers, fillIns, orders, intervals]
18 | }
19 |
--------------------------------------------------------------------------------
/src/renderer/utils/filepaths.js:
--------------------------------------------------------------------------------
1 | import { remote } from 'electron'
2 | import path from 'path'
3 |
4 | export const DATA_DIR_PATH = path.join(remote.app.getPath('userData'), '/data')
5 | export const EXAM_DIR_PATH = path.join(DATA_DIR_PATH, '/exams')
6 | export const DEMO_SRC_PATH = path.join(__static, '/exams', 'demo.json')
7 | export const DEMO_DST_PATH = path.join(EXAM_DIR_PATH, 'demo.json')
8 | export const HISTORY_PATH = path.join(DATA_DIR_PATH, 'history.json')
9 | export const SESSIONS_PATH = path.join(DATA_DIR_PATH, 'sessions.json')
10 | export const OPTIONS_SRC_PATH = path.join(__static, 'options.json')
11 | export const OPTIONS_DST_PATH = path.join(DATA_DIR_PATH, 'options.json')
12 | export const BLUE_LOGO_PATH = 'https://s3.amazonaws.com/electron-exam/general/icon.png'
13 | export const RED_LOGO_PATH = 'https://s3.amazonaws.com/electron-exam/general/icon-red.png'
14 |
--------------------------------------------------------------------------------
/src/renderer/utils/formatAnswerLabel.js:
--------------------------------------------------------------------------------
1 | const alpha = [
2 | 'A',
3 | 'B',
4 | 'C',
5 | 'D',
6 | 'E',
7 | 'F',
8 | 'G',
9 | 'H',
10 | 'I',
11 | 'J',
12 | 'K',
13 | 'L',
14 | 'M',
15 | 'N',
16 | 'O',
17 | 'P',
18 | 'Q',
19 | 'R',
20 | 'S',
21 | 'T',
22 | 'U',
23 | 'V',
24 | 'W',
25 | 'X',
26 | 'Y',
27 | 'Z'
28 | ]
29 |
30 | export default index => alpha[index]
31 |
--------------------------------------------------------------------------------
/src/renderer/utils/formatCreatedAt.js:
--------------------------------------------------------------------------------
1 | import { formatDistance } from 'date-fns'
2 |
3 | export default date =>
4 | formatDistance(new Date(date), new Date()).replace(/about|over|almost|less/, '')
5 |
--------------------------------------------------------------------------------
/src/renderer/utils/formatDate.js:
--------------------------------------------------------------------------------
1 | import { format } from 'date-fns'
2 |
3 | export default date => format(new Date(date), 'MM/dd/RRRR')
4 |
--------------------------------------------------------------------------------
/src/renderer/utils/formatTimer.js:
--------------------------------------------------------------------------------
1 | function formatTimeString(str) {
2 | var re = /0|:/
3 | if (re.test(str[0]) && str.length > 4) {
4 | return formatTimeString(str.slice(1))
5 | }
6 | return str
7 | }
8 |
9 | export default sec => {
10 | let str = new Date(sec * 1000).toISOString().substr(11, 8)
11 | return formatTimeString(str)
12 | }
13 |
--------------------------------------------------------------------------------
/src/renderer/utils/processRemoteExam.js:
--------------------------------------------------------------------------------
1 | import fs from 'fs'
2 | import path from 'path'
3 | import { promisify } from 'util'
4 | import validateExam from './validateExam'
5 | import { EXAM_DIR_PATH } from './filepaths'
6 |
7 | const writeFile = promisify(fs.writeFile)
8 |
9 | export default (filename, exam) => {
10 | return new Promise(async (resolve, reject) => {
11 | const dstFilepath = path.join(EXAM_DIR_PATH, filename)
12 | const data = JSON.stringify(exam)
13 | const valid = await validateExam(data)
14 | if (valid === 'valid') {
15 | await writeFile(dstFilepath, data)
16 | resolve(true)
17 | } else {
18 | const errors = valid.map(el => el.message)
19 | resolve(errors)
20 | }
21 | })
22 | }
23 |
--------------------------------------------------------------------------------
/src/renderer/utils/questionHelper.js:
--------------------------------------------------------------------------------
1 | export default (source, array, question) => {
2 | let newQuestion
3 | if (source === 0) {
4 | newQuestion = array[0]
5 | } else if (source === 1) {
6 | let i = array.indexOf(question + 1)
7 | if (i === 0) {
8 | newQuestion = false
9 | }
10 | newQuestion = array[i - 1]
11 | } else if (source === 2) {
12 | let i = array.indexOf(question - 1)
13 | if (i === array.length - 1) {
14 | newQuestion = false
15 | }
16 | newQuestion = array[i + 1]
17 | } else if (source === 3) {
18 | newQuestion = array[array.length - 1]
19 | }
20 | return newQuestion
21 | }
22 |
--------------------------------------------------------------------------------
/src/renderer/utils/randomizeArray.js:
--------------------------------------------------------------------------------
1 | export default array => {
2 | var current = array.length
3 | var tmp
4 | var random
5 |
6 | while (0 !== current) {
7 | random = Math.floor(Math.random() * current)
8 | current -= 1
9 | tmp = array[current]
10 | array[current] = array[random]
11 | array[random] = tmp
12 | }
13 |
14 | return array
15 | }
16 |
--------------------------------------------------------------------------------
/src/renderer/utils/readExamsDir.js:
--------------------------------------------------------------------------------
1 | import fs from 'fs'
2 | import path from 'path'
3 | import { promisify } from 'util'
4 | import { EXAM_DIR_PATH } from './filepaths'
5 |
6 | const readDir = promisify(fs.readdir)
7 | const readFile = promisify(fs.readFile)
8 | const getStats = promisify(fs.stat)
9 |
10 | /**
11 | * Read exams directory, parse all exam JSON files, return array of exam objects
12 | */
13 | export default async () => {
14 | const filenames = await readDir(EXAM_DIR_PATH)
15 | const examData = filenames.map(file => readFile(path.join(EXAM_DIR_PATH, file)))
16 | const statsData = filenames.map(file => getStats(path.join(EXAM_DIR_PATH, file)))
17 | const resolved = await Promise.all([examData, statsData].map(el => Promise.all(el)))
18 | return resolved[0]
19 | .map(el => JSON.parse(el))
20 | .map((el, i) => {
21 | el.stats = resolved[1][i]
22 | el.filename = filenames[i]
23 | return el
24 | })
25 | }
26 |
--------------------------------------------------------------------------------
/src/renderer/utils/readHistoryFile.js:
--------------------------------------------------------------------------------
1 | import fs from 'fs'
2 | import { promisify } from 'util'
3 | import { HISTORY_PATH } from './filepaths'
4 |
5 | const readFile = promisify(fs.readFile)
6 |
7 | /**
8 | * Read history.json, parse, return array of history objects
9 | */
10 | export default async () => {
11 | const data = await readFile(HISTORY_PATH)
12 | return data.length ? JSON.parse(data) : []
13 | }
14 |
--------------------------------------------------------------------------------
/src/renderer/utils/readOptionsFile.js:
--------------------------------------------------------------------------------
1 | import fs from 'fs'
2 | import { promisify } from 'util'
3 | import { OPTIONS_DST_PATH } from './filepaths'
4 |
5 | const readFile = promisify(fs.readFile)
6 |
7 | /**
8 | * Read options.json, parse, return options objects
9 | */
10 | export default async () => {
11 | const data = await readFile(OPTIONS_DST_PATH)
12 | return JSON.parse(data)
13 | }
14 |
--------------------------------------------------------------------------------
/src/renderer/utils/readSessionsFile.js:
--------------------------------------------------------------------------------
1 | import fs from 'fs'
2 | import { promisify } from 'util'
3 | import { SESSIONS_PATH } from './filepaths'
4 |
5 | const readFile = promisify(fs.readFile)
6 |
7 | /**
8 | * Read sessions.json, parse, return array of history objects
9 | */
10 | export default async () => {
11 | const data = await readFile(SESSIONS_PATH)
12 | return data.length ? JSON.parse(data) : []
13 | }
14 |
--------------------------------------------------------------------------------
/src/renderer/utils/showAboutDialog.js:
--------------------------------------------------------------------------------
1 | import { clipboard, remote } from 'electron'
2 |
3 | const detail = `
4 | Version: ${remote.app.getVersion()}
5 | Date: ${new Date().toISOString()}
6 | Electron: ${process.versions.electron}
7 | Chrome: ${process.versions.chrome}
8 | Node.js: ${process.versions.node}
9 | V8: ${process.versions.v8}
10 | OS: ${process.platform}
11 | `
12 |
13 | export default () =>
14 | remote.dialog.showMessageBox(
15 | remote.getCurrentWindow(),
16 | {
17 | type: 'info',
18 | buttons: ['Close', 'Copy'],
19 | title: 'Exam Simulator',
20 | message: 'Exam Simulator',
21 | detail,
22 | noLink: true,
23 | defaultId: 0,
24 | cancelId: 0
25 | },
26 | response => {
27 | if (response === 1) {
28 | clipboard.writeText(detail)
29 | }
30 | }
31 | )
32 |
--------------------------------------------------------------------------------
/src/renderer/utils/showFileDialog.js:
--------------------------------------------------------------------------------
1 | import { remote } from 'electron'
2 | import fs from 'fs'
3 | import path from 'path'
4 | import { promisify } from 'util'
5 | import validateExam from './validateExam'
6 | import { EXAM_DIR_PATH } from './filepaths'
7 |
8 | const readFile = promisify(fs.readFile)
9 | const writeFile = promisify(fs.writeFile)
10 |
11 | /**
12 | * Show native file dialog then parse and validate JSON file
13 | * @param {object} win - Instance of electron BrowserWindow
14 | */
15 | export default win => {
16 | return new Promise((resolve, reject) => {
17 | remote.dialog.showOpenDialog(
18 | win,
19 | {
20 | title: 'Load Local Exam File',
21 | filters: [{ name: 'JSON', extensions: ['json'] }],
22 | properties: ['openFile'],
23 | buttonLabel: 'Load Exam'
24 | },
25 | async filepaths => {
26 | // user clicked 'cancel'
27 | if (!filepaths) {
28 | resolve(false)
29 | } else {
30 | // isolate filename from path
31 | const filename = filepaths[0].split('\\').pop()
32 | // path to application data directory
33 | const dstFilepath = path.join(EXAM_DIR_PATH, filename)
34 | // read contents of new exam file
35 | const data = await readFile(filepaths[0])
36 | // validate JSON against predefined schema
37 | const valid = await validateExam(data)
38 | // exam is valid so write it to application data
39 | if (valid === 'valid') {
40 | await writeFile(dstFilepath, data)
41 | resolve(true)
42 | // exam is not valid return error messages
43 | } else {
44 | const errors = valid.map(el => el.message)
45 | resolve(errors)
46 | }
47 | }
48 | }
49 | )
50 | })
51 | }
52 |
--------------------------------------------------------------------------------
/src/renderer/utils/validateExam.js:
--------------------------------------------------------------------------------
1 | const Ajv = require('ajv')
2 | const ajv = new Ajv({ allErrors: true })
3 |
4 | const schema = {
5 | definitions: {},
6 | $schema: 'http://json-schema.org/draft-07/schema#',
7 | $id: 'http://example.com/root.json',
8 | type: 'object',
9 | title: 'The Root Schema',
10 | required: ['author', 'createdAt', 'title', 'code', 'time', 'pass', 'cover', 'test'],
11 | properties: {
12 | author: {
13 | $id: '#/properties/author',
14 | type: 'object',
15 | title: 'The Author Schema',
16 | required: ['id', 'name', 'image'],
17 | properties: {
18 | id: {
19 | $id: '#/properties/author/properties/id',
20 | type: 'string',
21 | title: 'The Author Id Schema',
22 | default: '',
23 | examples: ['12345']
24 | },
25 | name: {
26 | $id: '#/properties/author/properties/name',
27 | type: 'string',
28 | title: 'The Author Name Schema',
29 | default: 'unknown',
30 | examples: ['benjaminadk']
31 | },
32 | image: {
33 | $id: '#/properties/author/properties/image',
34 | type: 'string',
35 | title: 'The Author Image Schema',
36 | default: '',
37 | examples: ['http://www.author.com/image.png']
38 | }
39 | }
40 | },
41 | createdAt: {
42 | $id: '#/properties/createdAt',
43 | type: 'string',
44 | title: 'The CreatedAt Schema',
45 | default: '2019-03-05T08:06:29.168Z',
46 | examples: ['2019-03-05T08:06:29.168Z']
47 | },
48 | title: {
49 | $id: '#/properties/title',
50 | type: 'string',
51 | title: 'The Title Schema',
52 | default: 'Untitled',
53 | examples: ['Oracle Database']
54 | },
55 | code: {
56 | $id: '#/properties/code',
57 | type: 'string',
58 | title: 'The Code Schema',
59 | default: '000-000',
60 | examples: ['1z0-061']
61 | },
62 | time: {
63 | $id: '#/properties/time',
64 | type: 'integer',
65 | title: 'The Time Schema',
66 | default: 60,
67 | examples: [120]
68 | },
69 | pass: {
70 | $id: '#/properties/pass',
71 | type: 'integer',
72 | title: 'The Pass Schema',
73 | default: 60,
74 | examples: [65]
75 | },
76 | cover: {
77 | $id: '#/properties/cover',
78 | type: 'array',
79 | title: 'The Cover Schema',
80 | items: {
81 | $id: '#/properties/cover/items',
82 | type: 'object',
83 | title: 'The Cover Items Schema',
84 | required: ['variant', 'text'],
85 | properties: {
86 | variant: {
87 | $id: '#/properties/cover/items/properties/variant',
88 | type: 'integer',
89 | enum: [0, 1, 2],
90 | title: 'The Cover Item Variant Schema',
91 | default: 1,
92 | examples: [0]
93 | },
94 | text: {
95 | $id: '#/properties/cover/items/properties/text',
96 | type: 'string',
97 | title: 'The Cover Item Text Schema',
98 | default: '',
99 | examples: ['Oracle Database Fundamentals']
100 | }
101 | }
102 | }
103 | },
104 | test: {
105 | $id: '#/properties/test',
106 | type: 'array',
107 | title: 'The Test Schema',
108 | items: {
109 | $id: '#/properties/test/items',
110 | type: 'object',
111 | title: 'The Test Items Schema',
112 | required: ['variant', 'question', 'choices', 'answer', 'explanation'],
113 | properties: {
114 | variant: {
115 | $id: '#/properties/test/items/properties/variant',
116 | type: 'integer',
117 | enum: [0, 1, 2, 3],
118 | title: 'The Question Variant Schema',
119 | default: 0,
120 | examples: [1]
121 | },
122 | question: {
123 | $id: '#/properties/test/items/properties/question',
124 | type: 'array',
125 | title: 'The Question Item Schema',
126 | items: {
127 | $id: '#/properties/test/items/properties/question/items',
128 | type: 'object',
129 | title: 'The Items Schema',
130 | required: ['variant', 'text'],
131 | properties: {
132 | variant: {
133 | $id: '#/properties/test/items/properties/question/items/properties/variant',
134 | type: 'integer',
135 | enum: [0, 1, 2],
136 | title: 'The Question Item Variant Schema',
137 | default: 1,
138 | examples: [1]
139 | },
140 | text: {
141 | $id: '#/properties/test/items/properties/question/items/properties/text',
142 | type: 'string',
143 | title: 'The Question Item Text Schema',
144 | default: '',
145 | examples: [
146 | 'Which three tasks can be performed using SQL functions built into Oracle Database?'
147 | ]
148 | }
149 | }
150 | }
151 | },
152 | choices: {
153 | $id: '#/properties/test/items/properties/choices',
154 | type: 'array',
155 | title: 'The Choices Schema',
156 | items: {
157 | $id: '#/properties/test/items/properties/choices/items',
158 | type: 'object',
159 | title: 'The Choice Items Schema',
160 | required: ['label', 'text'],
161 | properties: {
162 | label: {
163 | $id: '#/properties/test/items/properties/choices/items/properties/label',
164 | type: ['string', 'integer'],
165 | title: 'The Choice Label Schema',
166 | default: 'A',
167 | examples: ['A']
168 | },
169 | text: {
170 | $id: '#/properties/test/items/properties/choices/items/properties/text',
171 | type: 'string',
172 | title: 'The Choice Text Schema',
173 | default: '',
174 | examples: ['Displaying a date in nondefault format']
175 | }
176 | }
177 | }
178 | },
179 | answer: {
180 | $id: '#/properties/test/items/properties/answer',
181 | type: 'array',
182 | title: 'The Answer Schema',
183 | items: {
184 | $id: '#/properties/test/items/properties/answer/items',
185 | type: 'boolean',
186 | title: 'The Answer Item Schema',
187 | default: false,
188 | examples: [false]
189 | }
190 | },
191 | explanation: {
192 | $id: '#/properties/test/items/properties/explanation',
193 | type: 'array',
194 | title: 'The Explanation Schema',
195 | items: {
196 | $id: '#/properties/test/items/properties/explanation/items',
197 | type: 'object',
198 | title: 'The Items Schema',
199 | required: ['variant', 'text'],
200 | properties: {
201 | variant: {
202 | $id: '#/properties/test/items/properties/explanation/items/properties/variant',
203 | type: 'integer',
204 | enum: [0, 1, 2, 3],
205 | title: 'The Explanation Variant Schema',
206 | default: 1,
207 | examples: [1]
208 | },
209 | text: {
210 | $id: '#/properties/test/items/properties/explanation/items/properties/text',
211 | type: 'string',
212 | title: 'The Explanation Text Schema',
213 | default: '',
214 | examples: ['The answer to the question is 3 because 2 plus 1 equals 3']
215 | }
216 | }
217 | }
218 | }
219 | }
220 | }
221 | }
222 | }
223 | }
224 |
225 | const validate = ajv.compile(schema)
226 |
227 | export default data => {
228 | return new Promise((resolve, reject) => {
229 | const valid = validate(JSON.parse(data))
230 | const payload = valid ? 'valid' : validate.errors
231 | resolve(payload)
232 | })
233 | }
234 |
--------------------------------------------------------------------------------
/src/renderer/utils/writeData.js:
--------------------------------------------------------------------------------
1 | import fs from 'fs'
2 | import { promisify } from 'util'
3 | import { HISTORY_PATH, SESSIONS_PATH, EXAM_DIR_PATH } from './filepaths'
4 |
5 | const writeFile = promisify(fs.writeFile)
6 |
7 | export default async (type, data, filename) => {
8 | if (type === 'history') {
9 | await writeFile(HISTORY_PATH, JSON.stringify(data)).catch(console.error)
10 | } else if (type === 'session') {
11 | await writeFile(SESSIONS_PATH, JSON.stringify(data)).catch(console.error)
12 | } else if (type === 'exam') {
13 | await writeFile(`${EXAM_DIR_PATH}/${filename}`, JSON.stringify(data)).catch(console.error)
14 | }
15 | }
16 |
--------------------------------------------------------------------------------
/static/exams/demo.json:
--------------------------------------------------------------------------------
1 | {
2 | "author": {
3 | "id": "5bbec39837b4fd0a207df53e",
4 | "name": "benjaminadk",
5 | "image": "https://yt3.ggpht.com/-9Q_OGPy0Reg/AAAAAAAAAAI/AAAAAAAAAAA/a-GWCV9iwcA/s88-c-k-no-mo-rj-c0xffffff/photo.jpg"
6 | },
7 | "createdAt": "2019-03-05T08:06:29.168Z",
8 | "title": "Demo Exam",
9 | "description": "Demo Exam to show the capabilities of Exam Simulator. Questions are themed around the Netflix documentary series 'Making a Murderer'. Features include multiple choice, multiple answer, fill in and list order questions as well as explanations.",
10 | "code": "000-000",
11 | "pass": 75,
12 | "time": 10,
13 | "image": "https://s3.amazonaws.com/electron-exam/general/icon-50x50.png",
14 | "cover": [
15 | { "variant": 2, "text": "Demo Exam" },
16 | { "variant": 1, "text": "Making a Murderer Edition" },
17 | {
18 | "variant": 0,
19 | "text": "https://s3.amazonaws.com/electron-exam/general/demo/demo-exam-cover.png"
20 | },
21 | { "variant": 1, "text": "Get a serious brain workout with Exam Simulator" },
22 | { "variant": 1, "text": "Build your own exam with Exam Maker" },
23 | { "variant": 1, "text": "Embrace free and open source software" }
24 | ],
25 | "test": [
26 | {
27 | "answer": [false],
28 | "question": [
29 | {
30 | "variant": 1,
31 | "text": "Arrange the following events from Making a Murderer in chronological order."
32 | },
33 | { "variant": 1, "text": "Place the event that happened first at the top." }
34 | ],
35 | "choices": [
36 | { "label": 0, "text": "Avery rape conviction overturned" },
37 | { "label": 1, "text": "Avery files 36 million dollar lawsuit" },
38 | { "label": 2, "text": "Teresa Halbach is reported missing" },
39 | { "label": 3, "text": "Toyota RAV4 is found at salvage yard" },
40 | { "label": 4, "text": "Brendan Dassey confesses to murder" },
41 | { "label": 5, "text": "Avery convicted of 1st degree murder" }
42 | ],
43 | "explanation": [],
44 | "variant": 3
45 | },
46 | {
47 | "answer": [false, true, false, false],
48 | "question": [
49 | {
50 | "variant": 1,
51 | "text": "Who is the lead prosecutor in Season One of the Netflix series Making a Murderer?"
52 | }
53 | ],
54 | "choices": [
55 | { "label": "A", "text": "Norman Gahn" },
56 | { "label": "B", "text": "Kenneth Kratz" },
57 | { "label": "C", "text": "Mark Fremgen" },
58 | { "label": "D", "text": "Dean Strang" }
59 | ],
60 | "explanation": [
61 | { "variant": 1, "text": "Ken Kratz during his infamous press conference." },
62 | {
63 | "variant": 0,
64 | "text": "https://hips.hearstapps.com/esq.h-cdn.co/assets/16/04/1453735169-4886.jpeg?resize=480:*"
65 | }
66 | ],
67 | "variant": 0
68 | },
69 | {
70 | "answer": [false, false, true, false, true],
71 | "question": [
72 | {
73 | "variant": 1,
74 | "text": "Which two characters are convicted for the murder of Teresa Halbach?"
75 | }
76 | ],
77 | "choices": [
78 | { "label": "A", "text": "Earl Avery" },
79 | { "label": "B", "text": "Chuck Avery" },
80 | { "label": "C", "text": "Steve Avery" },
81 | { "label": "D", "text": "Bobby Dassey" },
82 | { "label": "E", "text": "Brendan Dassey" }
83 | ],
84 | "explanation": [
85 | { "variant": 1, "text": "Avery and Dassey cerca 2006 at the time of their trials." },
86 | {
87 | "variant": 0,
88 | "text": "https://www.thewrap.com/wp-content/uploads/2015/12/Steven-Avery-Brendan-Dassey-Making-a-Murderer-Netflix.jpg"
89 | }
90 | ],
91 | "variant": 1
92 | },
93 | {
94 | "answer": [false, false, false, true],
95 | "question": [
96 | {
97 | "variant": 1,
98 | "text": "What Wisconsin county is in charge of investigating and prosecuting the murder of Teresa Halbach?"
99 | }
100 | ],
101 | "choices": [
102 | { "label": "A", "text": "Winnebago" },
103 | { "label": "B", "text": "Sheboygan" },
104 | { "label": "C", "text": "Manitowak" },
105 | { "label": "D", "text": "Calumet" }
106 | ],
107 | "explanation": [],
108 | "variant": 0
109 | },
110 | {
111 | "answer": [false],
112 | "question": [
113 | {
114 | "variant": 1,
115 | "text": "What is the name of the post conviction lawyer that argued for Habeas Corpus relief in front of the 7th Circuit Court in Chicago?"
116 | }
117 | ],
118 | "choices": [{ "label": "B", "text": "Laura Nirider" }],
119 | "explanation": [],
120 | "variant": 2
121 | },
122 | {
123 | "answer": [false, false, true, false],
124 | "question": [
125 | {
126 | "variant": 1,
127 | "text": "What make and model of vehicle was Teresa Halbach driving when she went missing?"
128 | }
129 | ],
130 | "choices": [
131 | { "label": "A", "text": "Honda Element" },
132 | { "label": "B", "text": "Jeep Liberty" },
133 | { "label": "C", "text": "Toyota RAV4" },
134 | { "label": "D", "text": "Chevrolet Escape" }
135 | ],
136 | "explanation": [],
137 | "variant": 0
138 | },
139 | {
140 | "answer": [false, false, true, false],
141 | "question": [
142 | { "variant": 1, "text": "Which important character is pictured below?" },
143 | { "variant": 0, "text": "https://s3.amazonaws.com/electron-exam/general/demo/dolores.jpg" }
144 | ],
145 | "choices": [
146 | { "label": "A", "text": "Kathleen Zellner" },
147 | { "label": "B", "text": "Teresa Halbach" },
148 | { "label": "C", "text": "Dolores Avery" },
149 | { "label": "D", "text": "Barbara Tadych" }
150 | ],
151 | "explanation": [],
152 | "variant": 0
153 | },
154 | {
155 | "answer": [true, true, false, false, true],
156 | "question": [
157 | {
158 | "variant": 1,
159 | "text": "What types of crimes has Steve Avery been accused of during his lifetime?"
160 | }
161 | ],
162 | "choices": [
163 | { "label": "A", "text": "Lighting a cat on fire" },
164 | { "label": "B", "text": "Murder" },
165 | { "label": "C", "text": "Forging Checks" },
166 | { "label": "D", "text": "Heroin Possession" },
167 | { "label": "E", "text": "Rape" }
168 | ],
169 | "explanation": [],
170 | "variant": 1
171 | },
172 | {
173 | "answer": [false, false, true, false],
174 | "question": [{ "variant": 1, "text": "What is the Avery family business?" }],
175 | "choices": [
176 | { "label": "A", "text": "Italian Restaurant " },
177 | { "label": "B", "text": "Landscaping" },
178 | { "label": "C", "text": "Automotive" },
179 | { "label": "D", "text": "Convenience Store" }
180 | ],
181 | "explanation": [],
182 | "variant": 0
183 | },
184 | {
185 | "answer": [true, false, false, false],
186 | "question": [
187 | {
188 | "variant": 1,
189 | "text": "What state does Steve Avery want to move to, with his parents, after he is exonerated?"
190 | }
191 | ],
192 | "choices": [
193 | { "label": "A", "text": "Michigan" },
194 | { "label": "B", "text": "California" },
195 | { "label": "C", "text": "Montana" },
196 | { "label": "D", "text": "Texas" }
197 | ],
198 | "explanation": [],
199 | "variant": 0
200 | },
201 | {
202 | "answer": [false, true, true, false, false],
203 | "question": [
204 | {
205 | "variant": 1,
206 | "text": "Who were Steve Avery's two main lawyers in Season One of Making a Murderer?"
207 | }
208 | ],
209 | "choices": [
210 | { "label": "A", "text": "James Lenk" },
211 | { "label": "B", "text": "Dean Strang" },
212 | { "label": "C", "text": "Jerry Buting" },
213 | { "label": "D", "text": "Kathleen Zellner" },
214 | { "label": "E", "text": "Bobby Dassey" }
215 | ],
216 | "explanation": [],
217 | "variant": 1
218 | }
219 | ]
220 | }
221 |
--------------------------------------------------------------------------------
/static/icon.icns:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/exam-simulator/simulator/ff2529ca967ee69790701967525564a13442fa44/static/icon.icns
--------------------------------------------------------------------------------
/static/icon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/exam-simulator/simulator/ff2529ca967ee69790701967525564a13442fa44/static/icon.ico
--------------------------------------------------------------------------------
/static/options.json:
--------------------------------------------------------------------------------
1 | {
2 | "timer": true,
3 | "textSize": 18
4 | }
5 |
--------------------------------------------------------------------------------
/test/hooks.js:
--------------------------------------------------------------------------------
1 |
2 | const Application = require('spectron').Application
3 | const chai = require('chai')
4 | const chaiAsPromised = require('chai-as-promised')
5 | const electron = require('electron')
6 | const path = require('path')
7 |
8 | global.before(() => {
9 | chai.should();
10 | chai.use(chaiAsPromised);
11 | })
12 |
13 | module.exports = {
14 | async startApp() {
15 | const app = await new Application({
16 | path: electron,
17 | args: [path.join(__dirname, '..')]
18 | }).start()
19 | chaiAsPromised.transferPromiseness = app.transferPromiseness
20 | return app
21 | },
22 |
23 | async stopApp(app) {
24 | if (app && app.isRunning()) {
25 | await app.stop()
26 | }
27 | }
28 | }
--------------------------------------------------------------------------------
/test/mocha.opts:
--------------------------------------------------------------------------------
1 | --timeout 10000 --slow 150
--------------------------------------------------------------------------------
/test/spec.js:
--------------------------------------------------------------------------------
1 | const hooks = require('./hooks')
2 |
3 | const examItem = '[data-test="Demo Exam"]'
4 | const startExam = '[data-test="Start Exam"]'
5 | const confirmStart = 'div.action.confirm'
6 | const questionItem = '[data-test="Question"]'
7 | const gridItem = '[data-test="Grid Item 1"]'
8 | const timer = '[data-test="Timer"]'
9 |
10 | describe('Exam Simulator', function() {
11 | let app
12 |
13 | before(async function() {
14 | app = await hooks.startApp()
15 | })
16 |
17 | after(async function() {
18 | await hooks.stopApp(app)
19 | })
20 |
21 | it('opens a window', async function() {
22 | await app.client.waitUntilWindowLoaded()
23 | .getWindowCount().should.eventually.equal(1)
24 | .browserWindow.isMinimized().should.eventually.be.false
25 | .browserWindow.isVisible().should.eventually.be.true
26 | .browserWindow.getBounds().should.eventually.have.property('width').and.be.above(0)
27 | .browserWindow.getBounds().should.eventually.have.property('height').and.be.above(0)
28 | })
29 |
30 | it('displays a title', async function() {
31 | await app.browserWindow.getTitle().should.eventually.equal('Exam Simulator')
32 | })
33 |
34 | it('displays a demo exam item', async function() {
35 | await app.client.element(examItem)
36 | .element('.title').getText().should.eventually.equal('Demo Exam')
37 | })
38 |
39 | it('displays a cover screen', async function() {
40 | await app.client.element(examItem).click()
41 | await app.client.element(startExam).getText().should.eventually.equal('Start Exam')
42 | })
43 |
44 | it('displays a dialog before starting exam', async function() {
45 | await app.client.element(startExam).click()
46 | await app.client.element(confirmStart).getText().should.eventually.equal('START EXAM')
47 | })
48 |
49 | it('runs an exam file', async function() {
50 | await app.client.element(confirmStart).click()
51 | await app.client.element(questionItem).should.eventually.exist
52 | })
53 |
54 | it('displays navigation', async function() {
55 | await app.client.element(gridItem).getText().should.eventually.equal('2')
56 | })
57 |
58 | it('navigates to correct question', async function() {
59 | await app.client.element(gridItem).click()
60 | await app.client.pause(1000)
61 | await app.client.element(questionItem).getHTML(false).toString().includes('Who is the lead prosecutor in Season One of the Netflix series Making a Murderer?')
62 | })
63 |
64 | it('displays a working timer', async function() {
65 | await app.client.element(timer).getText().should.eventually.equal('9:59')
66 | await app.client.pause(2000)
67 | await app.client.element(timer).getText().should.eventually.equal('9:57')
68 | })
69 | })
70 |
71 |
72 |
--------------------------------------------------------------------------------