Compare commits
63
Commits
24218218cf
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
972d2a9766 | ||
|
|
837c8cea2b | ||
|
|
c5663c4a56 | ||
|
|
5aae169a25 | ||
|
|
be04531a12 | ||
|
|
9bccd3a4f1 | ||
|
|
58deaee5ce | ||
|
|
348cdbf9d8 | ||
|
|
2ca3ae4761 | ||
|
|
32655c9fa8 | ||
|
|
8ab8a7e8db | ||
|
|
f74aba0e9c | ||
|
|
873d05e7cd | ||
|
|
253b0bc183 | ||
|
|
499707f946 | ||
|
|
9afc75763e | ||
|
|
4380d03d89 | ||
|
|
cc2e17e331 | ||
|
|
e90667a37e | ||
|
|
e68483ba41 | ||
|
|
a50af67315 | ||
|
|
db04550e1a | ||
|
|
8993a1e8a1 | ||
|
|
fe1f8dfc1d | ||
|
|
803775f462 | ||
|
|
c6b0e14dda | ||
|
|
eed5b1344a | ||
|
|
d380c745c9 | ||
|
|
9e53a54ac1 | ||
|
|
f7221fd930 | ||
|
|
971a82c6a1 | ||
|
|
b6bccf8ce3 | ||
|
|
4cc033ad90 | ||
|
|
50cd3f10cf | ||
|
|
02162d6aea | ||
|
|
01514c4ea4 | ||
|
|
2f88e101e9 | ||
|
|
a8aac638da | ||
|
|
87bc50d40b | ||
|
|
9a9aa5d56c | ||
|
|
7abaee97c4 | ||
|
|
10c1aacd65 | ||
|
|
7ebba5cdb2 | ||
|
|
2f6e3a8701 | ||
|
|
64b496988f | ||
|
|
ff94c4db93 | ||
|
|
57e76c10fa | ||
|
|
d284c91173 | ||
|
|
8c3fb74a25 | ||
|
|
fe6f398424 | ||
|
|
a8b6100018 | ||
|
|
2df520f83a | ||
|
|
70647d162b | ||
|
|
c8515b51c7 | ||
|
|
16bbd58580 | ||
|
|
e52ee72b92 | ||
|
|
0a93c8c006 | ||
|
|
8d1d3d62d7 | ||
|
|
e5e36e03fd | ||
|
|
aeac202a3c | ||
|
|
0863b4e311 | ||
|
|
525777107b | ||
|
|
6902b1f8ea |
Vendored
+6
-3
@@ -1,8 +1,11 @@
|
||||
{
|
||||
"editor.autoIndent": "keep",
|
||||
"javascript.format.insertSpaceAfterFunctionKeywordForAnonymousFunctions": false,
|
||||
"javascript.format.insertSpaceAfterKeywordsInControlFlowStatements": false,
|
||||
"javascript.format.insertSpaceAfterKeywordsInControlFlowStatements": true,
|
||||
"javascript.format.insertSpaceAfterOpeningAndBeforeClosingNonemptyBraces": false,
|
||||
"javascript.format.enable": false,
|
||||
"liveServer.settings.port": 5502
|
||||
"javascript.format.enable": true,
|
||||
"liveServer.settings.port": 5502,
|
||||
"javascript.format.insertSpaceAfterConstructor": true,
|
||||
"javascript.format.insertSpaceBeforeFunctionParenthesis": false,
|
||||
"javascript.format.insertSpaceAfterOpeningAndBeforeClosingEmptyBraces": false
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
/* Set some default CSS */
|
||||
html,
|
||||
body,
|
||||
div,
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
/* Some fancy loading CSS */
|
||||
.sk-cube-grid {
|
||||
width: 60px;
|
||||
height: 60px;
|
||||
}
|
||||
|
||||
.sk-cube-grid .sk-cube {
|
||||
width: 33%;
|
||||
height: 33%;
|
||||
background-color: #4527A0;
|
||||
float: left;
|
||||
-webkit-animation: sk-cubeGridScaleDelay 1.3s infinite ease-in-out;
|
||||
animation: sk-cubeGridScaleDelay 1.3s infinite ease-in-out;
|
||||
}
|
||||
|
||||
.sk-cube-grid .sk-cube1 {
|
||||
-webkit-animation-delay: 0.2s;
|
||||
animation-delay: 0.2s;
|
||||
}
|
||||
|
||||
.sk-cube-grid .sk-cube2 {
|
||||
-webkit-animation-delay: 0.3s;
|
||||
animation-delay: 0.3s;
|
||||
}
|
||||
|
||||
.sk-cube-grid .sk-cube3 {
|
||||
-webkit-animation-delay: 0.4s;
|
||||
animation-delay: 0.4s;
|
||||
}
|
||||
|
||||
.sk-cube-grid .sk-cube4 {
|
||||
-webkit-animation-delay: 0.1s;
|
||||
animation-delay: 0.1s;
|
||||
}
|
||||
|
||||
.sk-cube-grid .sk-cube5 {
|
||||
-webkit-animation-delay: 0.2s;
|
||||
animation-delay: 0.2s;
|
||||
}
|
||||
|
||||
.sk-cube-grid .sk-cube6 {
|
||||
-webkit-animation-delay: 0.3s;
|
||||
animation-delay: 0.3s;
|
||||
}
|
||||
|
||||
.sk-cube-grid .sk-cube7 {
|
||||
-webkit-animation-delay: 0s;
|
||||
animation-delay: 0s;
|
||||
}
|
||||
|
||||
.sk-cube-grid .sk-cube8 {
|
||||
-webkit-animation-delay: 0.1s;
|
||||
animation-delay: 0.1s;
|
||||
}
|
||||
|
||||
.sk-cube-grid .sk-cube9 {
|
||||
-webkit-animation-delay: 0.2s;
|
||||
animation-delay: 0.2s;
|
||||
}
|
||||
|
||||
@-webkit-keyframes sk-cubeGridScaleDelay {
|
||||
|
||||
0%,
|
||||
70%,
|
||||
100% {
|
||||
-webkit-transform: scale3D(1, 1, 1);
|
||||
transform: scale3D(1, 1, 1);
|
||||
}
|
||||
|
||||
35% {
|
||||
-webkit-transform: scale3D(0, 0, 1);
|
||||
transform: scale3D(0, 0, 1);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes sk-cubeGridScaleDelay {
|
||||
|
||||
0%,
|
||||
70%,
|
||||
100% {
|
||||
-webkit-transform: scale3D(1, 1, 1);
|
||||
transform: scale3D(1, 1, 1);
|
||||
}
|
||||
|
||||
35% {
|
||||
-webkit-transform: scale3D(0, 0, 1);
|
||||
transform: scale3D(0, 0, 1);
|
||||
}
|
||||
}
|
||||
+460
-98
@@ -1,7 +1,8 @@
|
||||
@charset "UTF-8";
|
||||
@import "./base.css";
|
||||
@import "./loading.css";
|
||||
@import url("https://fonts.googleapis.com/css?family=Noto+Sans:400,700");
|
||||
/* Colourscheme from https://www.materialui.co/colors */
|
||||
/* Colourscheme from https://www.materialui.co/colors (as per original) */
|
||||
|
||||
* {
|
||||
-webkit-font-smoothing: antialiased;
|
||||
@@ -50,7 +51,7 @@ html {
|
||||
|
||||
html,
|
||||
body,
|
||||
input:not([type=text]),
|
||||
input:not([type=text]):not([type=number]), /* TODO: check it works in safari */
|
||||
button,
|
||||
select,
|
||||
option {
|
||||
@@ -72,8 +73,6 @@ textarea {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
color: black;
|
||||
/*#1d3850;*/
|
||||
/*font-family: 'Noto Sans', sans-serif;*/
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
@@ -239,6 +238,7 @@ td {
|
||||
box-shadow: none;
|
||||
text-shadow: none;
|
||||
flex-shrink: 0;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.nav-left {
|
||||
@@ -272,7 +272,7 @@ td {
|
||||
border-style: dashed;
|
||||
}
|
||||
|
||||
/*@media screen and (orientation:landscape) {*/
|
||||
/* LANDSCAPE */
|
||||
@media screen and (min-aspect-ratio:12/9) {
|
||||
.exam-content-panel {
|
||||
display: flex;
|
||||
@@ -339,7 +339,7 @@ td {
|
||||
}
|
||||
}
|
||||
|
||||
/*@media screen and (orientation:portrait) {*/
|
||||
/* PORTRAIT*/
|
||||
@media screen and (max-aspect-ratio:12/9) {
|
||||
.exam-content-panel {
|
||||
display: block;
|
||||
@@ -473,6 +473,7 @@ td {
|
||||
overflow: hidden;
|
||||
resize: none;
|
||||
margin-bottom: 1.6rem;
|
||||
border-radius: 0.3em;
|
||||
}
|
||||
|
||||
.long-answer-marked {
|
||||
@@ -498,6 +499,20 @@ td {
|
||||
box-shadow: 0 0 0 3px #4527A0;
|
||||
}
|
||||
|
||||
/* .short-option-answer {
|
||||
width: 100%;
|
||||
display: block;
|
||||
border: 1px solid #4527A0;
|
||||
margin: 0.8rem 1.6rem 1.6rem 1.6rem;
|
||||
overflow: hidden;
|
||||
resize: none;
|
||||
padding: 0.4rem;
|
||||
}
|
||||
|
||||
.short-option-answer:focus {
|
||||
box-shadow: 0 0 0 3px #4527A0;
|
||||
} */
|
||||
|
||||
img {
|
||||
display: block;
|
||||
padding: 0;
|
||||
@@ -548,6 +563,71 @@ img {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* Finish dialog styling */
|
||||
.finish-header {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding-top: 6vh;
|
||||
pointer-events: none;
|
||||
}
|
||||
.login-risr-box {
|
||||
display: inline-block;
|
||||
}
|
||||
.login-risr-logo {
|
||||
height: 64px;
|
||||
width: auto;
|
||||
user-drag: none;
|
||||
}
|
||||
.finish-dialog {
|
||||
display: inline-block;
|
||||
background-color: #1b1b1b;
|
||||
color: #fff;
|
||||
margin: 3vh auto;
|
||||
padding: 1.6rem;
|
||||
border-radius: 8px;
|
||||
max-width: 520px;
|
||||
box-shadow: 0 8px 24px rgba(0,0,0,0.6);
|
||||
text-align: left;
|
||||
}
|
||||
.logout-title {
|
||||
font-size: 1.4rem;
|
||||
font-weight: 700;
|
||||
margin-bottom: 0.8rem;
|
||||
}
|
||||
.logout-error.info {
|
||||
background-color: #e3f2fd;
|
||||
color: #0d47a1;
|
||||
border: 1px solid rgba(13,71,161,0.15);
|
||||
padding: 0.8rem;
|
||||
border-radius: 4px;
|
||||
margin-bottom: 0.8rem;
|
||||
}
|
||||
.logout-text {
|
||||
margin-bottom: 0.8rem;
|
||||
color: #ddd;
|
||||
}
|
||||
.logout-buttons {
|
||||
display: flex;
|
||||
gap: 0.6rem;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
.logout-button {
|
||||
padding: 0.6rem 1rem;
|
||||
border-radius: 4px;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
font-weight: 600;
|
||||
}
|
||||
.logout-button.danger {
|
||||
background: #b71c1c;
|
||||
color: white;
|
||||
}
|
||||
.logout-button.primary {
|
||||
background: #1976d2;
|
||||
color: white;
|
||||
}
|
||||
|
||||
|
||||
.dialog {
|
||||
/*display: inline-block;*/
|
||||
display: none;
|
||||
@@ -790,6 +870,7 @@ select option:disabled {
|
||||
border-radius: 5px;
|
||||
color: #311B92;
|
||||
margin: 0 .8rem .8rem 0;
|
||||
padding: 1rem 1.6rem;
|
||||
box-shadow: none;
|
||||
text-shadow: none;
|
||||
flex: 0 0 auto;
|
||||
@@ -921,94 +1002,6 @@ select option:disabled {
|
||||
content: "⏸"
|
||||
}
|
||||
|
||||
.sk-cube-grid {
|
||||
width: 60px;
|
||||
height: 60px;
|
||||
}
|
||||
|
||||
.sk-cube-grid .sk-cube {
|
||||
width: 33%;
|
||||
height: 33%;
|
||||
background-color: #4527A0;
|
||||
float: left;
|
||||
-webkit-animation: sk-cubeGridScaleDelay 1.3s infinite ease-in-out;
|
||||
animation: sk-cubeGridScaleDelay 1.3s infinite ease-in-out;
|
||||
}
|
||||
|
||||
.sk-cube-grid .sk-cube1 {
|
||||
-webkit-animation-delay: 0.2s;
|
||||
animation-delay: 0.2s;
|
||||
}
|
||||
|
||||
.sk-cube-grid .sk-cube2 {
|
||||
-webkit-animation-delay: 0.3s;
|
||||
animation-delay: 0.3s;
|
||||
}
|
||||
|
||||
.sk-cube-grid .sk-cube3 {
|
||||
-webkit-animation-delay: 0.4s;
|
||||
animation-delay: 0.4s;
|
||||
}
|
||||
|
||||
.sk-cube-grid .sk-cube4 {
|
||||
-webkit-animation-delay: 0.1s;
|
||||
animation-delay: 0.1s;
|
||||
}
|
||||
|
||||
.sk-cube-grid .sk-cube5 {
|
||||
-webkit-animation-delay: 0.2s;
|
||||
animation-delay: 0.2s;
|
||||
}
|
||||
|
||||
.sk-cube-grid .sk-cube6 {
|
||||
-webkit-animation-delay: 0.3s;
|
||||
animation-delay: 0.3s;
|
||||
}
|
||||
|
||||
.sk-cube-grid .sk-cube7 {
|
||||
-webkit-animation-delay: 0s;
|
||||
animation-delay: 0s;
|
||||
}
|
||||
|
||||
.sk-cube-grid .sk-cube8 {
|
||||
-webkit-animation-delay: 0.1s;
|
||||
animation-delay: 0.1s;
|
||||
}
|
||||
|
||||
.sk-cube-grid .sk-cube9 {
|
||||
-webkit-animation-delay: 0.2s;
|
||||
animation-delay: 0.2s;
|
||||
}
|
||||
|
||||
@-webkit-keyframes sk-cubeGridScaleDelay {
|
||||
|
||||
0%,
|
||||
70%,
|
||||
100% {
|
||||
-webkit-transform: scale3D(1, 1, 1);
|
||||
transform: scale3D(1, 1, 1);
|
||||
}
|
||||
|
||||
35% {
|
||||
-webkit-transform: scale3D(0, 0, 1);
|
||||
transform: scale3D(0, 0, 1);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes sk-cubeGridScaleDelay {
|
||||
|
||||
0%,
|
||||
70%,
|
||||
100% {
|
||||
-webkit-transform: scale3D(1, 1, 1);
|
||||
transform: scale3D(1, 1, 1);
|
||||
}
|
||||
|
||||
35% {
|
||||
-webkit-transform: scale3D(0, 0, 1);
|
||||
transform: scale3D(0, 0, 1);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
.displayblock {
|
||||
@@ -1145,7 +1138,7 @@ select option:disabled {
|
||||
}
|
||||
}
|
||||
|
||||
#submit-error-overlay {
|
||||
#view-answers-overlay {
|
||||
opacity: 0.9;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
@@ -1158,6 +1151,78 @@ select option:disabled {
|
||||
z-index: 5000;
|
||||
}
|
||||
|
||||
#submit-error-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background-color: rgba(0, 0, 0, 0.85);
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
z-index: 10000;
|
||||
user-select: text;
|
||||
-moz-user-select: text;
|
||||
-webkit-user-select: text;
|
||||
overflow-y: auto;
|
||||
padding: 20px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
#submit-error-overlay .error-card {
|
||||
background-color: #1a1a1a;
|
||||
border: 1px solid #c62828;
|
||||
border-radius: 8px;
|
||||
max-width: 600px;
|
||||
width: 100%;
|
||||
padding: 25px;
|
||||
box-shadow: 0 10px 25px rgba(0,0,0,0.5);
|
||||
color: #eee;
|
||||
font-family: sans-serif;
|
||||
}
|
||||
|
||||
#submit-error-overlay .error-card h3 {
|
||||
margin-top: 0;
|
||||
color: #ef5350;
|
||||
font-size: 20px;
|
||||
border-bottom: 1px solid #333;
|
||||
padding-bottom: 10px;
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
#submit-error-overlay .error-card button {
|
||||
cursor: pointer;
|
||||
background-color: #311B92;
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 10px 18px;
|
||||
font-size: 14px;
|
||||
border-radius: 4px;
|
||||
transition: background-color 0.2s;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
#submit-error-overlay .error-card button:hover {
|
||||
background-color: #4527A0;
|
||||
}
|
||||
|
||||
#submit-error-overlay .error-card button.btn-secondary {
|
||||
background-color: #444;
|
||||
}
|
||||
|
||||
#submit-error-overlay .error-card button.btn-secondary:hover {
|
||||
background-color: #555;
|
||||
}
|
||||
|
||||
#submit-error-overlay .error-card button.btn-danger {
|
||||
background-color: #c62828;
|
||||
}
|
||||
|
||||
#submit-error-overlay .error-card button.btn-danger:hover {
|
||||
background-color: #d32f2f;
|
||||
}
|
||||
|
||||
.cache-out-of-date {
|
||||
color: red;
|
||||
}
|
||||
@@ -1187,12 +1252,26 @@ select option:disabled {
|
||||
.invalid-login {
|
||||
color: red;
|
||||
}
|
||||
|
||||
input[type="number"] {
|
||||
.invalid-login::before {
|
||||
content: "Invalid login";
|
||||
color: red;
|
||||
font-size: 1rem;
|
||||
font-weight: 900;
|
||||
border: 0.25rem solid red;
|
||||
display: inline-block;
|
||||
text-transform: uppercase;
|
||||
border-radius: 1rem;
|
||||
font-family: 'Courier';
|
||||
opacity: 80%;
|
||||
margin-top: 3em;
|
||||
padding: 0.25rem 1rem;
|
||||
position: absolute;
|
||||
}
|
||||
/* input[type="number"] {
|
||||
user-select: text;
|
||||
-moz-user-select: text;
|
||||
-webkit-user-select: text;
|
||||
}
|
||||
} */
|
||||
|
||||
.feedback-image {
|
||||
max-width: 100%;
|
||||
@@ -1204,4 +1283,287 @@ input[type="button"], button {
|
||||
|
||||
.login-button {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
h2:has(+ #exam-list:empty), h2:has(+ #packet-list:empty){
|
||||
opacity: 30%;
|
||||
}
|
||||
|
||||
#exam-list:empty::after, #packet-list:empty::after{
|
||||
content: "None available.";
|
||||
opacity: 30%;
|
||||
}
|
||||
|
||||
#viewer-title-bar {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 0.8rem 1.6rem;
|
||||
background-color: gray;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.title-bar-button {
|
||||
display: inline-flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
border: none;
|
||||
margin: 0 0 0 2px;
|
||||
background-color: gray;
|
||||
color: white;
|
||||
border-left: 1px solid white;
|
||||
}
|
||||
|
||||
.viewer-icon {
|
||||
margin: auto;
|
||||
}
|
||||
|
||||
.title-bar-button:hover {
|
||||
background-color: darkgray;
|
||||
}
|
||||
|
||||
/* Sync Status Styles */
|
||||
#sync-status {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 500;
|
||||
color: rgba(255, 255, 255, 0.85);
|
||||
margin: auto 0.8rem;
|
||||
padding: 0.3rem 0.6rem;
|
||||
border-radius: 4px;
|
||||
background-color: rgba(255, 255, 255, 0.05);
|
||||
transition: background-color 0.3s ease;
|
||||
}
|
||||
|
||||
.sync-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.sync-dot.saved {
|
||||
background-color: #4CAF50;
|
||||
box-shadow: 0 0 8px #4CAF50;
|
||||
}
|
||||
|
||||
.sync-dot.syncing {
|
||||
background-color: #FFC107;
|
||||
box-shadow: 0 0 8px #FFC107;
|
||||
animation: pulse-syncing 1.2s infinite ease-in-out;
|
||||
}
|
||||
|
||||
.sync-dot.failed {
|
||||
background-color: #F44336;
|
||||
box-shadow: 0 0 8px #F44336;
|
||||
animation: pulse-failed 1s infinite alternate;
|
||||
}
|
||||
|
||||
.sync-dot.practice {
|
||||
background-color: #2196F3;
|
||||
box-shadow: 0 0 8px #2196F3;
|
||||
}
|
||||
|
||||
.sync-text {
|
||||
letter-spacing: 0.2px;
|
||||
}
|
||||
|
||||
@keyframes pulse-syncing {
|
||||
0% {
|
||||
transform: scale(0.85);
|
||||
opacity: 0.6;
|
||||
}
|
||||
50% {
|
||||
transform: scale(1.15);
|
||||
opacity: 1;
|
||||
}
|
||||
100% {
|
||||
transform: scale(0.85);
|
||||
opacity: 0.6;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes pulse-failed {
|
||||
from {
|
||||
box-shadow: 0 0 4px #F44336;
|
||||
}
|
||||
to {
|
||||
box-shadow: 0 0 12px #F44336;
|
||||
}
|
||||
}
|
||||
|
||||
.refresh-button {
|
||||
background-color: transparent;
|
||||
border: 1px solid #555;
|
||||
border-radius: 3px;
|
||||
color: #bbb;
|
||||
padding: 1px 6px;
|
||||
font-size: 10px;
|
||||
cursor: pointer;
|
||||
margin-left: 10px;
|
||||
vertical-align: middle;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.refresh-button:hover {
|
||||
background-color: #333;
|
||||
border-color: #888;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.global-refresh-container {
|
||||
margin-bottom: 20px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 15px;
|
||||
}
|
||||
|
||||
.exit-exam-mode {
|
||||
position: fixed;
|
||||
bottom: 20px;
|
||||
right: 20px;
|
||||
background-color: #b71c1c;
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 10px 15px;
|
||||
border-radius: 5px;
|
||||
cursor: pointer;
|
||||
z-index: 10000;
|
||||
}
|
||||
|
||||
/* Options and Cache Tidy Up */
|
||||
.options-details, .cache-details-box {
|
||||
background: #181818;
|
||||
border: 1px solid #333;
|
||||
border-radius: 6px;
|
||||
margin-bottom: 15px;
|
||||
padding: 12px 18px;
|
||||
font-family: sans-serif;
|
||||
}
|
||||
|
||||
.options-details summary, .cache-details-box summary {
|
||||
font-weight: bold;
|
||||
cursor: pointer;
|
||||
font-size: 15px;
|
||||
outline: none;
|
||||
color: #e0e0e0;
|
||||
}
|
||||
|
||||
.options-details summary:hover, .cache-details-box summary:hover {
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.options-details[open], .cache-details-box[open] {
|
||||
border-color: #444;
|
||||
}
|
||||
|
||||
.options-details[open] summary, .cache-details-box[open] summary {
|
||||
border-bottom: 1px solid #333;
|
||||
padding-bottom: 8px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.database-btn-group {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
.database-btn-group input[type="button"] {
|
||||
background: #2a2a2a;
|
||||
color: #eee;
|
||||
border: 1px solid #444;
|
||||
padding: 8px 14px;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
font-weight: bold;
|
||||
transition: background-color 0.2s, border-color 0.2s;
|
||||
}
|
||||
|
||||
.database-btn-group input[type="button"]:hover {
|
||||
background: #3a3a3a;
|
||||
border-color: #666;
|
||||
}
|
||||
|
||||
.database-btn-group input#btn-delete-answer-databases {
|
||||
background: #5f0909;
|
||||
border-color: #8c0d0d;
|
||||
color: #ffcdd2;
|
||||
}
|
||||
|
||||
.database-btn-group input#btn-delete-answer-databases:hover {
|
||||
background: #8c0d0d;
|
||||
border-color: #b71c1c;
|
||||
}
|
||||
|
||||
.database-btn-group input#btn-reset-local {
|
||||
background: #5d2e00;
|
||||
border-color: #8c4600;
|
||||
color: #ffe0b2;
|
||||
}
|
||||
|
||||
.database-btn-group input#btn-reset-local:hover {
|
||||
background: #8c4600;
|
||||
border-color: #e65100;
|
||||
}
|
||||
|
||||
.local-loader-field {
|
||||
border: 1px solid #333;
|
||||
padding: 15px;
|
||||
border-radius: 6px;
|
||||
background: #111;
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
.local-loader-field h2 {
|
||||
margin-top: 0;
|
||||
font-size: 14px;
|
||||
color: #ccc;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.local-loader-field input[type="file"] {
|
||||
background: #222;
|
||||
border: 1px solid #333;
|
||||
color: #eee;
|
||||
padding: 4px;
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.local-loader-field input[type="button"] {
|
||||
background: #311B92;
|
||||
color: #fff;
|
||||
border: none;
|
||||
padding: 6px 12px;
|
||||
border-radius: 4px;
|
||||
font-weight: bold;
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
margin-left: 5px;
|
||||
}
|
||||
|
||||
.local-loader-field input[type="button"]:hover {
|
||||
background: #4527A0;
|
||||
}
|
||||
|
||||
.view-answers-btn {
|
||||
background: #2e7d32;
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 10px 16px;
|
||||
border-radius: 4px;
|
||||
font-size: 13px;
|
||||
font-weight: bold;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.2s;
|
||||
}
|
||||
|
||||
.view-answers-btn:hover {
|
||||
background: #388e3c;
|
||||
}
|
||||
+410
-186
@@ -1,233 +1,457 @@
|
||||
<!DOCTYPE html>
|
||||
<html xmlns="http://www.w3.org/1999/xhtml" xml:lang="en" class="no-touch" lang="en">
|
||||
|
||||
<head>
|
||||
<meta http-equiv="content-type" content="text/html; charset=UTF-8">
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport"
|
||||
content="width=device-width, initial-scale=1, minimum-scale=1, maximum-scale=1, user-scalable=no">
|
||||
<meta name="viewport" content="initial-scale=1.0, maximum-scale=1.0, width=device-width, height=device-height">
|
||||
<link type="text/css" rel="stylesheet" href="lib/jquery.modal.min.css">
|
||||
<link type="text/css" rel="stylesheet" href="lib/cornerstone.min.css">
|
||||
<link type="text/css" rel="stylesheet" href="css/main.css">
|
||||
<!doctype html>
|
||||
<html
|
||||
xmlns="http://www.w3.org/1999/xhtml"
|
||||
xml:lang="en"
|
||||
class="no-touch"
|
||||
lang="en"
|
||||
>
|
||||
<head>
|
||||
<meta http-equiv="content-type" content="text/html; charset=UTF-8" />
|
||||
<meta charset="UTF-8" />
|
||||
<meta
|
||||
name="viewport"
|
||||
content="width=device-width, initial-scale=1, minimum-scale=1, maximum-scale=1, user-scalable=no"
|
||||
/>
|
||||
<meta
|
||||
name="viewport"
|
||||
content="initial-scale=1.0, maximum-scale=1.0, width=device-width, height=device-height"
|
||||
/>
|
||||
<link type="text/css" rel="stylesheet" href="lib/jquery.modal.min.css" />
|
||||
<link type="text/css" rel="stylesheet" href="lib/cornerstone.min.css" />
|
||||
<link type="text/css" rel="stylesheet" href="css/main.css" />
|
||||
<title>RTS</title>
|
||||
</head>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<body>
|
||||
<div id="content">
|
||||
<div class="content-panel">
|
||||
<div class="nav-bar">
|
||||
<button id="review-button" class="navigation nav-right"><b>☰</b></button>
|
||||
<button id="nav-submit-button" class="submit-button navigation nav-right"><b>submit</b></button>
|
||||
<!--<button id="options-button" class="navigation nav-right"
|
||||
title="click to load new packet or manage saved answers">options</button>-->
|
||||
<button id="review-overlay-button" class="navigation nav-right"
|
||||
title="click to finish exam and review answers">review</button>
|
||||
<button id="fullscreen-overlay-button" class="navigation nav-right"
|
||||
title="click to toggle fullscreen">fullscreen</button>
|
||||
<!--<button id="logout-button" class="navigation nav-right">logout</button>-->
|
||||
<div class="app-name nav-left">RTS</div>
|
||||
<div id="timer" title="click to pause"></div>
|
||||
<div class="exam-name" title="currently loaded packet"></div>
|
||||
<input type="button" class="navigation nav-left" value="previous" disabled="disabled">
|
||||
<input type="button" class="navigation nav-left" value="next">
|
||||
</div>
|
||||
<div class="main-panel">
|
||||
<div class="question-list-panel" style="display: block;">
|
||||
</div>
|
||||
<div class="exam-panel">
|
||||
<div class="exam-content-panel">
|
||||
<div class="question">
|
||||
<div class="title"><span style="font-weight:700;">1</span> </div>
|
||||
<div class="stem">
|
||||
<div id="thumbnails"></div>
|
||||
</div>
|
||||
<div class="thumbs">
|
||||
</div>
|
||||
</div>
|
||||
<div class="answers">
|
||||
<div class="answer-title">Answer</div>
|
||||
<div class="answer-panel">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="logout-background">
|
||||
<div class="logout-panel">
|
||||
<div class="logout-title">Ready to logout?</div>
|
||||
<div class="logout-text"></div>
|
||||
<button class="navigation logout-cancel">continue exam</button>
|
||||
<button class="navigation logout-okay">logout now</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="options-panel" class="fullscreen-overlay">
|
||||
<h1>RTS</h1>
|
||||
<input type='button' id='btn-candidate-login' class="login-button" value='Enter candidate details' onclick='void(0)' ><span
|
||||
id="candidate-details"></span>
|
||||
<input type='button' id='btn-user-login' class="login-button" value='User log in'>
|
||||
<!-- <a href="#" id="overlay-close" class="close"></a> -->
|
||||
<div id="user" class="user-text"></div>
|
||||
<div id="packets">
|
||||
<p>Click buttons below to load an exam / packet</p>
|
||||
<span class="exam-wrapper">
|
||||
<h2>Available Exams:</h2>
|
||||
<span id="exam-list">
|
||||
|
||||
</span>
|
||||
</span>
|
||||
|
||||
<h2>Available Packets:</h2>
|
||||
<span id="packet-list">
|
||||
</span>
|
||||
</div>
|
||||
<div id="options-link">
|
||||
</div>
|
||||
<div id="storage-details">
|
||||
</div>
|
||||
<div id="cache-details">
|
||||
<details>
|
||||
<summary></summary>
|
||||
<ul>
|
||||
</ul>
|
||||
</details>
|
||||
</div>
|
||||
<details>
|
||||
<summary>Options</summary>
|
||||
<div id="database-buttons">
|
||||
<input type='button' id='btn-delete-answer-databases' value='Delete local answers'>
|
||||
<input type='button' id='btn-delete-cached-questions' value='Delete cached questions'>
|
||||
<input type='button' id='btn-reset-local' value='Clear all local data'>
|
||||
</div>
|
||||
<div id="local-fileloader">
|
||||
<form id="jsonFile" name="jsonFile" enctype="multipart/form-data" method="post">
|
||||
|
||||
<fieldset>
|
||||
<h2>Load local question set</h2>
|
||||
<input type='file' id='fileinput'>
|
||||
<input type='button' id='btn-local-file-load' value='Load'>
|
||||
</fieldset>
|
||||
</form>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
<div id="review-overlay" class="fullscreen-overlay">
|
||||
<a href="#" id="review-overlay-close" class="close"></a>
|
||||
<div id="review-score"></div>
|
||||
<div id="exam-stats">
|
||||
<p>Performance breakdown:</p>
|
||||
Unanswered questions: <span id="unanswered-number"></span><br />
|
||||
Answered normal: <span id="normal-number"></span><br />
|
||||
Answered abnormal: <span id="abnormal-number"></span><br />
|
||||
<br />
|
||||
Undercall: <span id="undercall-number"></span><br />
|
||||
Overcall: <span id="overcall-number"></span><br />
|
||||
Incorrect call: <span id="incorrectcall-number"></span><br />
|
||||
</div>
|
||||
<ul id='review-answer-list'></ul>
|
||||
<table id='review-answer-table'></table>
|
||||
</div>
|
||||
<div class="content-panel">
|
||||
<div class="nav-bar">
|
||||
<button id="btn-review" class="navigation nav-right">
|
||||
<b>☰</b><br />OVERVIEW
|
||||
</button>
|
||||
<button
|
||||
id="btn-submit-nav"
|
||||
class="submit-button navigation nav-right"
|
||||
>
|
||||
<b>FINISH</b>
|
||||
</button>
|
||||
<button
|
||||
id="btn-review-overlay"
|
||||
class="navigation nav-right"
|
||||
title="click to finish exam and review answers"
|
||||
>
|
||||
REVIEW
|
||||
</button>
|
||||
<button
|
||||
id="btn-fullscreen-overlay"
|
||||
class="navigation nav-right"
|
||||
title="click to toggle fullscreen"
|
||||
>
|
||||
FULLSCREEN
|
||||
</button>
|
||||
<div class="app-name nav-left">RTS</div>
|
||||
<div id="sync-status" class="nav-left"></div>
|
||||
<div id="timer" title="click to pause"></div>
|
||||
<div class="exam-name" title="currently loaded packet"></div>
|
||||
<button
|
||||
class="navigation nav-left"
|
||||
disabled="disabled"
|
||||
data-nav="previous"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="16"
|
||||
height="16"
|
||||
fill="currentColor"
|
||||
class="bi bi-arrow-left-short"
|
||||
viewBox="0 0 16 16"
|
||||
>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
d="M12 8a.5.5 0 0 1-.5.5H5.707l2.147 2.146a.5.5 0 0 1-.708.708l-3-3a.5.5 0 0 1 0-.708l3-3a.5.5 0 1 1 .708.708L5.707 7.5H11.5a.5.5 0 0 1 .5.5"
|
||||
/>
|
||||
</svg>
|
||||
<br />
|
||||
<span>PREVIOUS</span>
|
||||
</button>
|
||||
<button class="navigation nav-left" data-nav="next">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="16"
|
||||
height="16"
|
||||
fill="currentColor"
|
||||
class="bi bi-arrow-right-short"
|
||||
viewBox="0 0 16 16"
|
||||
>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
d="M4 8a.5.5 0 0 1 .5-.5h5.793L8.146 5.354a.5.5 0 1 1 .708-.708l3 3a.5.5 0 0 1 0 .708l-3 3a.5.5 0 0 1-.708-.708L10.293 8.5H4.5A.5.5 0 0 1 4 8"
|
||||
/>
|
||||
</svg>
|
||||
<br />
|
||||
<span>NEXT</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="main-panel">
|
||||
<div class="question-list-panel" style="display: block"></div>
|
||||
<div class="exam-panel">
|
||||
<div class="exam-content-panel">
|
||||
<div class="question">
|
||||
<div class="title"><span style="font-weight: 700">1</span></div>
|
||||
<div class="stem">
|
||||
<div id="thumbnails"></div>
|
||||
</div>
|
||||
<div class="thumbs"></div>
|
||||
</div>
|
||||
<div class="answers">
|
||||
<div class="answer-title">Answer</div>
|
||||
<div class="answer-panel"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="logout-background"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="finish-title"
|
||||
>
|
||||
<div class="finish-header">
|
||||
<div class="login-risr-box">
|
||||
<img
|
||||
class="login-risr-logo"
|
||||
draggable="false"
|
||||
alt="Logo"
|
||||
src="risr-assess-on-dark.png"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="logout-panel finish-dialog" id="finish-dialog-panel">
|
||||
<div class="logout-title" id="finish-title">Ready to finish?</div>
|
||||
<div class="logout-error info">
|
||||
<p>
|
||||
<strong
|
||||
>Answers are submitted automatically. You can complete the
|
||||
exam by clicking below.</strong
|
||||
>
|
||||
</p>
|
||||
<p class="unanswered-line">
|
||||
You have <span id="unanswered-count">0</span> unanswered
|
||||
questions
|
||||
</p>
|
||||
</div>
|
||||
<div class="logout-text">
|
||||
Click “Submit exam” before closing the tab or window
|
||||
</div>
|
||||
<div class="logout-buttons">
|
||||
<button class="logout-button danger" id="button-logout">
|
||||
Submit exam
|
||||
</button>
|
||||
<button class="logout-button primary" id="button-continue">
|
||||
Continue exam
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="options-panel" class="fullscreen-overlay">
|
||||
<h1>RTS</h1>
|
||||
<span id="user-details">
|
||||
<span id="candidate-info">
|
||||
<span id="candidate-details"></span>
|
||||
</span>
|
||||
<span id="user-actions">
|
||||
<input
|
||||
type="button"
|
||||
id="btn-candidate-login"
|
||||
class="login-button"
|
||||
value="Enter candidate details"
|
||||
onclick="void 0"
|
||||
/>
|
||||
<input
|
||||
type="button"
|
||||
id="btn-user-login"
|
||||
class="login-button"
|
||||
value="User log in"
|
||||
/>
|
||||
</span>
|
||||
<!-- <a href="#" id="overlay-close" class="close"></a> -->
|
||||
<div id="user" class="user-text"></div>
|
||||
</span>
|
||||
<div id="packets">
|
||||
<div class="global-refresh-container">
|
||||
<span id="load-exam-packet-instructions">
|
||||
<span>Click buttons below to load an exam / packet</span>
|
||||
</span>
|
||||
<button
|
||||
id="btn-refresh-all"
|
||||
class="refresh-button"
|
||||
title="Refresh all exams and packets"
|
||||
>
|
||||
Refresh All
|
||||
</button>
|
||||
</div>
|
||||
<span class="exam-wrapper">
|
||||
<h2>Exams:</h2>
|
||||
<span id="exam-list"></span>
|
||||
</span>
|
||||
|
||||
<span class="packet-wrapper">
|
||||
<h2>
|
||||
Packets:
|
||||
<button
|
||||
id="btn-refresh-packets"
|
||||
class="refresh-button"
|
||||
title="Refresh packets"
|
||||
>
|
||||
Refresh
|
||||
</button>
|
||||
</h2>
|
||||
<span id="packet-list"></span>
|
||||
</span>
|
||||
</div>
|
||||
<div id="options-link"></div>
|
||||
<div id="storage-details" style="font-family: sans-serif; font-size: 13px; color: #aaa; margin: 15px 0 10px 0; padding-left: 5px;"></div>
|
||||
<div id="cache-details">
|
||||
<details class="cache-details-box">
|
||||
<summary>Cached Exams / Questions</summary>
|
||||
<ul style="margin: 10px 0 0 15px; padding: 0; font-size: 13px; color: #ccc;"></ul>
|
||||
</details>
|
||||
</div>
|
||||
<details class="options-details">
|
||||
<summary>Options</summary>
|
||||
<div id="database-buttons" class="database-btn-group">
|
||||
<input
|
||||
type="button"
|
||||
id="btn-delete-answer-databases"
|
||||
value="Delete local answers"
|
||||
/>
|
||||
<input
|
||||
type="button"
|
||||
id="btn-delete-cached-questions"
|
||||
value="Delete cached questions"
|
||||
/>
|
||||
<input
|
||||
type="button"
|
||||
id="btn-reset-local"
|
||||
value="Clear all local data"
|
||||
/>
|
||||
<details style="width: 100%; margin-top: 10px; font-size: 12px; border: 1px solid #444; border-radius: 4px; padding: 10px; background: #111; font-family: sans-serif;">
|
||||
<summary style="cursor: pointer; font-weight: bold; color: #aaa; outline: none; user-select: none;">Manual Reset Instructions</summary>
|
||||
<div style="margin-top: 8px; color: #bbb; line-height: 1.5;">
|
||||
<p style="margin: 0 0 8px 0;">If the automatic reset parameter fails, perform a manual reset in your browser:</p>
|
||||
<ol style="margin: 0; padding-left: 18px;">
|
||||
<li style="margin-bottom: 4px;">Open Developer Tools by pressing <strong>F12</strong> or <strong>Ctrl+Shift+I</strong> (Cmd+Option+I on Mac).</li>
|
||||
<li style="margin-bottom: 4px;">Navigate to the <strong>Application</strong> tab (Chrome/Edge) or <strong>Storage</strong> tab (Firefox).</li>
|
||||
<li style="margin-bottom: 4px;">Locate storage items and select <strong>Clear Site Data</strong> or delete entries under <em>IndexedDB</em>, <em>Local Storage</em>, and <em>Session Storage</em>.</li>
|
||||
<li>Reload the page.</li>
|
||||
</ol>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
<div id="local-fileloader" class="local-loader-field">
|
||||
<form
|
||||
id="jsonFile"
|
||||
name="jsonFile"
|
||||
enctype="multipart/form-data"
|
||||
method="post"
|
||||
>
|
||||
<fieldset style="border: none; padding: 0; margin: 0;">
|
||||
<h2>Load local question set</h2>
|
||||
<input type="file" id="fileinput" />
|
||||
<input type="button" id="btn-local-file-load" value="Load" />
|
||||
</fieldset>
|
||||
</form>
|
||||
</div>
|
||||
<p style="margin: 15px 0;">
|
||||
<button id="btn-view-local-answers" class="view-answers-btn">
|
||||
View local saved answers
|
||||
</button>
|
||||
</p>
|
||||
<div id="logging-controls" style="margin-top: 15px; border-top: 1px solid #333; padding-top: 15px; font-family: sans-serif; font-size: 13px;">
|
||||
<label for="select-log-level" style="font-weight: bold; color: #ccc;">Logging Level:</label>
|
||||
<select id="select-log-level" style="background:#222; color:#fff; border:1px solid #555; padding: 4px 8px; border-radius: 4px; font-size: 13px; margin-left: 5px; cursor: pointer; outline: none;">
|
||||
<option value="silent">Disabled (Silent)</option>
|
||||
<option value="error">Error</option>
|
||||
<option value="warn">Warn</option>
|
||||
<option value="info">Info</option>
|
||||
<option value="debug">Debug</option>
|
||||
</select>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
<div id="review-overlay" class="fullscreen-overlay">
|
||||
<a href="#" id="review-overlay-close" class="close"></a>
|
||||
<div id="review-score"></div>
|
||||
<div id="exam-stats">
|
||||
<p>Performance breakdown:</p>
|
||||
Unanswered questions: <span id="unanswered-number"></span><br />
|
||||
Answered normal: <span id="normal-number"></span><br />
|
||||
Answered abnormal: <span id="abnormal-number"></span><br />
|
||||
<br />
|
||||
Undercall: <span id="undercall-number"></span><br />
|
||||
Overcall: <span id="overcall-number"></span><br />
|
||||
Incorrect call: <span id="incorrectcall-number"></span><br />
|
||||
</div>
|
||||
<ul id="review-answer-list"></ul>
|
||||
<table id="review-answer-table"></table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="finish-dialog" class="dialog modal">
|
||||
<h3 class="dialog-title">Finish exam and review?</h3>
|
||||
<div class="dialog-text">
|
||||
<button id="finish-exam" class="navigation dialog-yes">Finish and review</button>
|
||||
<button id="finish-cancel" class="navigation dialog-cancel">Continue exam</button>
|
||||
</div>
|
||||
<h3 class="dialog-title">Finish exam and review?</h3>
|
||||
<div class="dialog-text">
|
||||
<button id="finish-exam" class="navigation dialog-yes">
|
||||
Finish and review
|
||||
</button>
|
||||
<button id="finish-cancel" class="navigation dialog-cancel">
|
||||
Continue exam
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="start-dialog" class="dialog modal">
|
||||
<h3 class="dialog-title">Start <span class="exam-name"></span></h3>
|
||||
<div class="packet-database-options">
|
||||
<input type='button' id='btn-delete-current' value='Reset current packet answers'><br />
|
||||
<input type='button' id='btn-delete-current-saved-answers' value='Reset current packet saved answers'>
|
||||
</div>
|
||||
<div class="dialog-text">
|
||||
Click to start exam.
|
||||
<p>You will have <input type='number' size=2 class='exam-time'></input> minutes.</p>
|
||||
</div>
|
||||
<div id="exam-candidate-number" class="dialog-text hidden">
|
||||
Check your below candidate number / user is correct.
|
||||
<input type='text' size=10 id='candidate-number2' disabled></input>
|
||||
</div>
|
||||
<button class="start-packet-button navigation dialog-yes">Start</button>
|
||||
<h3 class="dialog-title">Start <span class="exam-name"></span></h3>
|
||||
<div class="packet-database-options">
|
||||
<input
|
||||
type="button"
|
||||
id="btn-delete-current"
|
||||
value="Reset current packet answers"
|
||||
/><br />
|
||||
<input
|
||||
type="button"
|
||||
id="btn-delete-current-saved-answers"
|
||||
value="Reset current packet saved answers"
|
||||
/>
|
||||
</div>
|
||||
<div class="dialog-text">
|
||||
Click to start exam.
|
||||
<p>
|
||||
You will have
|
||||
<input type="number" size="2" class="exam-time" /> minutes.
|
||||
</p>
|
||||
</div>
|
||||
<div id="exam-candidate-number" class="dialog-text hidden">
|
||||
Check your below candidate number / user is correct.
|
||||
<input type="text" size="10" id="candidate-number2" disabled />
|
||||
</div>
|
||||
<button class="start-packet-button navigation dialog-yes">Start</button>
|
||||
</div>
|
||||
|
||||
<div id="time-up-dialog" class="dialog modal">
|
||||
<h3 class="dialog-title">Time ended</h3>
|
||||
<div class="dialog-text">
|
||||
Allocated time has ended. You can review or continue.
|
||||
</div>
|
||||
<button id="time-up-review-button" class="navigation dialog-review">Review</button>
|
||||
<button id="time-up-continue-button" class="navigation dialog-yes">Continue</button>
|
||||
<button id="dialog-submit-button" class="submit-button navigation dialog-review hidden"><b>submit</b></button>
|
||||
<h3 class="dialog-title">Time ended</h3>
|
||||
<div class="dialog-text">
|
||||
Allocated time has ended. You can review or continue.
|
||||
</div>
|
||||
<button id="btn-time-up-review" class="navigation dialog-review">
|
||||
Review
|
||||
</button>
|
||||
<button id="time-up-continue-button" class="navigation dialog-yes">
|
||||
Continue
|
||||
</button>
|
||||
<button
|
||||
id="dialog-submit-button"
|
||||
class="submit-button navigation dialog-review hidden"
|
||||
>
|
||||
<b>submit</b>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div id="login-dialog" class="dialog modal noclose">
|
||||
<h3 class="dialog-title">Enter Details</h3>
|
||||
<div class="dialog-text">
|
||||
Candidate number: <input type="number" id="candidate-number" name="candidate" required size="10"><br />
|
||||
Passcode: <input type="text" id="passcode" name="passcode" required size="10"><br />
|
||||
<h3 class="dialog-title">Enter Details</h3>
|
||||
<div class="dialog-text">
|
||||
Candidate number:
|
||||
<input
|
||||
type="number"
|
||||
id="candidate-number"
|
||||
name="candidate"
|
||||
required
|
||||
size="10"
|
||||
/><br />
|
||||
Passcode:
|
||||
<input
|
||||
type="text"
|
||||
id="passcode"
|
||||
name="passcode"
|
||||
required
|
||||
size="10"
|
||||
/><br />
|
||||
</div>
|
||||
<button id="btn-login" class="navigation dialog-yes">Login</button>
|
||||
</div>
|
||||
|
||||
<div id="resume-dialog" class="dialog modal noclose">
|
||||
<h3 class="dialog-title">Resume Session?</h3>
|
||||
<div class="dialog-text">
|
||||
<p id="resume-text">Choose a previous session to continue, or start a new one:</p>
|
||||
<div id="resume-sessions-list" style="margin: 15px 0; max-height: 200px; overflow-y: auto;">
|
||||
<!-- Session options list -->
|
||||
</div>
|
||||
<button id="login-button" class="navigation dialog-yes">Login</button>
|
||||
</div>
|
||||
<div class="dialog-buttons" style="display: flex; gap: 10px; justify-content: flex-end; margin-top: 15px;">
|
||||
<button id="btn-resume-start-new" class="navigation dialog-no" style="margin: 0;">Start New</button>
|
||||
<button id="btn-resume-continue" class="navigation dialog-yes" style="margin: 0;" disabled>Continue</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="loading" class="fullscreen-overlay">
|
||||
<div class="progress-block">
|
||||
<div id="progress">
|
||||
</div>
|
||||
<div class="sk-cube-grid full">
|
||||
<div class="sk-cube sk-cube1"></div>
|
||||
<div class="sk-cube sk-cube2"></div>
|
||||
<div class="sk-cube sk-cube3"></div>
|
||||
<div class="sk-cube sk-cube4"></div>
|
||||
<div class="sk-cube sk-cube5"></div>
|
||||
<div class="sk-cube sk-cube6"></div>
|
||||
<div class="sk-cube sk-cube7"></div>
|
||||
<div class="sk-cube sk-cube8"></div>
|
||||
<div class="sk-cube sk-cube9"></div>
|
||||
</div>
|
||||
<div class="progress-block">
|
||||
<div id="progress"></div>
|
||||
<div class="sk-cube-grid full">
|
||||
<div class="sk-cube sk-cube1"></div>
|
||||
<div class="sk-cube sk-cube2"></div>
|
||||
<div class="sk-cube sk-cube3"></div>
|
||||
<div class="sk-cube sk-cube4"></div>
|
||||
<div class="sk-cube sk-cube5"></div>
|
||||
<div class="sk-cube sk-cube6"></div>
|
||||
<div class="sk-cube sk-cube7"></div>
|
||||
<div class="sk-cube sk-cube8"></div>
|
||||
<div class="sk-cube sk-cube9"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="question-loading" class="fullscreen-overlay-transparent">
|
||||
<div class="progress-block">
|
||||
<div class="lds-ripple">
|
||||
<div></div>
|
||||
<div></div>
|
||||
</div>
|
||||
<div class="progress-block">
|
||||
<div class="lds-ripple">
|
||||
<div></div>
|
||||
<div></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="pause" class="fullscreen-overlay">
|
||||
</div>
|
||||
<div id="pause" class="fullscreen-overlay"></div>
|
||||
|
||||
<div id="packet-history" class="fullscreen-overlay">
|
||||
</div>
|
||||
<div id="packet-history" class="fullscreen-overlay"></div>
|
||||
|
||||
<div id="database-error" class="fullscreen-overlay">
|
||||
</div>
|
||||
<script src="https://browser.sentry-cdn.com/8.48.0/bundle.min.js" crossorigin="anonymous"></script>
|
||||
<div id="database-error" class="fullscreen-overlay"></div>
|
||||
<script
|
||||
src="https://js-de.sentry-cdn.com/017b4494d7038e3e789fe38378b53fb2.min.js"
|
||||
crossorigin="anonymous"
|
||||
></script>
|
||||
|
||||
<script src="lib/loglevel.min.js"></script>
|
||||
<script src="lib/uuidv4.min.js" type="module"></script>
|
||||
<script src="lib/jquery-3.4.1.min.js" type="text/javascript"></script>
|
||||
<script src="lib/jq-ajax-progress.min.js" type="text/javascript"></script>
|
||||
<script src="lib/easytimer.min.js" type="module"></script>
|
||||
<script src="lib/hammer.js"></script>
|
||||
<script src="https://unpkg.com/cornerstone-core/dist/cornerstone.js"></script>
|
||||
<script src="lib/cornerstone.js"></script>
|
||||
<script src="lib/dicomParser.min.js"></script>
|
||||
<script src="lib/cornerstoneMath.min.js"></script>
|
||||
<!-- <script src="lib/cornerstoneTools.min.js"></script> -->
|
||||
<script src="https://www.unpkg.com/cornerstone-tools@5.2.0/dist/cornerstoneTools.js"></script>
|
||||
<script src="lib/cornerstoneTools.js"></script>
|
||||
<script src="lib/cornerstoneWebImageLoader.min.js"></script>
|
||||
<script src="lib/cornerstoneWADOImageLoader.js"></script>
|
||||
<script src="lib/cornerstone-base64-image-loader.umd.js"></script>
|
||||
<script src="lib/dexie.js"></script>
|
||||
<script src="lib/jquery.modal.min.js"></script>
|
||||
<!-- <script src="packets/rr1" defer="defer"></script> -->
|
||||
|
||||
<script src="js/main.js" defer="defer" type="module"></script>
|
||||
<script src="lib/notify.min.js" type="text/javascript"></script>
|
||||
|
||||
</body>
|
||||
|
||||
</html>
|
||||
<script></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
/* global Dexie */
|
||||
|
||||
// Set up database
|
||||
export const db = new Dexie("answers_database");
|
||||
db.version(1).stores({
|
||||
answers: "[aid+cid+eid+qid+qidn], [aid+cid+eid], qid, ans, eid",
|
||||
flags: "[aid+cid+eid+qid+qidn], [aid+cid+eid], qid",
|
||||
user_answers: "[qid+type+ans], [qid+type]",
|
||||
session: "[eid+aid], eid, packet, aid, status, date, score, max_score, exam_time, time_left, question_order, questions_answered, total_questions",
|
||||
});
|
||||
|
||||
export const question_db = new Dexie("question_database");
|
||||
question_db.version(1).stores({
|
||||
question_data: "&[qid+type], qid, type",
|
||||
saved_exams: "&eid, type, exam_mode, name, order, time, exam_json_id",
|
||||
});
|
||||
+90
-56
@@ -3,7 +3,11 @@ import * as config from "./config.js";
|
||||
/**
|
||||
* Submits answers
|
||||
*/
|
||||
export function submitAnswers(exam_details, db, config) {
|
||||
export function submitAnswers(exam_details, db, URLS) {
|
||||
if (!exam_details || !exam_details.exam_mode) {
|
||||
alert("Answers can only be submitted when the exam is in exam mode.");
|
||||
return;
|
||||
}
|
||||
getJsonAnswers(exam_details, db)
|
||||
.then((a) => {
|
||||
let json = {
|
||||
@@ -12,7 +16,7 @@ export function submitAnswers(exam_details, db, config) {
|
||||
start_time: exam_details.start_time,
|
||||
answers: JSON.stringify(a),
|
||||
};
|
||||
postAnswers(json, config, exam_details);
|
||||
postAnswers(json, URLS, exam_details);
|
||||
})
|
||||
.catch((e) => {
|
||||
console.debug(e)
|
||||
@@ -44,11 +48,12 @@ export function getJsonAnswers(exam_details, db) {
|
||||
* Posts answers to url
|
||||
* @param {*} ans - json representation of answers
|
||||
*/
|
||||
export function postAnswers(ans, config, exam_details) {
|
||||
export function postAnswers(ans, URLS, exam_details) {
|
||||
$("#progress").html("Submitting answers...");
|
||||
console.debug("postAnswers", ans, config, exam_details)
|
||||
console.debug("postAnswers", ans, URLS, exam_details)
|
||||
console.debug("posturl", URLS.exam_submit_url)
|
||||
|
||||
$.post(config.exam_submit_url, ans, null, "json")
|
||||
$.post(URLS.exam_submit_url, ans, null, "json")
|
||||
.done((data) => {
|
||||
console.debug("returned data", data);
|
||||
if (data.success) {
|
||||
@@ -61,11 +66,17 @@ export function postAnswers(ans, config, exam_details) {
|
||||
|
||||
if (ret) {
|
||||
$(document).trigger("saveSessionEvent", [true]);
|
||||
if (config.exam_results_url != "") {
|
||||
let url = config.exam_results_url;
|
||||
if (URLS.exam_results_url != "") {
|
||||
let url = URLS.exam_results_url;
|
||||
|
||||
if (exam_details.cid != "") {
|
||||
url = url + exam_details.cid;
|
||||
url = `${url}${exam_details.cid}`;
|
||||
if (localStorage.getItem("cid.passcode") != null) {
|
||||
url = `${url}/${localStorage.getItem("cid.passcode")}`;
|
||||
}
|
||||
if (exam_details.cid.startsWith("u-")) {
|
||||
url = URLS.exam_user_results_url;
|
||||
}
|
||||
}
|
||||
$("#options-link")
|
||||
.empty()
|
||||
@@ -80,74 +91,97 @@ export function postAnswers(ans, config, exam_details) {
|
||||
alert(`Answers sucessfully submitted.`);
|
||||
}
|
||||
} else {
|
||||
submissionError(data, ans, exam_details);
|
||||
submissionError(data, ans, exam_details, URLS);
|
||||
}
|
||||
})
|
||||
.fail((e) => {
|
||||
// Will occur with server error such as 500
|
||||
console.debug("error", e);
|
||||
submissionError(e, ans, exam_details);
|
||||
submissionError(e, ans, exam_details, URLS);
|
||||
});
|
||||
// $.post( "http://localhost:8000/submit_answers", JSON.stringify(ans));
|
||||
}
|
||||
|
||||
function submissionError(data, answer_json, exam_details) {
|
||||
// error will not be defined with server errors
|
||||
if (data.error != undefined) {
|
||||
alert(`Error submitting answers: ${data.error}`);
|
||||
} else {
|
||||
alert(`Error submitting answers`);
|
||||
}
|
||||
var docHeight = $(document).height();
|
||||
export function submissionError(data, answer_json, exam_details, URLS) {
|
||||
// Clear any existing overlay
|
||||
$("#submit-error-overlay").remove();
|
||||
|
||||
let answers = JSON.parse(answer_json.answers)
|
||||
let answers = JSON.parse(answer_json.answers);
|
||||
let answer_map = {};
|
||||
|
||||
let answer_map = {}
|
||||
|
||||
answers.forEach((ans, n) => {
|
||||
answers.forEach((ans) => {
|
||||
if (!answer_map.hasOwnProperty(ans.qid)) {
|
||||
answer_map[ans.qid] = [];
|
||||
}
|
||||
|
||||
answer_map[ans.qid].push(ans);
|
||||
|
||||
});
|
||||
|
||||
let html = $("<ul></ul>");
|
||||
|
||||
exam_details.question_order.forEach((i, j) => {
|
||||
console.debug(i, answer_map)
|
||||
if (i in answer_map) {
|
||||
console.debug("YES", i, answer_map)
|
||||
let ans_array = answer_map[i];
|
||||
ans_array.forEach((x, y) => {
|
||||
$(html).append(`<li><b>Question ${j+1}.${y}:</b> ${x.ans}</li>`);
|
||||
let $list = $("<ol style='margin-left: 20px; font-size: 13px; max-height: 150px; overflow-y: auto; color: #ccc;'></ol>");
|
||||
exam_details.question_order.forEach((qid, idx) => {
|
||||
if (qid in answer_map) {
|
||||
let ans_array = answer_map[qid];
|
||||
ans_array.forEach((ans_obj, sub_idx) => {
|
||||
$list.append(`<li><strong>Question ${idx + 1}.${sub_idx + 1}:</strong> ${ans_obj.ans}</li>`);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
})
|
||||
let rawJsonStr = JSON.stringify(answer_json, null, 2);
|
||||
|
||||
console.debug(exam_details.question_order);
|
||||
console.debug(answer_map);
|
||||
console.debug(html);
|
||||
|
||||
if ($("#submit-error-overlay").length < 1) {
|
||||
$("body").append(
|
||||
`<div id='submit-error-overlay'><span style='color: white'><p>An error has occurred when submitting your answers. A copy of your answers are displayed below, you may wish to <a id="save-failed-answers" href="#">save a copy</a>. Please try submitting again or refresh this page to continue (answers will be saved locally in the browser unless you clear local storage).</p><p>${html.get(0).innerHTML}</p><p>${JSON.stringify(
|
||||
answer_json
|
||||
)}</p></span></div>`
|
||||
);
|
||||
|
||||
$("#nav-submit-button").prependTo("#submit-error-overlay");
|
||||
|
||||
$("#submit-error-overlay").height(docHeight);
|
||||
|
||||
let file = new Blob([JSON.stringify(answer_json)], {type: "application/json"})
|
||||
|
||||
let link = document.getElementById("save-failed-answers");
|
||||
link.href = URL.createObjectURL(file);
|
||||
link.download = "answers.json";
|
||||
let errorMsg = "An error occurred while submitting your answers.";
|
||||
if (data && data.error) {
|
||||
errorMsg = `Server returned error: ${data.error}`;
|
||||
} else if (data && data.statusText) {
|
||||
errorMsg = `Network error: ${data.statusText} (${data.status})`;
|
||||
}
|
||||
|
||||
let $overlay = $(`
|
||||
<div id="submit-error-overlay">
|
||||
<div class="error-card">
|
||||
<h3>Submission Failed</h3>
|
||||
<p style="margin-bottom: 15px; color: #ef5350; font-weight: bold;">${errorMsg}</p>
|
||||
<p style="font-size: 13px; color: #bbb; line-height: 1.4;">
|
||||
Your answers are saved locally in the browser's database and will not be lost unless you clear your local data.
|
||||
Please try to submit again, or save a backup file of your answers and contact support.
|
||||
</p>
|
||||
|
||||
<div style="margin: 15px 0;">
|
||||
<h4 style="margin: 0 0 5px 0; font-size: 14px; color: #eee;">Submitted Answers:</h4>
|
||||
<div style="background: #111; border: 1px solid #333; padding: 10px; border-radius: 4px;">
|
||||
${$list.prop("outerHTML")}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<details style="margin: 15px 0; font-size: 12px; cursor: pointer; color: #aaa;">
|
||||
<summary style="font-weight: bold; margin-bottom: 5px;">View Raw Submission JSON</summary>
|
||||
<pre style="background: #111; border: 1px solid #333; padding: 10px; overflow: auto; max-height: 150px; text-align: left; font-family: monospace; white-space: pre-wrap; margin: 0; color: #a5d6a7;">${rawJsonStr}</pre>
|
||||
</details>
|
||||
|
||||
<div style="display: flex; justify-content: flex-end; gap: 10px; margin-top: 20px;">
|
||||
<button id="btn-err-close" class="btn-secondary">Close</button>
|
||||
<a id="btn-err-backup" class="button" style="text-decoration: none;"><button class="btn-secondary">Save Backup</button></a>
|
||||
<button id="btn-err-retry">Retry Submit</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`);
|
||||
|
||||
$("body").append($overlay);
|
||||
|
||||
// Setup download backup link
|
||||
let fileBlob = new Blob([JSON.stringify(answer_json)], {type: "application/json"});
|
||||
let backupUrl = URL.createObjectURL(fileBlob);
|
||||
$overlay.find("#btn-err-backup").attr("href", backupUrl).attr("download", `answers_backup_${exam_details.eid.replace(/\//g, "_")}.json`);
|
||||
|
||||
// Close handler
|
||||
$overlay.find("#btn-err-close").on("click", function() {
|
||||
$("#submit-error-overlay").remove();
|
||||
});
|
||||
|
||||
// Retry handler
|
||||
$overlay.find("#btn-err-retry").on("click", function() {
|
||||
postAnswers(answer_json, URLS, exam_details);
|
||||
});
|
||||
}
|
||||
|
||||
export function getQuestion(url, question_number, question_total) {
|
||||
@@ -180,11 +214,11 @@ export function getQuestion(url, question_number, question_total) {
|
||||
});
|
||||
}
|
||||
|
||||
export function postSavedAnswer(type, qid, answer, e, db_object, db) {
|
||||
export function postSavedAnswer(type, qid, answer, e, db_object, db, submit_url) {
|
||||
console.debug("post", type, qid, answer, e)
|
||||
return $.ajax({
|
||||
type: "POST",
|
||||
url: config.question_answer_submit_url,
|
||||
url: submit_url,
|
||||
data: JSON.stringify({
|
||||
qid: `${type}/${qid}`,
|
||||
answer: answer,
|
||||
|
||||
+1054
-307
File diff suppressed because it is too large
Load Diff
+101
@@ -0,0 +1,101 @@
|
||||
/**
|
||||
* Sentry Error Monitoring initializer for RTS.
|
||||
* Optimized for Free Tier with beforeSend noise filtering.
|
||||
*/
|
||||
|
||||
export function initSentry() {
|
||||
try {
|
||||
const defaultDsn = "https://a5a64d2190093ce4b8972dd9a6907828@o4511766757244928.ingest.de.sentry.io/4511766900310096";
|
||||
const metaDsn = document.querySelector('meta[name="sentry-dsn"]')?.getAttribute('content');
|
||||
const dsn = window.SENTRY_DSN || window.RTS_SENTRY_DSN || metaDsn || defaultDsn;
|
||||
|
||||
if (!dsn) {
|
||||
window.SENTRY_STATUS = { loaded: false, reason: "No DSN configured" };
|
||||
console.warn("[Sentry] ❌ Error monitoring disabled (No DSN provided)");
|
||||
return;
|
||||
}
|
||||
|
||||
const beforeSend = (event, hint) => {
|
||||
const error = hint?.originalException;
|
||||
const message = event?.message || (error && error.message) || "";
|
||||
|
||||
// Ignore common harmless browser/network noise to preserve Sentry free quota
|
||||
const noisePatterns = [
|
||||
/ResizeObserver loop/i,
|
||||
/Failed to fetch/i,
|
||||
/NetworkError/i,
|
||||
/Load failed/i,
|
||||
/Script error/i,
|
||||
/top.GLOBALS/i,
|
||||
/chrome-extension:\/\//i,
|
||||
/moz-extension:\/\//i,
|
||||
];
|
||||
|
||||
if (noisePatterns.some((pattern) => pattern.test(message))) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return event;
|
||||
};
|
||||
|
||||
const config = {
|
||||
dsn: dsn,
|
||||
environment: window.SENTRY_ENVIRONMENT || "production",
|
||||
tracesSampleRate: parseFloat(window.SENTRY_TRACES_SAMPLE_RATE || "0.02"),
|
||||
beforeSend: beforeSend,
|
||||
ignoreErrors: [
|
||||
"ResizeObserver loop limit exceeded",
|
||||
"ResizeObserver loop completed with undelivered notifications.",
|
||||
"NetworkError when attempting to fetch resource.",
|
||||
"Failed to fetch",
|
||||
"Load failed",
|
||||
],
|
||||
};
|
||||
|
||||
const onInitialized = () => {
|
||||
window.SENTRY_STATUS = { loaded: true, dsn: dsn, environment: config.environment };
|
||||
console.info(`[Sentry] ✅ Error monitoring initialized (Env: ${config.environment})`);
|
||||
|
||||
// Expose a convenient window.testSentry() function for testing from browser console
|
||||
window.testSentry = function(msg = "RTS Sentry Test Event") {
|
||||
if (window.Sentry && typeof window.Sentry.captureException === "function") {
|
||||
const testErr = new Error(msg);
|
||||
window.Sentry.captureException(testErr);
|
||||
console.info("[Sentry] 🚀 Sent test error event to Sentry:", testErr);
|
||||
return "Sentry test event sent!";
|
||||
} else {
|
||||
console.error("[Sentry] ❌ Sentry is not available to send test event");
|
||||
return "Failed to send event";
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
if (window.Sentry) {
|
||||
window.Sentry.init({
|
||||
...config,
|
||||
integrations: [
|
||||
window.Sentry.browserTracingIntegration ? window.Sentry.browserTracingIntegration() : null,
|
||||
].filter(Boolean),
|
||||
});
|
||||
onInitialized();
|
||||
} else {
|
||||
const script = document.createElement("script");
|
||||
script.src = "https://browser.sentry-cdn.com/8.48.0/bundle.min.js";
|
||||
script.crossOrigin = "anonymous";
|
||||
script.onload = () => {
|
||||
if (window.Sentry) {
|
||||
window.Sentry.init(config);
|
||||
onInitialized();
|
||||
}
|
||||
};
|
||||
script.onerror = () => {
|
||||
window.SENTRY_STATUS = { loaded: false, reason: "Failed to load Sentry CDN script" };
|
||||
console.warn("[Sentry] ❌ Failed to load Sentry CDN script");
|
||||
};
|
||||
document.head.appendChild(script);
|
||||
}
|
||||
} catch (err) {
|
||||
window.SENTRY_STATUS = { loaded: false, error: err };
|
||||
console.warn("[Sentry] ❌ Initialization failed:", err);
|
||||
}
|
||||
}
|
||||
+194
@@ -0,0 +1,194 @@
|
||||
// Background Sync Module for RTS
|
||||
|
||||
import { db } from "./db.js";
|
||||
|
||||
let syncInterval = null;
|
||||
let debounceTimeout = null;
|
||||
let currentStatus = "saved"; // "saved", "syncing", "failed", "practice"
|
||||
|
||||
/**
|
||||
* Update the sync UI state
|
||||
* @param {string} status - "saved", "syncing", "failed", "practice"
|
||||
*/
|
||||
export function updateSyncUI(status) {
|
||||
currentStatus = status;
|
||||
const container = document.getElementById("sync-status");
|
||||
if (!container) return;
|
||||
|
||||
let html = "";
|
||||
switch (status) {
|
||||
case "practice":
|
||||
html = `
|
||||
<span class="sync-dot practice"></span>
|
||||
<span class="sync-text">Practice mode (local only)</span>
|
||||
`;
|
||||
break;
|
||||
case "syncing":
|
||||
html = `
|
||||
<span class="sync-dot syncing"></span>
|
||||
<span class="sync-text">Saving to server...</span>
|
||||
`;
|
||||
break;
|
||||
case "failed":
|
||||
html = `
|
||||
<span class="sync-dot failed"></span>
|
||||
<span class="sync-text">Offline - Save failed</span>
|
||||
`;
|
||||
break;
|
||||
case "saved":
|
||||
default:
|
||||
html = `
|
||||
<span class="sync-dot saved"></span>
|
||||
<span class="sync-text">Saved to server</span>
|
||||
`;
|
||||
break;
|
||||
}
|
||||
container.innerHTML = html;
|
||||
}
|
||||
|
||||
/**
|
||||
* Perform background synchronization of unsynced answers
|
||||
*/
|
||||
export async function performSync(exam_details, URLS) {
|
||||
if (!exam_details || !exam_details.exam_mode || !exam_details.eid) return;
|
||||
|
||||
// Find all unsynced answers for this exam/cid/aid
|
||||
const unsynced = await db.answers
|
||||
.where({
|
||||
aid: exam_details.aid,
|
||||
cid: exam_details.cid,
|
||||
eid: exam_details.eid,
|
||||
})
|
||||
.filter(ans => ans.synced === false)
|
||||
.toArray();
|
||||
|
||||
if (unsynced.length === 0) {
|
||||
if (currentStatus === "syncing") {
|
||||
updateSyncUI("saved");
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
updateSyncUI("syncing");
|
||||
|
||||
const json = {
|
||||
eid: exam_details.eid,
|
||||
cid: exam_details.cid,
|
||||
start_time: exam_details.start_time,
|
||||
answers: JSON.stringify(unsynced),
|
||||
in_progress: "true",
|
||||
};
|
||||
|
||||
try {
|
||||
const response = await new Promise((resolve, reject) => {
|
||||
$.ajax({
|
||||
type: "POST",
|
||||
url: URLS.exam_submit_url,
|
||||
data: json,
|
||||
dataType: "json",
|
||||
global: false, // Prevents triggering global ajaxStart / ajaxStop spinners
|
||||
success: resolve,
|
||||
error: reject,
|
||||
});
|
||||
});
|
||||
|
||||
if (response && response.success) {
|
||||
// Mark as synced in Dexie database
|
||||
await db.transaction("rw", db.answers, async () => {
|
||||
for (const ans of unsynced) {
|
||||
ans.synced = true;
|
||||
await db.answers.put(ans);
|
||||
}
|
||||
});
|
||||
updateSyncUI("saved");
|
||||
} else {
|
||||
updateSyncUI("failed");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Autosubmission failed:", error);
|
||||
updateSyncUI("failed");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Trigger an immediate, debounced sync (2s delay after last change)
|
||||
*/
|
||||
export function triggerImmediateSync(exam_details, URLS) {
|
||||
if (!exam_details || !exam_details.exam_mode) return;
|
||||
|
||||
if (debounceTimeout) {
|
||||
clearTimeout(debounceTimeout);
|
||||
}
|
||||
debounceTimeout = setTimeout(() => {
|
||||
performSync(exam_details, URLS);
|
||||
}, 2000);
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize background sync process
|
||||
*/
|
||||
export function initSync(exam_details, URLS) {
|
||||
// Clear any existing sync process
|
||||
if (syncInterval) {
|
||||
clearInterval(syncInterval);
|
||||
}
|
||||
|
||||
// Setup click redirect to remote user answers on the server if in exam mode
|
||||
const container = document.getElementById("sync-status");
|
||||
if (container) {
|
||||
if (exam_details && exam_details.exam_mode) {
|
||||
$(container).css("cursor", "pointer");
|
||||
$(container).off("click").on("click", () => {
|
||||
let url = URLS.exam_results_url;
|
||||
if (url) {
|
||||
if (exam_details.cid) {
|
||||
if (exam_details.cid.startsWith("u-")) {
|
||||
url = URLS.exam_user_results_url || url;
|
||||
} else {
|
||||
url = `${url}${exam_details.cid}`;
|
||||
const passcode = localStorage.getItem("cid.passcode");
|
||||
if (passcode) {
|
||||
url = `${url}/${passcode}`;
|
||||
}
|
||||
}
|
||||
}
|
||||
window.open(url, "_blank");
|
||||
}
|
||||
});
|
||||
} else {
|
||||
$(container).css("cursor", "default");
|
||||
$(container).off("click");
|
||||
}
|
||||
}
|
||||
|
||||
if (!exam_details || !exam_details.exam_mode) {
|
||||
updateSyncUI("practice");
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if there are unsynced answers at startup
|
||||
db.answers
|
||||
.where({
|
||||
aid: exam_details.aid,
|
||||
cid: exam_details.cid,
|
||||
eid: exam_details.eid,
|
||||
})
|
||||
.filter(ans => ans.synced === false)
|
||||
.toArray()
|
||||
.then(unsynced => {
|
||||
if (unsynced && unsynced.length > 0) {
|
||||
updateSyncUI("failed"); // Unsynced answers exist; they aren't accepted by server yet
|
||||
performSync(exam_details, URLS);
|
||||
} else {
|
||||
updateSyncUI("saved");
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
updateSyncUI("saved");
|
||||
});
|
||||
|
||||
// Run periodic sync every 15 seconds
|
||||
syncInterval = setInterval(() => {
|
||||
performSync(exam_details, URLS);
|
||||
}, 15000);
|
||||
}
|
||||
+255
-141
@@ -11,6 +11,9 @@ export function loadMainImage(image, stack) {
|
||||
const RotateTool = cornerstoneTools.RotateTool;
|
||||
const StackScrollTool = cornerstoneTools.StackScrollTool;
|
||||
const ArrowAnnotateTool = cornerstoneTools.ArrowAnnotateTool;
|
||||
const LengthTool = cornerstoneTools.LengthTool;
|
||||
const RectangleRoiTool = cornerstoneTools.RectangleRoiTool;
|
||||
const EllipseRoiTool = cornerstoneTools.EllipseRoiTool;
|
||||
const AngleTool = cornerstoneTools.AngleTool;
|
||||
const CobbAngleTool = cornerstoneTools.CobbAngleTool;
|
||||
const EraserTool = cornerstoneTools.EraserTool;
|
||||
@@ -35,6 +38,9 @@ export function loadMainImage(image, stack) {
|
||||
cornerstoneTools.addToolForElement(element, CobbAngleTool);
|
||||
cornerstoneTools.addToolForElement(element, StackScrollTool);
|
||||
cornerstoneTools.addToolForElement(element, EraserTool);
|
||||
cornerstoneTools.addToolForElement(element, LengthTool);
|
||||
cornerstoneTools.addToolForElement(element, RectangleRoiTool);
|
||||
//cornerstoneTools.addToolForElement(element, EllipseRoiTool);
|
||||
|
||||
cornerstoneTools.addToolForElement(element, ArrowAnnotateTool, {
|
||||
configuration: {
|
||||
@@ -58,50 +64,25 @@ export function loadMainImage(image, stack) {
|
||||
|
||||
setDicomCanvasNonFullscreen(element);
|
||||
cornerstone.reset(element);
|
||||
element.scrollIntoView(false);
|
||||
// element.scrollTo(0);
|
||||
let viewerTitleBar = document.getElementById("viewer-title-bar");
|
||||
viewerTitleBar.scrollIntoView();
|
||||
//element.scrollIntoView(false);
|
||||
//element.scrollTo(0);
|
||||
|
||||
$(element).dblclick(function () {
|
||||
$(element).dblclick(function() {
|
||||
if ($(".canvas-panel").length == 0) {
|
||||
// already fullscreen (disable it)
|
||||
disableFullscreen(this);
|
||||
} else {
|
||||
$(".content-panel").append($(".canvas-panel"));
|
||||
$(".canvas-panel").toggleClass("canvas-panel canvas-panel-fullscreen");
|
||||
$("#dicom-image").attr("height", "100%");
|
||||
$("#dicom-image").height("100%");
|
||||
// $(".cornerstone-canvas").attr("height", "100%");
|
||||
// $(".cornerstone-canvas").height("100%");
|
||||
cornerstone.resize(this, true);
|
||||
enableFullscreen(this);
|
||||
}
|
||||
});
|
||||
|
||||
element.removeEventListener("wheel", element.wheelEventHandler);
|
||||
|
||||
// Add tool selector
|
||||
$(".canvas-panel").append(
|
||||
'<select class="control-overlay"> \
|
||||
<option value="pan">pan [p]</option> \
|
||||
<option value="zoom">zoom [z]</option> \
|
||||
<option value="rotate">rotate [r]</option> \
|
||||
<option value="angle">angle</option> \
|
||||
<option value="cobbangle">cobb angle</option> \
|
||||
<option value="eraser">eraser</option> \
|
||||
<option value="scroll" hidden="" disabled="">scroll (1/1)</option> \
|
||||
<option value="window">window ()</option> \
|
||||
<option value="abdomen" hidden="" disabled="">window = abdomen [a]</option> \
|
||||
<option value="pulmonary" hidden="" disabled="">window = pulmonary [u]</option> \
|
||||
<option value="brain" hidden="" disabled="">window = brain [b]</option> \
|
||||
<option value="bone" hidden="" disabled="">window = bone [o]</option> \
|
||||
<option value="reset">reset [e]</option> \
|
||||
<option value="close">close [c]</option> \
|
||||
<option disabled="true" value="notes">[modality = CR][size = 9.8]</option> \
|
||||
</select>'
|
||||
);
|
||||
|
||||
$(".control-overlay")
|
||||
$(".dicom-select-control")
|
||||
.get(0)
|
||||
.addEventListener("change", function () {
|
||||
.addEventListener("change", function() {
|
||||
changeControlSelection();
|
||||
});
|
||||
|
||||
@@ -117,7 +98,7 @@ function onImageRendered(e) {
|
||||
const eventData = e.detail;
|
||||
|
||||
// Update ww/wl
|
||||
const sel = $(".control-overlay").get(0);
|
||||
const sel = $(".dicom-select-control").get(0);
|
||||
sel.options[find_option(sel, "window")].firstChild.textContent =
|
||||
"window (" +
|
||||
Math.round(eventData.viewport.voi.windowCenter) +
|
||||
@@ -133,10 +114,10 @@ function onImageRendered(e) {
|
||||
$("option[value=scroll").prop("hidden", false);
|
||||
$("option[value=scroll").text(
|
||||
"scroll (" +
|
||||
(stack.currentImageIdIndex + 1) +
|
||||
"/" +
|
||||
stack.imageIds.length +
|
||||
")"
|
||||
(stack.currentImageIdIndex + 1) +
|
||||
"/" +
|
||||
stack.imageIds.length +
|
||||
")"
|
||||
);
|
||||
|
||||
// Temp way to enable CT window presets
|
||||
@@ -176,7 +157,10 @@ export function disableFullscreen(dicom_element) {
|
||||
$(".canvas-panel-fullscreen").toggleClass(
|
||||
"canvas-panel canvas-panel-fullscreen"
|
||||
);
|
||||
$(".question").append($(".canvas-panel"));
|
||||
|
||||
$("#disable-fullscreen-viewer-button").hide();
|
||||
$("#fullscreen-viewer-button").show();
|
||||
$(".question").prepend($(".canvas-panel"));
|
||||
$(".canvas-panel").get(0).scrollIntoView();
|
||||
|
||||
setDicomCanvasNonFullscreen(dicom_element);
|
||||
@@ -279,10 +263,12 @@ export function debugCornerstone() {
|
||||
|
||||
/**
|
||||
* Registers the selected dicom tool
|
||||
*
|
||||
* This also allows selecting of window levels
|
||||
*/
|
||||
export function changeControlSelection() {
|
||||
// We also duplicate the sel that are available
|
||||
const sel = $(".control-overlay").get(0);
|
||||
const sel = $(".dicom-select-control").get(0);
|
||||
|
||||
const old = sel.oldSelectedIndex;
|
||||
sel.oldSelectedIndex = sel.selectedIndex;
|
||||
@@ -307,6 +293,25 @@ export function changeControlSelection() {
|
||||
});
|
||||
break;
|
||||
}
|
||||
case "measure": {
|
||||
cornerstoneTools.setToolActiveForElement(dicom_element, "Length", {
|
||||
mouseButtonMask: 1,
|
||||
});
|
||||
break;
|
||||
}
|
||||
case "rectangle": {
|
||||
cornerstoneTools.setToolActiveForElement(dicom_element, "RectangleRoi", {
|
||||
mouseButtonMask: 1,
|
||||
});
|
||||
break;
|
||||
}
|
||||
case "ellipse": {
|
||||
cornerstoneTools.setToolActiveForElement(dicom_element, "EllipseRoi", {
|
||||
mouseButtonMask: 1,
|
||||
});
|
||||
break;
|
||||
}
|
||||
|
||||
case "angle": {
|
||||
cornerstoneTools.setToolActiveForElement(dicom_element, "Angle", {
|
||||
mouseButtonMask: 1,
|
||||
@@ -376,24 +381,13 @@ export function changeControlSelection() {
|
||||
break;
|
||||
}
|
||||
case "reset": {
|
||||
cornerstone.reset(dicom_element);
|
||||
resetViewer(dicom_element);
|
||||
sel.selectedIndex = old;
|
||||
sel.oldSelectedIndex = old;
|
||||
break;
|
||||
}
|
||||
case "close": {
|
||||
// disable fullscreen if required
|
||||
if ($(".canvas-panel").length == 0) {
|
||||
disableFullscreen(dicom_element);
|
||||
}
|
||||
if (dicom_element != undefined) {
|
||||
// See https://github.com/cornerstonejs/cornerstoneTools/issues/1337
|
||||
cornerstone.removeElementData(dicom_element);
|
||||
cornerstone.disable(dicom_element);
|
||||
$(".canvas-panel").remove();
|
||||
$(".figure-open").removeClass("figure-open").addClass("figure");
|
||||
$(dicom_element).remove();
|
||||
}
|
||||
closeViewer(dicom_element);
|
||||
break;
|
||||
}
|
||||
}
|
||||
@@ -403,6 +397,12 @@ export function changeControlSelection() {
|
||||
}
|
||||
}
|
||||
|
||||
function resetViewer(dicom_element) {
|
||||
cornerstoneTools.clearToolState(dicom_element, "RectangleRoi");
|
||||
cornerstoneTools.clearToolState(dicom_element, "Length");
|
||||
cornerstone.reset(dicom_element);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle key button events
|
||||
* These are the same as in the RCR pratique implementation
|
||||
@@ -422,12 +422,16 @@ export function keydown_handler(event) {
|
||||
if (target_element == "INPUT" || target_element == "TEXTAREA") {
|
||||
return;
|
||||
}
|
||||
const sel = $(".control-overlay").get(0);
|
||||
const sel = $(".dicom-select-control").get(0);
|
||||
switch (event.code) {
|
||||
case "KeyP":
|
||||
sel.selectedIndex = find_option(sel, "pan");
|
||||
changeControlSelection();
|
||||
break;
|
||||
case "KeyC":
|
||||
sel.selectedIndex = find_option(sel, "rectangle");
|
||||
changeControlSelection();
|
||||
break;
|
||||
case "KeyZ":
|
||||
sel.selectedIndex = find_option(sel, "zoom");
|
||||
changeControlSelection();
|
||||
@@ -466,7 +470,7 @@ export function keydown_handler(event) {
|
||||
changeControlSelection();
|
||||
break;
|
||||
// Escape and C do the same
|
||||
case "KeyC":
|
||||
//case "KeyC":
|
||||
case "Escape":
|
||||
sel.selectedIndex = find_option(sel, "close");
|
||||
changeControlSelection();
|
||||
@@ -527,7 +531,7 @@ export function keydown_handler(event) {
|
||||
manualZoomDicom(-1);
|
||||
break;
|
||||
case "rotate":
|
||||
manualRotateDicom(1);
|
||||
manualRotateDicom(-1);
|
||||
break;
|
||||
case "scroll":
|
||||
manualScrollDicom(1);
|
||||
@@ -547,7 +551,7 @@ export function keydown_handler(event) {
|
||||
manualZoomDicom(1);
|
||||
break;
|
||||
case "rotate":
|
||||
manualRotateDicom(-1);
|
||||
manualRotateDicom(1);
|
||||
break;
|
||||
case "scroll":
|
||||
manualScrollDicom(-1);
|
||||
@@ -562,6 +566,62 @@ export function keydown_handler(event) {
|
||||
event.preventDefault();
|
||||
}
|
||||
|
||||
function loadAnnotation(imageId, annotation) {
|
||||
const toolStateManager =
|
||||
cornerstoneTools.globalImageIdSpecificToolStateManager;
|
||||
|
||||
if (annotation == undefined || annotation.length < 1) {
|
||||
return;
|
||||
}
|
||||
|
||||
let tool_state_no_id = JSON.parse(annotation);
|
||||
|
||||
let tool_state = {};
|
||||
tool_state[imageId] = tool_state_no_id;
|
||||
|
||||
toolStateManager.restoreToolState(tool_state);
|
||||
}
|
||||
async function load(images, annotations) {
|
||||
// Parallelize image preparation and annotation loading
|
||||
const imageTasks = images.map(async (data_url, i) => {
|
||||
const annotation = annotations[i];
|
||||
let imageId;
|
||||
if (data_url.startsWith("data:image")) {
|
||||
imageId = "base64://" + data_url.split(",")[1];
|
||||
} else if (data_url.startsWith("data:")) {
|
||||
const dfile = await urltoFile(data_url, "dicom", "application/dicom");
|
||||
imageId = cornerstoneWADOImageLoader.wadouri.fileManager.add(dfile);
|
||||
} else {
|
||||
let url = data_url;
|
||||
if (url.endsWith("dcm")) {
|
||||
url = "wadouri:" + url;
|
||||
}
|
||||
if (/(?:\/|^)[^.\/]+$/.test(url)) {
|
||||
url = "wadouri:" + url;
|
||||
}
|
||||
imageId = url;
|
||||
}
|
||||
return {imageId, annotation};
|
||||
});
|
||||
|
||||
// Wait for all image tasks to complete
|
||||
const imageResults = await Promise.all(imageTasks);
|
||||
const imageIds = imageResults.map(r => r.imageId);
|
||||
|
||||
// Display the first image as soon as possible
|
||||
cornerstone.loadAndCacheImage(imageIds[0]).then(function(image) {
|
||||
const stack = {
|
||||
currentImageIdIndex: 0,
|
||||
imageIds,
|
||||
};
|
||||
loadMainImage(image, stack);
|
||||
// Restore annotations for all images after display
|
||||
imageResults.forEach(({imageId, annotation}) => {
|
||||
loadAnnotation(imageId, annotation);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* View cornstone image
|
||||
* @param {*} t
|
||||
@@ -572,82 +632,7 @@ export function openMainImage(current_question, t, source) {
|
||||
* Load image
|
||||
* @param {*} images - list of images
|
||||
*/
|
||||
function loadAnnotation(imageId, annotation) {
|
||||
const toolStateManager =
|
||||
cornerstoneTools.globalImageIdSpecificToolStateManager;
|
||||
|
||||
if (annotation == undefined || annotation.length < 1) {
|
||||
return;
|
||||
}
|
||||
|
||||
let tool_state_no_id = JSON.parse(annotation);
|
||||
|
||||
let tool_state = {};
|
||||
tool_state[imageId] = tool_state_no_id;
|
||||
|
||||
toolStateManager.restoreToolState(tool_state);
|
||||
}
|
||||
async function load(images, annotations) {
|
||||
//console.debug("Load function", images)
|
||||
const imageIds = [];
|
||||
for (let i = 0; i < images.length; i++) {
|
||||
const data_url = images[i];
|
||||
const annotation = annotations[i];
|
||||
|
||||
// check stack type
|
||||
if (data_url.startsWith("data:image")) {
|
||||
const imageId = "base64://" + data_url.split(",")[1];
|
||||
|
||||
loadAnnotation(imageId, annotation);
|
||||
|
||||
imageIds.push(imageId);
|
||||
//} else if (data_url.startsWith("data:application/dicom")) {
|
||||
} else if (data_url.startsWith("data:")) {
|
||||
// stack = stack.split(";")[1];
|
||||
|
||||
const dfile = await urltoFile(data_url, "dicom", "application/dicom");
|
||||
|
||||
const imageId = cornerstoneWADOImageLoader.wadouri.fileManager.add(
|
||||
dfile
|
||||
);
|
||||
|
||||
loadAnnotation(imageId, annotation);
|
||||
|
||||
imageIds.push(imageId);
|
||||
// cornerstone.loadImage(imageId).then(function(image) {
|
||||
// tempFunction(image);
|
||||
// });
|
||||
} else {
|
||||
let url = data_url;
|
||||
//if (data_url.startsWith("http")) {
|
||||
// url = data_url;
|
||||
//} else {
|
||||
// url = window.location.href.replace(/\/\#\/?$/, "") + "/" + data_url;
|
||||
//}
|
||||
|
||||
if (url.endsWith("dcm")) {
|
||||
url = "wadouri:" + url;
|
||||
}
|
||||
|
||||
// if there is no extension treat it as a dicom
|
||||
if (/(?:\/|^)[^.\/]+$/.test(url)) {
|
||||
url = "wadouri:" + url;
|
||||
}
|
||||
//console.debug(url)
|
||||
|
||||
loadAnnotation(url, annotation);
|
||||
|
||||
imageIds.push(url);
|
||||
}
|
||||
}
|
||||
const stack = {
|
||||
currentImageIdIndex: 0,
|
||||
imageIds,
|
||||
};
|
||||
cornerstone.loadAndCacheImage(imageIds[0]).then(function (image) {
|
||||
loadMainImage(image, stack);
|
||||
});
|
||||
}
|
||||
|
||||
if (current_question) {
|
||||
// Check if the figure is already loaded (or if another one is)
|
||||
@@ -658,7 +643,7 @@ export function openMainImage(current_question, t, source) {
|
||||
// No full size figure / dicom loaded yet
|
||||
if (figure_to_load == open_figure) {
|
||||
// Scroll to the image if it is already open
|
||||
document.getElementById("dicom-image").scrollIntoView();
|
||||
document.getElementById("viewer-title-bar").scrollIntoView();
|
||||
return;
|
||||
} else {
|
||||
let el;
|
||||
@@ -676,11 +661,97 @@ export function openMainImage(current_question, t, source) {
|
||||
|
||||
source.className = "figure-open";
|
||||
|
||||
$(".question").append(
|
||||
'<div class= "canvas-panel"><div id="dicom-image" data-figure="' +
|
||||
figure_to_load +
|
||||
'"></div></div>'
|
||||
);
|
||||
let image_title = source.getElementsByClassName("figcaption")[0].textContent;
|
||||
|
||||
// Native DOM creation for performance
|
||||
const questionElem = document.querySelector('.question');
|
||||
const canvasPanel = document.createElement('div');
|
||||
canvasPanel.className = 'canvas-panel';
|
||||
|
||||
const viewerTitleBar = document.createElement('div');
|
||||
viewerTitleBar.id = 'viewer-title-bar';
|
||||
|
||||
const flexDiv = document.createElement('div');
|
||||
flexDiv.style.flex = '1 1 auto';
|
||||
|
||||
const viewerTitle = document.createElement('div');
|
||||
viewerTitle.id = 'viewer-title';
|
||||
viewerTitle.style.color = 'white';
|
||||
viewerTitle.textContent = image_title;
|
||||
flexDiv.appendChild(viewerTitle);
|
||||
viewerTitleBar.appendChild(flexDiv);
|
||||
|
||||
// Select control
|
||||
const select = document.createElement('select');
|
||||
select.title = 'SELECT CLICK ACTION';
|
||||
select.className = 'dicom-select-control title-bar-button';
|
||||
select.style.marginRight = '8px';
|
||||
const options = [
|
||||
{ value: 'pan', text: 'pan [p]' },
|
||||
{ value: 'zoom', text: 'zoom [z]' },
|
||||
{ value: 'rotate', text: 'rotate [r]' },
|
||||
{ value: 'scroll', text: 'scroll [s]' },
|
||||
{ value: 'window', text: 'window (128 ± 127.5) [w]' },
|
||||
{ value: 'abdomen', text: 'window = abdomen [a]', hidden: true, disabled: true },
|
||||
{ value: 'pulmonary', text: 'window = pulmonary [p]', hidden: true, disabled: true },
|
||||
{ value: 'brain', text: 'window = brain [b]', hidden: true, disabled: true },
|
||||
{ value: 'bone', text: 'window = bone [o]', hidden: true, disabled: true },
|
||||
{ value: 'measure', text: 'measure [m]' },
|
||||
{ value: 'ellipse', text: 'ellipse [l]' },
|
||||
{ value: 'rectangle', text: 'rectangle [c]' },
|
||||
{ value: 'reset', text: 'reset [e]' },
|
||||
{ value: 'notes', text: '[modality = SC]', disabled: true }
|
||||
];
|
||||
options.forEach(opt => {
|
||||
const o = document.createElement('option');
|
||||
o.value = opt.value;
|
||||
o.textContent = opt.text;
|
||||
if (opt.disabled) o.disabled = true;
|
||||
if (opt.hidden) o.hidden = true;
|
||||
select.appendChild(o);
|
||||
});
|
||||
viewerTitleBar.appendChild(select);
|
||||
|
||||
// Buttons
|
||||
function createButton(id, title, svg, style = '') {
|
||||
const btn = document.createElement('button');
|
||||
btn.id = id;
|
||||
btn.className = 'title-bar-button';
|
||||
btn.title = title;
|
||||
btn.style.marginRight = '8px';
|
||||
if (style) btn.style.display = style;
|
||||
const span = document.createElement('span');
|
||||
span.className = 'viewer-icon';
|
||||
span.innerHTML = svg;
|
||||
btn.appendChild(span);
|
||||
return btn;
|
||||
}
|
||||
// SVGs
|
||||
const svgReset = `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-arrow-counterclockwise" viewBox="0 0 16 16"><path fill-rule="evenodd" d="M8 3a5 5 0 1 1-4.546 2.914.5.5 0 0 0-.908-.417A6 6 0 1 0 8 2z"/><path d="M8 4.466V.534a.25.25 0 0 0-.41-.192L5.23 2.308a.25.25 0 0 0 0 .384l2.36 1.966A.25.25 0 0 0 8 4.466"/></svg>`;
|
||||
const svgFullscreen = `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-fullscreen" viewBox="0 0 16 16"><path d="M1.5 1a.5.5 0 0 0-.5.5v4a.5.5 0 0 1-1 0v-4A1.5 1.5 0 0 1 1.5 0h4a.5.5 0 0 1 0 1zM10 .5a.5.5 0 0 1 .5-.5h4A1.5 1.5 0 0 1 16 1.5v4a.5.5 0 0 1-1 0v-4a.5.5 0 0 0-.5-.5h-4a.5.5 0 0 1-.5-.5M.5 10a.5.5 0 0 1 .5.5v4a.5.5 0 0 0 .5.5h4a.5.5 0 0 1 0 1h-4A1.5 1.5 0 0 1 0 14.5v-4a.5.5 0 0 1 .5-.5m15 0a.5.5 0 0 1 .5.5v4a1.5 1.5 0 0 1-1.5 1.5h-4a.5.5 0 0 1 0-1h4a.5.5 0 0 0-.5-.5v-4a.5.5 0 0 1 .5-.5"/></svg>`;
|
||||
const svgFullscreenExit = `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-fullscreen-exit" viewBox="0 0 16 16"><path d="M5.5 0a.5.5 0 0 1 .5.5v4A1.5 1.5 0 0 1 4.5 6h-4a.5.5 0 0 1 0-1h4a.5.5 0 0 0 .5-.5v-4a.5.5 0 0 1 .5-.5m5 0a.5.5 0 0 1 .5.5v4a.5.5 0 0 0 .5.5h4a.5.5 0 0 1 0 1h-4A1.5 1.5 0 0 1 10 4.5v-4a.5.5 0 0 1 .5-.5M0 10.5a.5.5 0 0 1 .5-.5h4A1.5 1.5 0 0 1 6 11.5v4a.5.5 0 0 1-1 0v-4a.5.5 0 0 0-.5-.5h-4a.5.5 0 0 1-.5-.5m10 1a1.5 1.5 0 0 1 1.5-1.5h4a.5.5 0 0 1 0 1h-4a.5.5 0 0 0-.5.5v4a.5.5 0 0 1-1 0z"/></svg>`;
|
||||
const svgClose = `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-x-lg" viewBox="0 0 16 16"><path d="M2.146 2.854a.5.5 0 1 1 .708-.708L8 7.293l5.146-5.147a.5.5 0 0 1 .708.708L8.707 8l5.147 5.146a.5.5 0 0 1-.708.708L8 8.707l-5.146 5.147a.5.5 0 0 1-.708-.708L7.293 8z"/></svg>`;
|
||||
|
||||
const btnReset = createButton('reset-viewer-button', 'RESET VIEW', svgReset);
|
||||
const btnFullscreen = createButton('fullscreen-viewer-button', 'TOGGLE FULLSCREEN', svgFullscreen);
|
||||
const btnFullscreenExit = createButton('disable-fullscreen-viewer-button', 'TOGGLE FULLSCREEN', svgFullscreenExit, 'none');
|
||||
const btnClose = createButton('close-viewer-button', 'CLOSE VIEWER', svgClose);
|
||||
|
||||
viewerTitleBar.appendChild(btnReset);
|
||||
viewerTitleBar.appendChild(btnFullscreen);
|
||||
viewerTitleBar.appendChild(btnFullscreenExit);
|
||||
viewerTitleBar.appendChild(btnClose);
|
||||
|
||||
canvasPanel.appendChild(viewerTitleBar);
|
||||
|
||||
// DICOM image container
|
||||
const dicomImageDiv = document.createElement('div');
|
||||
dicomImageDiv.id = 'dicom-image';
|
||||
dicomImageDiv.setAttribute('data-figure', figure_to_load);
|
||||
canvasPanel.appendChild(dicomImageDiv);
|
||||
|
||||
// Prepend to question
|
||||
questionElem.insertBefore(canvasPanel, questionElem.firstChild);
|
||||
|
||||
let images = current_question.images[figure_to_load.split("-")[1]];
|
||||
// images = current_question.images
|
||||
@@ -698,16 +769,59 @@ export function openMainImage(current_question, t, source) {
|
||||
}
|
||||
}
|
||||
|
||||
const dicom_element = document.getElementById("dicom-image");
|
||||
// Bind button actions
|
||||
$("#close-viewer-button").click(function() {
|
||||
closeViewer(dicom_element);
|
||||
});
|
||||
$("#reset-viewer-button").click(function() {
|
||||
resetViewer(dicom_element);
|
||||
});
|
||||
$("#fullscreen-viewer-button").click(function() {
|
||||
enableFullscreen(dicom_element);
|
||||
});
|
||||
$("#disable-fullscreen-viewer-button").click(function() {
|
||||
disableFullscreen(dicom_element);
|
||||
});
|
||||
|
||||
load(images, annotations);
|
||||
}
|
||||
}
|
||||
|
||||
function closeViewer(dicom_element) {
|
||||
// disable fullscreen if required
|
||||
if ($(".canvas-panel").length == 0) {
|
||||
disableFullscreen(dicom_element);
|
||||
}
|
||||
if (dicom_element != undefined) {
|
||||
// See https://github.com/cornerstonejs/cornerstoneTools/issues/1337
|
||||
cornerstone.removeElementData(dicom_element);
|
||||
cornerstone.disable(dicom_element);
|
||||
$(".canvas-panel").remove();
|
||||
$(".figure-open").removeClass("figure-open").addClass("figure");
|
||||
$(dicom_element).remove();
|
||||
}
|
||||
}
|
||||
|
||||
export function urltoFile(url, filename, mimeType) {
|
||||
return fetch(url)
|
||||
.then(function (res) {
|
||||
.then(function(res) {
|
||||
return res.arrayBuffer();
|
||||
})
|
||||
.then(function (buf) {
|
||||
return new File([buf], filename, { type: mimeType });
|
||||
.then(function(buf) {
|
||||
return new File([buf], filename, {type: mimeType});
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
function enableFullscreen(dicom_element) {
|
||||
$(".content-panel").prepend($(".canvas-panel"));
|
||||
$(".canvas-panel").toggleClass("canvas-panel canvas-panel-fullscreen");
|
||||
$("#dicom-image").attr("height", "100%");
|
||||
$("#dicom-image").height("100%");
|
||||
$("#disable-fullscreen-viewer-button").show();
|
||||
$("#fullscreen-viewer-button").hide();
|
||||
// $(".cornerstone-canvas").attr("height", "100%");
|
||||
// $(".cornerstone-canvas").height("100%");
|
||||
cornerstone.resize(dicom_element, true);
|
||||
}
|
||||
+1989
-1504
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
Vendored
+2
@@ -0,0 +1,2 @@
|
||||
/*! loglevel - v1.8.1 - https://github.com/pimterry/loglevel - (c) 2022 Tim Perry - licensed MIT */
|
||||
!function(a,b){"use strict";"function"==typeof define&&define.amd?define(b):"object"==typeof module&&module.exports?module.exports=b():a.log=b()}(this,function(){"use strict";function a(a,b){var c=a[b];if("function"==typeof c.bind)return c.bind(a);try{return Function.prototype.bind.call(c,a)}catch(b){return function(){return Function.prototype.apply.apply(c,[a,arguments])}}}function b(){console.log&&(console.log.apply?console.log.apply(console,arguments):Function.prototype.apply.apply(console.log,[console,arguments])),console.trace&&console.trace()}function c(c){return"debug"===c&&(c="log"),typeof console!==i&&("trace"===c&&j?b:void 0!==console[c]?a(console,c):void 0!==console.log?a(console,"log"):h)}function d(a,b){for(var c=0;c<k.length;c++){var d=k[c];this[d]=c<a?h:this.methodFactory(d,a,b)}this.log=this.debug}function e(a,b,c){return function(){typeof console!==i&&(d.call(this,b,c),this[a].apply(this,arguments))}}function f(a,b,d){return c(a)||e.apply(this,arguments)}function g(a,b,c){function e(a){var b=(k[a]||"silent").toUpperCase();if(typeof window!==i&&m){try{return void(window.localStorage[m]=b)}catch(a){}try{window.document.cookie=encodeURIComponent(m)+"="+b+";"}catch(a){}}}function g(){var a;if(typeof window!==i&&m){try{a=window.localStorage[m]}catch(a){}if(typeof a===i)try{var b=window.document.cookie,c=b.indexOf(encodeURIComponent(m)+"=");-1!==c&&(a=/^([^;]+)/.exec(b.slice(c))[1])}catch(a){}return void 0===l.levels[a]&&(a=void 0),a}}function h(){if(typeof window!==i&&m){try{return void window.localStorage.removeItem(m)}catch(a){}try{window.document.cookie=encodeURIComponent(m)+"=; expires=Thu, 01 Jan 1970 00:00:00 UTC"}catch(a){}}}var j,l=this;b=null==b?"WARN":b;var m="loglevel";"string"==typeof a?m+=":"+a:"symbol"==typeof a&&(m=void 0),l.name=a,l.levels={TRACE:0,DEBUG:1,INFO:2,WARN:3,ERROR:4,SILENT:5},l.methodFactory=c||f,l.getLevel=function(){return j},l.setLevel=function(b,c){if("string"==typeof b&&void 0!==l.levels[b.toUpperCase()]&&(b=l.levels[b.toUpperCase()]),!("number"==typeof b&&b>=0&&b<=l.levels.SILENT))throw"log.setLevel() called with invalid level: "+b;if(j=b,!1!==c&&e(b),d.call(l,b,a),typeof console===i&&b<l.levels.SILENT)return"No console available for logging"},l.setDefaultLevel=function(a){b=a,g()||l.setLevel(a,!1)},l.resetLevel=function(){l.setLevel(b,!1),h()},l.enableAll=function(a){l.setLevel(l.levels.TRACE,a)},l.disableAll=function(a){l.setLevel(l.levels.SILENT,a)};var n=g();null==n&&(n=b),l.setLevel(n,!1)}var h=function(){},i="undefined",j=typeof window!==i&&typeof window.navigator!==i&&/Trident\/|MSIE /.test(window.navigator.userAgent),k=["trace","debug","info","warn","error"],l=new g,m={};l.getLogger=function(a){if("symbol"!=typeof a&&"string"!=typeof a||""===a)throw new TypeError("You must supply a name when creating a logger.");var b=m[a];return b||(b=m[a]=new g(a,l.getLevel(),l.methodFactory)),b};var n=typeof window!==i?window.log:void 0;return l.noConflict=function(){return typeof window!==i&&window.log===l&&(window.log=n),l},l.getLoggers=function(){return m},l.default=l,l});
|
||||
Generated
+6435
-373
File diff suppressed because it is too large
Load Diff
+7
-5
@@ -6,8 +6,10 @@
|
||||
"directories": {
|
||||
"lib": "lib"
|
||||
},
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"test": "echo \"Error: no test specified\" && exit 1"
|
||||
"test": "NODE_OPTIONS=--experimental-vm-modules jest",
|
||||
"lint": "eslint --fix . && echo 'Lint complete.'"
|
||||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
@@ -21,7 +23,7 @@
|
||||
"homepage": "https://github.com/xkjq/rts#readme",
|
||||
"devDependencies": {
|
||||
"eslint": "^6.8.0",
|
||||
"eslint-config-google": "^0.14.0"
|
||||
},
|
||||
"lint": "eslint --fix . && echo 'Lint complete.'"
|
||||
}
|
||||
"eslint-config-google": "^0.14.0",
|
||||
"jest": "^30.4.2"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
// Unit tests for RTS Background Sync
|
||||
|
||||
import { jest } from "@jest/globals";
|
||||
|
||||
// Mock Dexie and jQuery globally before importing modules that use them
|
||||
const mockAnswersWhere = jest.fn();
|
||||
const mockAnswersFilter = jest.fn();
|
||||
const mockAnswersToArray = jest.fn();
|
||||
const mockAnswersPut = jest.fn();
|
||||
const mockTransaction = jest.fn();
|
||||
|
||||
globalThis.Dexie = class {
|
||||
constructor(name) {
|
||||
this.name = name;
|
||||
this.answers = {
|
||||
where: mockAnswersWhere,
|
||||
put: mockAnswersPut,
|
||||
};
|
||||
}
|
||||
version() {
|
||||
return {
|
||||
stores: () => {}
|
||||
};
|
||||
}
|
||||
transaction(mode, tables, cb) {
|
||||
return mockTransaction(mode, tables, cb);
|
||||
}
|
||||
};
|
||||
|
||||
// Define mock jQuery $.ajax
|
||||
const mockAjax = jest.fn();
|
||||
|
||||
globalThis.$ = {
|
||||
ajax: mockAjax,
|
||||
};
|
||||
|
||||
// Mock DOM elements
|
||||
const mockHtml = jest.fn();
|
||||
globalThis.document = {
|
||||
getElementById: jest.fn(() => ({
|
||||
set innerHTML(val) {
|
||||
mockHtml(val);
|
||||
}
|
||||
})),
|
||||
};
|
||||
|
||||
// Dynamic import to avoid ES module hoisting execution before mocks are set up
|
||||
let performSync, updateSyncUI;
|
||||
|
||||
describe("RTS Sync Unit Tests", () => {
|
||||
beforeAll(async () => {
|
||||
const syncModule = await import("../js/sync.js");
|
||||
performSync = syncModule.performSync;
|
||||
updateSyncUI = syncModule.updateSyncUI;
|
||||
});
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
test("updateSyncUI updates the DOM container", () => {
|
||||
updateSyncUI("syncing");
|
||||
expect(globalThis.document.getElementById).toHaveBeenCalledWith("sync-status");
|
||||
expect(mockHtml).toHaveBeenCalledWith(expect.stringContaining("Saving to server..."));
|
||||
});
|
||||
|
||||
test("performSync does nothing if exam details are missing", async () => {
|
||||
await performSync(null, {});
|
||||
expect(mockAnswersWhere).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("performSync processes unsynced answers and marks them as synced", async () => {
|
||||
const examDetails = {
|
||||
aid: "session-123",
|
||||
cid: "9999",
|
||||
eid: "rapid/1",
|
||||
start_time: 1234567,
|
||||
exam_mode: true,
|
||||
};
|
||||
const URLS = {
|
||||
exam_submit_url: "/submit-url",
|
||||
};
|
||||
|
||||
const unsyncedAnswers = [
|
||||
{ aid: "session-123", cid: "9999", eid: "rapid/1", qid: 1, qidn: "1", ans: "Normal", synced: false }
|
||||
];
|
||||
|
||||
mockAnswersWhere.mockReturnValue({
|
||||
filter: mockAnswersFilter.mockReturnValue({
|
||||
toArray: mockAnswersToArray.mockResolvedValue(unsyncedAnswers)
|
||||
})
|
||||
});
|
||||
|
||||
mockAjax.mockImplementation((options) => {
|
||||
if (options.success) {
|
||||
options.success({ success: true });
|
||||
}
|
||||
});
|
||||
|
||||
mockTransaction.mockImplementation(async (mode, tables, cb) => {
|
||||
await cb();
|
||||
});
|
||||
|
||||
await performSync(examDetails, URLS);
|
||||
|
||||
expect(mockAnswersWhere).toHaveBeenCalledWith({
|
||||
aid: examDetails.aid,
|
||||
cid: examDetails.cid,
|
||||
eid: examDetails.eid,
|
||||
});
|
||||
expect(mockAjax).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
type: "POST",
|
||||
url: URLS.exam_submit_url,
|
||||
dataType: "json",
|
||||
global: false,
|
||||
data: expect.objectContaining({
|
||||
in_progress: "true",
|
||||
}),
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user