Set streak hour offset
diff --git a/frontend/src/styles/inputs.scss b/frontend/src/styles/inputs.scss
index 2e3533bde952..ca0768d7920c 100644
--- a/frontend/src/styles/inputs.scss
+++ b/frontend/src/styles/inputs.scss
@@ -102,10 +102,6 @@ label.checkboxWithSub {
}
}
-#wordFilterModal #exactMatchOnly {
- width: 1.25em;
-}
-
input[type="checkbox"] {
appearance: none;
height: 1.25em;
diff --git a/frontend/src/styles/media-queries-blue.scss b/frontend/src/styles/media-queries-blue.scss
index b3634b288738..c7c24fb6c409 100644
--- a/frontend/src/styles/media-queries-blue.scss
+++ b/frontend/src/styles/media-queries-blue.scss
@@ -11,13 +11,6 @@
#testModesNotice {
font-size: 0.8rem;
}
- #customTextModal {
- .modal {
- .buttonsTop2 {
- grid-template-columns: 1fr;
- }
- }
- }
.page404 {
.content {
grid-template-columns: 300px;
diff --git a/frontend/src/styles/media-queries-green.scss b/frontend/src/styles/media-queries-green.scss
index 3bffcd365a04..b2989e3aa581 100644
--- a/frontend/src/styles/media-queries-green.scss
+++ b/frontend/src/styles/media-queries-green.scss
@@ -102,20 +102,6 @@
// }
}
- #customTextModal {
- .modal {
- grid-template-areas:
- "topButtons topButtons"
- "topButtons2 topButtons2"
- "textArea textArea"
- "checkboxes checkboxes"
- "ok ok";
- grid-template-columns: 1fr 1fr;
- .inputs {
- grid-template-columns: 1fr 1fr;
- }
- }
- }
.testActivity {
--gap-size: 0.1em;
--font-size: 0.8em;
diff --git a/frontend/src/styles/media-queries-purple.scss b/frontend/src/styles/media-queries-purple.scss
index 0ca89061d979..f698bc37f6f1 100644
--- a/frontend/src/styles/media-queries-purple.scss
+++ b/frontend/src/styles/media-queries-purple.scss
@@ -118,44 +118,6 @@
.modalWrapper .modal {
padding: 1rem;
}
- #customTextModal {
- .modal {
- .buttonsTop {
- grid-template-columns: 1fr;
- }
- .inputs {
- grid-template-columns: 1fr;
- }
- }
- }
- #wordFilterModal {
- .modal {
- gap: 1rem;
- .divider {
- height: 0.25rem;
- width: 100%;
- position: relative;
- display: grid;
- &::before {
- content: "or";
- font-size: 0.75rem;
- position: absolute;
- top: -0.5em;
- justify-self: center;
- background: var(--bg-color);
- color: var(--sub-color);
- padding: 0 1rem;
- }
- }
- grid-template-areas:
- "top top"
- "left left"
- "divider divider"
- "right right"
- "bottom bottom";
- grid-template-columns: 1fr 1fr;
- }
- }
.testActivity {
.wrapper {
width: 100%;
diff --git a/frontend/src/styles/media-queries-yellow.scss b/frontend/src/styles/media-queries-yellow.scss
index 5d7e27a6c25a..1d8d9782123b 100644
--- a/frontend/src/styles/media-queries-yellow.scss
+++ b/frontend/src/styles/media-queries-yellow.scss
@@ -34,16 +34,6 @@
font-size: 9rem;
}
}
- #customTextModal {
- .modal {
- .buttonsTop {
- grid-template-columns: 1fr 1fr;
- }
- // textarea {
- // min-height: 426px;
- // }
- }
- }
.testActivity {
// --box-size: 0.9em;
// --font-size: 0.9em;
diff --git a/frontend/src/styles/popups.scss b/frontend/src/styles/popups.scss
index ae36bf124d25..58d80de8e022 100644
--- a/frontend/src/styles/popups.scss
+++ b/frontend/src/styles/popups.scss
@@ -91,206 +91,6 @@ body.darkMode {
}
}
-#customTextModal {
- .modal {
- max-width: 1200px;
- // grid-template-areas:
- // "topButtons topButtons topButtons"
- // "textArea textArea checkboxes"
- // "ok ok ok";
- grid-template-areas:
- "topButtons checkboxes"
- "topButtons2 checkboxes"
- "textArea checkboxes"
- "ok checkboxes";
- grid-template-columns: auto 20rem;
- grid-template-rows: min-content min-content 1fr min-content;
-
- .buttonsTop {
- grid-area: topButtons;
- }
-
- .buttonsTop2 {
- grid-area: topButtons2;
- }
-
- .textAreaWrapper {
- grid-area: textArea;
- }
-
- .inputs {
- grid-area: checkboxes;
- }
-
- .button.apply {
- grid-area: ok;
- }
-
- // .replaceNewLinesButtons {
- // display: grid;
- // justify-content: center;
- // width: 100%;
- // font-size: 0.75rem;
- // grid-template-columns: 1fr;
- // padding: 0 1rem;
- // &.disabled {
- // opacity: 0.5;
- // pointer-events: none;
- // -webkit-user-select: none;
- // user-select: none;
- // }
- // }
-
- .longCustomTextWarning,
- .challengeWarning {
- // background: rgba(0, 0, 0, 0.5);
- background: var(--sub-alt-color);
- position: absolute;
- left: 0;
- right: 0;
- top: 0;
- bottom: 0;
- display: grid;
- place-items: center center;
- border-radius: var(--roundness);
- text-align: center;
- height: 100%;
- align-items: center;
- p {
- font-size: 1.25em;
- margin: 0;
- }
- p.small {
- font-size: 0.75em;
- color: var(--sub-color);
- }
- }
-
- .buttonsTop {
- display: grid;
- // grid-template-columns: repeat(auto-fit, minmax(12rem, 1fr));
- grid-template-columns: 1fr 1fr;
- gap: 1rem;
- }
-
- .buttonsTop2 {
- display: grid;
- grid-template-columns: 1fr 1fr 1fr;
- gap: 1rem;
- }
-
- .savedTexts {
- display: grid;
- gap: 0.5rem;
- .title {
- color: var(--sub-color);
- }
- .buttons {
- display: grid;
- grid-template-columns: 1fr 1fr;
- gap: 1rem;
- }
- }
-
- textarea {
- align-self: start;
- background: var(--sub-alt-color);
- padding: 1rem;
- color: var(--main-color);
- border: none;
- outline: none;
- font-size: 1rem;
- font-family: var(--font);
- width: 100%;
- border-radius: var(--roundness);
- resize: vertical;
- min-height: 524px;
- color: var(--text-color);
- overflow-x: hidden;
- overflow-y: scroll;
- }
-
- .inputs {
- display: grid;
- grid-template-columns: 1fr;
- gap: 1rem;
- align-items: center;
- justify-items: left;
- height: min-content;
- // margin: 1rem 0;
- font-size: 0.75rem;
- align-items: start;
-
- .group {
- display: grid;
- // gap: 0.5rem;
- align-items: center;
- width: 100%;
- .title {
- color: var(--sub-color);
- text-transform: lowercase;
- }
- .sub {
- // font-size: 0.75em;
- // height: 0;
- // overflow: hidden;
- color: var(--text-color);
- margin-top: 0.25rem;
- margin-bottom: 0.5rem;
- }
- .groupInputs {
- &.limitInputs {
- grid-column: 2/-1;
- display: flex;
- grid-template-columns: 1fr auto 1fr;
- text-align: center;
- align-items: center;
- width: 100%;
- gap: 1rem;
- input {
- width: 100%;
- }
- .or {
- color: var(--sub-color);
- }
- &.disabled {
- opacity: 0.5;
- pointer-events: none;
- -webkit-user-select: none;
- user-select: none;
- }
- }
- .buttonGroup {
- display: flex;
- width: 100%;
- gap: 0.5rem;
- button {
- flex-grow: 1;
- }
- // button {
- // flex-grow: 1;
- // border-radius: 0;
- // }
- // button:first-child {
- // border-radius: var(--roundness) 0 0 var(--roundness);
- // }
- // button:last-child {
- // border-radius: 0 var(--roundness) var(--roundness) 0;
- // }
- }
- }
- }
-
- &.disabled {
- opacity: 0.5;
- -webkit-user-select: none;
- user-select: none;
- pointer-events: none;
- }
- }
- }
-}
-
#practiseWordsModal {
.modal {
max-width: 400px;
@@ -324,305 +124,6 @@ body.darkMode {
}
}
-#savedTextsModal {
- .modal {
- max-width: 500px;
- .list {
- display: grid;
- gap: 1rem;
- .savedText {
- display: grid;
- gap: 0.5rem;
- grid-template-columns: 2fr 3rem;
- .button .fas {
- pointer-events: none;
- }
- }
- }
-
- .divider {
- height: 0.25rem;
- background: var(--sub-alt-color);
- border-radius: var(--roundness);
- margin: 1rem 0;
- }
-
- .message {
- font-size: 0.75em;
- color: var(--sub-color);
- }
-
- .listLong {
- display: grid;
- gap: 1rem;
- .savedLongText {
- display: grid;
- gap: 0.5rem;
- grid-template-columns: 2fr auto auto;
- .button .fas {
- pointer-events: none;
- }
- }
- }
- }
-}
-
-#saveCustomTextModal {
- .modal {
- max-width: 400px;
- }
-}
-
-#wordFilterModal {
- .modal {
- grid-template-areas: "top top top" "left divider right" "bottom bottom bottom";
- grid-template-columns: 1fr auto 1fr;
- gap: 2rem 1rem;
- max-width: 800px;
-
- .top {
- grid-area: top;
- }
-
- .leftSide {
- grid-area: left;
- }
-
- .rightSide {
- grid-area: right;
- }
-
- .bottom {
- grid-area: bottom;
- }
-
- .leftSide,
- .rightSide,
- .bottom,
- .top {
- display: grid;
- gap: 1rem;
- height: max-content;
- }
-
- input {
- width: 100%;
- }
-
- .wordFilterLanguage {
- grid-column: span 2;
- .title {
- width: 100%;
- }
- }
-
- .group {
- display: grid;
- gap: 0.5rem;
- .title {
- color: var(--sub-color);
- }
- }
-
- .lengthgrid {
- display: grid;
- grid-template-columns: 1fr 1fr;
- grid-template-rows: auto 1fr;
- column-gap: 1rem;
- }
-
- .tip {
- color: var(--sub-color);
- font-size: 0.8rem;
- }
-
- .loadingIndicator {
- justify-self: center;
- color: var(--main-color);
- }
-
- .divider {
- width: 0.25rem;
- background-color: var(--sub-alt-color);
- border-radius: var(--roundness);
- grid-area: divider;
- }
- }
-}
-
-#customGeneratorModal {
- .modal {
- max-width: 600px;
-
- .main {
- display: grid;
- gap: 1.5rem;
- }
-
- .bottom {
- display: grid;
- gap: 1rem;
- margin-top: 1rem;
- }
-
- .separator {
- height: 0.25rem;
- width: 100%;
- background-color: var(--sub-alt-color);
- border-radius: var(--roundness);
- }
-
- .group {
- display: grid;
- gap: 0.5rem;
-
- .title {
- color: var(--sub-color);
- }
- }
-
- .lengthgrid {
- display: grid;
- grid-template-columns: 1fr 1fr;
- grid-template-rows: auto 1fr;
- column-gap: 1rem;
- }
-
- .tip {
- color: var(--sub-color);
- font-size: 0.8rem;
- }
-
- input,
- textarea {
- width: 100%;
- padding: 0.5rem;
- background: var(--sub-alt-color);
- color: var(--text-color);
- border: none;
- border-radius: var(--roundness);
-
- &::placeholder {
- color: var(--sub-color);
- }
- }
-
- textarea {
- min-height: 100px;
- resize: vertical;
- }
- }
-}
-
-#quoteRateModal {
- .modal {
- max-width: 800px;
- overflow: unset;
-
- display: grid;
- grid-template-areas: "warning warning warning" "spacer2 spacer2 spacer2" "ratingStats ratingStats submitButton" "spacer spacer spacer" "quote quote quote";
- grid-template-columns: auto 1fr;
-
- color: var(--text-color);
-
- .warning {
- grid-area: warning;
- span {
- color: var(--error-color);
- }
- }
- .spacer,
- .spacer2 {
- grid-area: spacer;
- width: 100%;
- height: 0.1rem;
- border-radius: var(--roundness);
- background: var(--sub-color);
- opacity: 0.25;
- }
-
- .spacer2 {
- grid-area: spacer2;
- }
-
- button.submitButton {
- font-size: 2rem;
- grid-area: submitButton;
- color: var(--sub-color);
- &:hover {
- color: var(--text-color);
- }
- }
-
- .top {
- color: var(--sub-color);
- font-size: 0.8rem;
- }
-
- .ratingStats {
- display: grid;
- grid-template-columns: 1fr 1fr 1fr;
- gap: 1rem;
- grid-area: ratingStats;
- .top {
- font-size: 1rem;
- }
- .val {
- font-size: 2.25rem;
- }
- }
-
- .quote {
- display: grid;
- grid-area: quote;
- gap: 1rem;
- grid-template-areas:
- "text text text"
- "id length source";
- grid-template-columns: 1fr 1fr 3fr;
- .text {
- grid-area: text;
- }
- .id {
- grid-area: id;
- }
- .length {
- grid-area: length;
- }
- .source {
- grid-area: source;
- }
- }
-
- .stars {
- display: grid;
- color: var(--sub-color);
- font-size: 2rem;
- grid-template-columns: auto auto auto auto auto;
- justify-content: flex-start;
- align-items: center;
- cursor: pointer;
- button.star {
- padding: 0;
- background: none;
- color: var(--sub-color);
- &:hover.active {
- color: var(--text-color);
- }
- }
- i {
- pointer-events: none;
- }
- .star.active {
- color: var(--main-color);
- }
- // &:hover .star.active {
- // color: var(--text-color);
- // }
- }
- }
-}
-
#simpleModal {
.modal {
max-width: 500px;
@@ -958,181 +459,12 @@ body.darkMode {
}
}
-#quoteSearchModal {
- .highlight {
- color: var(--main-color);
- }
-
- .modal {
- background: var(--bg-color);
- border-radius: var(--roundness);
- padding: 2rem;
- display: grid;
- gap: 1rem;
- width: 80vw;
- max-width: 1000px;
- height: 80vh;
- grid-template-rows: auto auto auto 1fr;
-
- #quoteSearchTop {
- display: flex;
- justify-content: space-between;
-
- .title {
- font-size: 1.5rem;
- color: var(--sub-color);
- }
-
- .buttons {
- display: grid;
- gap: 0.5rem;
- button {
- width: 180px;
- }
- }
- }
-
- #quoteSearchControlsWrapper {
- display: grid;
- grid-template-columns: 1.5fr 1fr max-content;
- gap: 1rem;
-
- #searchBox {
- width: 100%;
- }
-
- .toggleFavorites {
- height: 100%;
- align-items: center;
- }
- }
-
- #quoteSearchPageNavigator {
- display: flex;
- align-items: flex-end;
- justify-content: center;
- }
-
- .prevPage,
- .nextPage {
- display: flex;
- align-items: center;
- justify-content: center;
- cursor: pointer;
- }
-
- .pageInfo {
- flex: 1;
- text-align: center;
- max-width: 20rem;
- color: var(--sub-color);
- padding: 0.5rem 1.5rem;
- }
-
- #quoteSearchResults {
- display: grid;
- gap: 0.5rem;
- height: auto;
- overflow-y: scroll;
- align-content: baseline;
-
- .searchResult {
- display: grid;
- grid-template-columns: 1fr 1fr 3fr 0fr 0fr;
- grid-template-areas:
- "text text text text text"
- "id len source report favorite";
- grid-auto-rows: auto;
- width: 100%;
- gap: 0.5rem;
- transition: 0.25s;
- padding: 1rem;
- box-sizing: border-box;
- -webkit-user-select: none;
- user-select: none;
- cursor: pointer;
- height: min-content;
-
- .text {
- grid-area: text;
- overflow: visible;
- color: var(--text-color);
- }
- .id {
- grid-area: id;
- font-size: 0.8rem;
- color: var(--sub-color);
- }
- .length {
- grid-area: len;
- font-size: 0.8rem;
- color: var(--sub-color);
- }
- .source {
- grid-area: source;
- font-size: 0.8rem;
- color: var(--sub-color);
- }
- .resultChevron {
- grid-area: chevron;
- display: flex;
- align-items: center;
- justify-items: center;
- color: var(--sub-color);
- font-size: 2rem;
- }
- .report {
- grid-area: report;
- color: var(--sub-color);
- transition: 0.25s;
- &:hover {
- color: var(--text-color);
- }
- }
- .favorite {
- grid-area: favorite;
- color: var(--sub-color);
- transition: 0.25s;
- &:hover {
- color: var(--text-color);
- }
- }
- .sub {
- opacity: 0.5;
- }
- }
- .searchResult:hover {
- background: var(--sub-alt-color);
- border-radius: 5px;
- }
- }
- }
-}
-
#importExportSettingsModal {
.modal {
max-width: 900px;
}
}
-#quoteSubmitModal {
- .modal {
- max-width: 1000px;
-
- label {
- color: var(--sub-color);
- margin-bottom: -0.5em;
- }
-
- textarea {
- resize: vertical;
- width: 100%;
- padding: 10px;
- line-height: 1.2rem;
- min-height: 5rem;
- }
- }
-}
#apeKeysModal {
.modal {
max-width: 1000px;
@@ -1231,128 +563,6 @@ body.darkMode {
}
}
-#quoteApproveModal {
- .modal {
- max-width: 1000px;
- max-height: 600px;
- height: 100%;
- grid-template-rows: auto 1fr;
-
- .top {
- display: flex;
- justify-content: space-between;
- .title {
- font-size: 1.5rem;
- color: var(--sub-color);
- }
- button {
- padding: 0.5em 1em;
- }
- }
-
- .quotes {
- display: grid;
- gap: 2rem;
- height: auto;
- overflow-y: scroll;
- align-content: baseline;
- @extend .ffscroll;
-
- .quote {
- display: grid;
- grid-template-columns: 1fr auto;
- grid-auto-rows: auto 2rem;
- width: 100%;
- gap: 1rem;
- transition: 0.25s;
- box-sizing: border-box;
- -webkit-user-select: none;
- user-select: none;
- height: min-content;
- margin-bottom: 1rem;
- padding: 0.25em;
- padding-right: 0.5rem;
-
- .text {
- grid-column: 1/2;
- grid-row: 1/2;
- overflow: visible;
- color: var(--text-color);
- resize: vertical;
- min-height: 5rem;
- }
- .source {
- grid-column: 1/2;
- grid-row: 2/3;
- color: var(--text-color);
- }
- .buttons {
- display: flex;
- flex-direction: column;
- justify-content: center;
- grid-column: 2/3;
- grid-row: 1/4;
- color: var(--sub-color);
- gap: 1rem;
- }
-
- .bottom {
- display: flex;
- justify-content: space-around;
- color: var(--sub-color);
- flex-wrap: wrap;
- gap: 1rem;
- .length.red {
- color: var(--error-color);
- }
- i.fas {
- margin-right: 0.5em;
- }
- }
-
- .sub {
- opacity: 0.5;
- }
- }
- .searchResult:hover {
- background: var(--sub-alt-color);
- border-radius: 5px;
- }
- }
- }
-}
-
-#quoteReportModal {
- .modal {
- max-width: 800px;
-
- label {
- color: var(--sub-color);
- margin-bottom: -0.5rem;
- }
-
- .red {
- color: var(--error-color);
- }
-
- // .text {
- // // color: var(--sub-color);
- // }
-
- .quote {
- font-size: 1.5rem;
- }
-
- textarea {
- resize: vertical;
- width: 100%;
- padding: 10px;
- line-height: 1.2rem;
- min-height: 5rem;
- }
- }
-}
-
#userReportModal {
.modal {
max-width: 800px;
diff --git a/frontend/src/ts/commandline/lists.ts b/frontend/src/ts/commandline/lists.ts
index 2af52dec0463..a5a09d372824 100644
--- a/frontend/src/ts/commandline/lists.ts
+++ b/frontend/src/ts/commandline/lists.ts
@@ -21,7 +21,7 @@ import { setConfig } from "../config/setters";
import * as getErrorMessage from "../utils/error";
import * as JSONData from "../utils/json-data";
import { randomizeTheme } from "../controllers/theme-controller";
-import * as CustomTextPopup from "../modals/custom-text";
+import { showModal } from "../states/modals";
import {
showErrorNotification,
showSuccessNotification,
@@ -30,7 +30,6 @@ import {
import * as VideoAdPopup from "../popups/video-ad-popup";
import * as ShareTestSettingsPopup from "../modals/share-test-settings";
import * as TestStats from "../test/test-stats";
-import * as QuoteSearchModal from "../modals/quote-search";
import { Command, CommandsSubgroup } from "./types";
import { buildCommandForConfigKey } from "./util";
import { CommandlineConfigMetadataObject } from "./commandline-metadata";
@@ -79,7 +78,7 @@ export const commands: CommandsSubgroup = {
display: "Change custom text",
icon: "fa-align-left",
exec: (): void => {
- CustomTextPopup.show();
+ showModal("CustomText");
},
},
{
@@ -88,7 +87,7 @@ export const commands: CommandsSubgroup = {
icon: "fa-search",
exec: (): void => {
setConfig("mode", "quote");
- void QuoteSearchModal.show();
+ showModal("QuoteSearch");
},
shouldFocusTestUI: false,
},
diff --git a/frontend/src/ts/components/common/AnimatedModal.tsx b/frontend/src/ts/components/common/AnimatedModal.tsx
index b0711d08c3e6..df29fe8f7904 100644
--- a/frontend/src/ts/components/common/AnimatedModal.tsx
+++ b/frontend/src/ts/components/common/AnimatedModal.tsx
@@ -37,7 +37,7 @@ type AnimatedModalProps = ParentProps<{
hide?: AnimationConfig;
};
focusFirstInput?: true | "focusAndSelect";
- beforeShow?: () => void | Promise
;
+ beforeShow?: (isChained: boolean) => void | Promise;
afterShow?: () => void | Promise;
beforeHide?: () => void | Promise;
afterHide?: () => void | Promise;
@@ -75,7 +75,7 @@ export function AnimatedModal(props: AnimatedModalProps): JSXElement {
if (dialogEl() === undefined || modalEl() === undefined) return;
if (dialogEl()?.native.open) return;
- await props.beforeShow?.();
+ await props.beforeShow?.(isChained);
// Open the dialog
dialogEl()?.show();
diff --git a/frontend/src/ts/components/common/Button.tsx b/frontend/src/ts/components/common/Button.tsx
index 545a4209a990..6527f74c9643 100644
--- a/frontend/src/ts/components/common/Button.tsx
+++ b/frontend/src/ts/components/common/Button.tsx
@@ -13,10 +13,10 @@ type BaseProps = {
children?: JSXElement;
balloon?: BalloonProps;
"router-link"?: true;
- onClick?: () => void;
+ onClick?: (e: MouseEvent) => void;
type?: HTMLButtonElement["type"];
- onMouseEnter?: () => void;
- onMouseLeave?: () => void;
+ onMouseEnter?: (e: MouseEvent) => void;
+ onMouseLeave?: (e: MouseEvent) => void;
dataset?: Record;
active?: boolean;
};
@@ -98,9 +98,9 @@ export function Button(props: ButtonProps | AnchorProps): JSXElement {
}
{...balloonHtmlProps()}
{...(props["router-link"] ? { "router-link": "" } : {})}
- onClick={() => props.onClick?.()}
- onMouseEnter={() => props.onMouseEnter?.()}
- onMouseLeave={() => props.onMouseLeave?.()}
+ onClick={(e) => props.onClick?.(e)}
+ onMouseEnter={(e) => props.onMouseEnter?.(e)}
+ onMouseLeave={(e) => props.onMouseLeave?.(e)}
data-ui-variant={variant()}
data-ui-element="button"
{...props.dataset}
@@ -113,9 +113,9 @@ export function Button(props: ButtonProps | AnchorProps): JSXElement {
// oxlint-disable-next-line button-has-type
type={(props as ButtonProps).type ?? "button"}
class={getClasses()}
- onClick={() => props.onClick?.()}
- onMouseEnter={() => props.onMouseEnter?.()}
- onMouseLeave={() => props.onMouseLeave?.()}
+ onClick={(e) => props.onClick?.(e)}
+ onMouseEnter={(e) => props.onMouseEnter?.(e)}
+ onMouseLeave={(e) => props.onMouseLeave?.(e)}
{...balloonHtmlProps()}
{...(props["router-link"] ? { "router-link": "" } : {})}
disabled={props.disabled ?? false}
diff --git a/frontend/src/ts/components/modals/CustomGeneratorModal.tsx b/frontend/src/ts/components/modals/CustomGeneratorModal.tsx
new file mode 100644
index 000000000000..e743fb83dc00
--- /dev/null
+++ b/frontend/src/ts/components/modals/CustomGeneratorModal.tsx
@@ -0,0 +1,236 @@
+import { createForm } from "@tanstack/solid-form";
+import { createSignal, JSXElement, Setter } from "solid-js";
+
+import { hideModal } from "../../states/modals";
+import { showNoticeNotification } from "../../states/notifications";
+import { AnimatedModal } from "../common/AnimatedModal";
+import { Button } from "../common/Button";
+import { Separator } from "../common/Separator";
+import { InputField } from "../ui/form/InputField";
+import { SubmitButton } from "../ui/form/SubmitButton";
+import { TextareaField } from "../ui/form/TextareaField";
+import SlimSelect from "../ui/SlimSelect";
+
+type CustomTextIncomingData =
+ | ({ set?: boolean; long?: boolean } & (
+ | { text: string; splitText?: never }
+ | { text?: never; splitText: string[] }
+ ))
+ | null;
+
+type Preset = {
+ display: string;
+ characters: string[];
+};
+
+const presets: Record = {
+ alphas: {
+ display: "a-z",
+ characters: "abcdefghijklmnopqrstuvwxyz".split(""),
+ },
+ numbers: {
+ display: "0-9",
+ characters: "0123456789".split(""),
+ },
+ special: {
+ display: "symbols",
+ characters: "!@#$%^&*()_+-=[]{}|;:',.<>?/`~".split(""),
+ },
+ bigrams: {
+ display: "bigrams",
+ characters: [
+ "th",
+ "he",
+ "in",
+ "er",
+ "an",
+ "re",
+ "on",
+ "at",
+ "en",
+ "nd",
+ "ed",
+ "es",
+ "or",
+ "te",
+ "st",
+ "ar",
+ "ou",
+ "it",
+ "al",
+ "as",
+ ],
+ },
+ trigrams: {
+ display: "trigrams",
+ characters: [
+ "the",
+ "and",
+ "ing",
+ "ion",
+ "tio",
+ "ent",
+ "ati",
+ "for",
+ "her",
+ "ter",
+ "ate",
+ "ver",
+ "all",
+ "con",
+ "res",
+ "are",
+ "rea",
+ "int",
+ ],
+ },
+};
+
+const presetOptions = Object.entries(presets).map(([id, preset]) => ({
+ value: id,
+ text: preset.display,
+}));
+
+export function CustomGeneratorModal(props: {
+ setChainedData: Setter;
+}): JSXElement {
+ const [selectedPreset, setSelectedPreset] = createSignal(
+ presetOptions[0]?.value ?? "",
+ );
+
+ let submitAction: "set" | "add" = "set";
+
+ const form = createForm(() => ({
+ defaultValues: {
+ characterSet: "",
+ minLength: "2",
+ maxLength: "5",
+ wordCount: "100",
+ },
+ onSubmit: ({ value }) => {
+ const input = value.characterSet.trim();
+ if (input === "") {
+ showNoticeNotification("Character set cannot be empty");
+ return;
+ }
+
+ const characters = input.split(/\s+/);
+ const minLength = parseInt(value.minLength) || 2;
+ const maxLength = parseInt(value.maxLength) || 5;
+ const wordCount = parseInt(value.wordCount) || 100;
+ const generatedWords: string[] = [];
+
+ for (let i = 0; i < wordCount; i++) {
+ const wordLength =
+ Math.floor(Math.random() * (maxLength - minLength + 1)) + minLength;
+ let word = "";
+ for (let j = 0; j < wordLength; j++) {
+ const randomChar =
+ characters[Math.floor(Math.random() * characters.length)];
+ word += randomChar;
+ }
+ generatedWords.push(word);
+ }
+
+ if (generatedWords.length === 0) return;
+
+ props.setChainedData({
+ splitText: generatedWords,
+ set: submitAction === "set",
+ });
+ hideModal("CustomGenerator");
+ },
+ }));
+
+ const applyPreset = () => {
+ const preset = presets[selectedPreset()];
+ if (preset) {
+ form.setFieldValue("characterSet", preset.characters.join(" "));
+ }
+ };
+
+ return (
+
+
+
+ );
+}
diff --git a/frontend/src/ts/components/modals/CustomTextModal.tsx b/frontend/src/ts/components/modals/CustomTextModal.tsx
new file mode 100644
index 000000000000..e9df706a54a6
--- /dev/null
+++ b/frontend/src/ts/components/modals/CustomTextModal.tsx
@@ -0,0 +1,731 @@
+import type { CustomTextMode } from "@monkeytype/schemas/util";
+
+import { createForm } from "@tanstack/solid-form";
+import { batch, createSignal, For, JSXElement, Show, untrack } from "solid-js";
+
+import type { FaSolidIcon } from "../../types/font-awesome";
+
+import { setConfig } from "../../config/setters";
+import { Config } from "../../config/store";
+import * as CustomTextState from "../../legacy-states/custom-text-name";
+import { restartTestEvent } from "../../states/core";
+import { hideModalAndClearChain, showModal } from "../../states/modals";
+import {
+ showNoticeNotification,
+ showErrorNotification,
+} from "../../states/notifications";
+import { getLoadedChallenge, setLoadedChallenge } from "../../states/test";
+import * as CustomText from "../../test/custom-text";
+import * as PractiseWords from "../../test/practise-words";
+import { cn } from "../../utils/cn";
+import * as Strings from "../../utils/strings";
+import { AnimatedModal } from "../common/AnimatedModal";
+import { Button } from "../common/Button";
+import { Fa } from "../common/Fa";
+import { Separator } from "../common/Separator";
+import { SubmitButton } from "../ui/form/SubmitButton";
+import { TextareaField } from "../ui/form/TextareaField";
+import { CustomGeneratorModal } from "./CustomGeneratorModal";
+import { SaveCustomTextModal } from "./SaveCustomTextModal";
+import { SavedTextsModal } from "./SavedTextsModal";
+import { WordFilterModal } from "./WordFilterModal";
+
+export type CustomTextIncomingData =
+ | ({ set?: boolean; long?: boolean } & (
+ | { text: string; splitText?: never }
+ | { text?: never; splitText: string[] }
+ ))
+ | null;
+
+type Mode = "simple" | CustomTextMode;
+
+const modeOptions = [
+ { value: "simple", label: "simple" },
+ { value: "repeat", label: "repeat" },
+ { value: "shuffle", label: "shuffle" },
+ { value: "random", label: "random" },
+];
+
+const delimiterOptions = [
+ { value: "true", label: "pipe" },
+ { value: "false", label: "space" },
+];
+
+export function CustomTextModal(): JSXElement {
+ const [longTextWarning, setLongTextWarning] = createSignal(false);
+ const [challengeWarning, setChallengeWarning] = createSignal(false);
+
+ const [incomingChainedData, setIncomingChainedData] =
+ createSignal(null);
+
+ const [textToSave, setTextToSave] = createSignal([]);
+
+ // oxlint-disable-next-line no-unassigned-vars -- assigned via SolidJS ref
+ let fileInputRef!: HTMLInputElement;
+ // oxlint-disable-next-line no-unassigned-vars -- assigned via SolidJS ref
+ let textareaRef: HTMLTextAreaElement | undefined;
+
+ const form = createForm(() => ({
+ defaultValues: {
+ text: "",
+ mode: "simple" as Mode,
+ limitWord: "",
+ limitTime: "",
+ limitSection: "",
+ pipeDelimiter: false,
+ },
+ onSubmit: ({ value }) => {
+ if (value.text === "") {
+ showNoticeNotification("Text cannot be empty");
+ return;
+ }
+
+ const activeLimits = [
+ value.limitWord,
+ value.limitTime,
+ value.limitSection,
+ ].filter((l) => l !== "");
+ if (activeLimits.length > 1) {
+ showNoticeNotification("You can only specify one limit", {
+ durationMs: 5000,
+ });
+ return;
+ }
+
+ if (
+ value.mode !== "simple" &&
+ value.limitWord === "" &&
+ value.limitTime === "" &&
+ value.limitSection === ""
+ ) {
+ showNoticeNotification("You need to specify a limit", {
+ durationMs: 5000,
+ });
+ return;
+ }
+
+ if (
+ value.limitSection === "0" ||
+ value.limitWord === "0" ||
+ value.limitTime === "0"
+ ) {
+ showNoticeNotification(
+ "Infinite test! Make sure to use Bail Out from the command line to save your result.",
+ { durationMs: 7000 },
+ );
+ }
+
+ const text = cleanUpText();
+ if (text.length === 0) {
+ showNoticeNotification("Text cannot be empty");
+ return;
+ }
+
+ if (value.mode === "simple") {
+ CustomText.setMode("repeat");
+ } else {
+ CustomText.setMode(value.mode);
+ }
+
+ CustomText.setPipeDelimiter(value.pipeDelimiter);
+ CustomText.setText(text);
+
+ if (value.mode === "simple" && value.pipeDelimiter) {
+ CustomText.setLimitMode("section");
+ CustomText.setLimitValue(text.length);
+ } else if (value.mode === "simple") {
+ CustomText.setLimitMode("word");
+ CustomText.setLimitValue(text.length);
+ } else if (value.limitWord !== "") {
+ CustomText.setLimitMode("word");
+ CustomText.setLimitValue(parseInt(value.limitWord));
+ } else if (value.limitTime !== "") {
+ CustomText.setLimitMode("time");
+ CustomText.setLimitValue(parseInt(value.limitTime));
+ } else if (value.limitSection !== "") {
+ CustomText.setLimitMode("section");
+ CustomText.setLimitValue(parseInt(value.limitSection));
+ }
+
+ if (getLoadedChallenge() !== null) {
+ showNoticeNotification("Challenge cleared");
+ setLoadedChallenge(null);
+ }
+ if (Config.mode !== "custom") {
+ setConfig("mode", "custom");
+ }
+ PractiseWords.resetBefore();
+ restartTestEvent.dispatch();
+ hideModalAndClearChain("CustomText");
+ },
+ }));
+
+ const formValues = form.useStore((s) => s.values);
+
+ const isDisabled = () => longTextWarning() || challengeWarning();
+ const isLimitDisabled = () => formValues().mode === "simple" || isDisabled();
+
+ const showWordLimit = () => !formValues().pipeDelimiter;
+ const showSectionLimit = () => formValues().pipeDelimiter;
+
+ const cleanUpText = (): string[] => {
+ let text = form.getFieldValue("text");
+ if (text === "") return [];
+
+ text = text.normalize();
+ text = text.replace(/[\u2000-\u200A\u202F\u205F\u00A0]/g, " ");
+ text = text.replace(/ +/gm, " ");
+ text = text.replace(/( *(\r\n|\r|\n) *)/g, "\n ");
+
+ return text
+ .split(form.getFieldValue("pipeDelimiter") ? "|" : " ")
+ .filter((word) => word !== "");
+ };
+
+ const applyRemoveZeroWidth = () => {
+ form.setFieldValue(
+ "text",
+ form.getFieldValue("text").replace(/[\u200B-\u200D\u2060\uFEFF]/g, ""),
+ );
+ };
+
+ const applyRemoveFancyTypography = () => {
+ form.setFieldValue(
+ "text",
+ Strings.cleanTypographySymbols(form.getFieldValue("text")),
+ );
+ };
+
+ const applyReplaceControlChars = () => {
+ form.setFieldValue(
+ "text",
+ Strings.replaceControlCharacters(form.getFieldValue("text")),
+ );
+ };
+
+ const applyReplaceNewlines = (mode: "space" | "periodSpace") => {
+ let text = form.getFieldValue("text");
+ if (mode === "periodSpace") {
+ text = text.replace(/\n/gm, ". ");
+ text = text.replace(/\.\. /gm, ". ");
+ text = text.replace(/ +/gm, " ");
+ } else {
+ text = text.replace(/\n/gm, " ");
+ text = text.replace(/ +/gm, " ");
+ }
+ form.setFieldValue("text", text);
+ };
+
+ const handleDelimiterChange = (newPipeDelimiter: boolean) => {
+ const currentPipeDelimiter = form.getFieldValue("pipeDelimiter");
+ let newtext = form
+ .getFieldValue("text")
+ .split(currentPipeDelimiter ? "|" : " ")
+ .join(newPipeDelimiter ? "|" : " ");
+ newtext = newtext.replace(/\n /g, "\n");
+
+ batch(() => {
+ form.setFieldValue("text", newtext);
+ form.setFieldValue("pipeDelimiter", newPipeDelimiter);
+ if (newPipeDelimiter && form.getFieldValue("limitWord") !== "") {
+ form.setFieldValue("limitWord", "");
+ }
+ if (!newPipeDelimiter && form.getFieldValue("limitSection") !== "") {
+ form.setFieldValue("limitSection", "");
+ }
+ });
+ };
+
+ const initState = () => {
+ let mode: Mode = CustomText.getMode();
+ if (
+ mode === "repeat" &&
+ CustomText.getLimitMode() !== "time" &&
+ CustomText.getLimitValue() === CustomText.getText().length
+ ) {
+ mode = "simple";
+ }
+
+ const pipeDelimiter = CustomText.getPipeDelimiter();
+ let limitWord = "";
+ let limitTime = "";
+ let limitSection = "";
+
+ if (mode !== "simple") {
+ if (CustomText.getLimitMode() === "word") {
+ limitWord = `${CustomText.getLimitValue()}`;
+ } else if (CustomText.getLimitMode() === "time") {
+ limitTime = `${CustomText.getLimitValue()}`;
+ } else if (CustomText.getLimitMode() === "section") {
+ limitSection = `${CustomText.getLimitValue()}`;
+ }
+ }
+
+ const text = CustomText.getText()
+ .join(pipeDelimiter ? "|" : " ")
+ .replace(/^ +/gm, "");
+
+ untrack(() => {
+ batch(() => {
+ form.setFieldValue("mode", mode);
+ form.setFieldValue("limitWord", limitWord);
+ form.setFieldValue("limitTime", limitTime);
+ form.setFieldValue("limitSection", limitSection);
+ form.setFieldValue("pipeDelimiter", pipeDelimiter);
+ form.setFieldValue("text", text);
+ });
+ });
+
+ setLongTextWarning(CustomTextState.isCustomTextLong() ?? false);
+ setChallengeWarning(getLoadedChallenge() !== null);
+ };
+
+ const handleIncomingData = () => {
+ const data = incomingChainedData();
+ if (data === null) return;
+ setIncomingChainedData(null);
+
+ if (data.long !== true && CustomTextState.isCustomTextLong()) {
+ CustomTextState.setCustomTextName("", undefined);
+ showNoticeNotification("Disabled long custom text progress tracking", {
+ durationMs: 5000,
+ });
+ setLongTextWarning(false);
+ }
+
+ if (data.long) {
+ setLongTextWarning(true);
+ }
+
+ const incomingText =
+ data.splitText !== undefined
+ ? data.splitText.join(form.getFieldValue("pipeDelimiter") ? "|" : " ")
+ : data.text;
+
+ const newText =
+ (data.set ?? true)
+ ? incomingText
+ : form.getFieldValue("text") + " " + incomingText;
+ untrack(() => {
+ batch(() => {
+ form.setFieldValue("text", newText);
+ form.setFieldValue("mode", "simple");
+ form.setFieldValue("limitWord", `${cleanUpText().length}`);
+ form.setFieldValue("limitTime", "");
+ form.setFieldValue("limitSection", "");
+ });
+ });
+ };
+
+ const handleFileOpen = () => {
+ const file = fileInputRef?.files?.[0];
+ if (!file) return;
+
+ if (file.type !== "text/plain") {
+ showErrorNotification("File is not a text file", { durationMs: 5000 });
+ return;
+ }
+
+ const reader = new FileReader();
+ reader.readAsText(file, "UTF-8");
+ reader.onload = (e) => {
+ const content = e.target?.result as string;
+ form.setFieldValue("text", content);
+ fileInputRef.value = "";
+ };
+ reader.onerror = () => {
+ showErrorNotification("Failed to read file", { durationMs: 5000 });
+ };
+ };
+
+ const handleTextareaKeydown = (e: KeyboardEvent) => {
+ if (e.key === "Tab") {
+ e.preventDefault();
+ const area = e.currentTarget as HTMLTextAreaElement;
+ const start = area.selectionStart;
+ const end = area.selectionEnd;
+ area.value =
+ area.value.substring(0, start) + "\t" + area.value.substring(end);
+ area.selectionStart = area.selectionEnd = start + 1;
+ form.setFieldValue("text", area.value);
+ }
+ };
+
+ const handleTextareaKeypress = (e: KeyboardEvent) => {
+ if (isDisabled()) {
+ e.preventDefault();
+ return;
+ }
+ if (e.code === "Enter" && e.ctrlKey) {
+ void form.handleSubmit();
+ }
+ if (
+ CustomTextState.isCustomTextLong() &&
+ CustomTextState.getCustomTextName() !== ""
+ ) {
+ CustomTextState.setCustomTextName("", undefined);
+ setLongTextWarning(false);
+ showNoticeNotification("Disabled long custom text progress tracking", {
+ durationMs: 5000,
+ });
+ }
+ };
+
+ const handleModeChange = (value: string) => {
+ batch(() => {
+ const previousMode = formValues().mode;
+
+ form.setFieldValue("mode", value as Mode);
+ if (value === "simple") {
+ form.setFieldValue("limitWord", "");
+ form.setFieldValue("limitTime", "");
+ form.setFieldValue("limitSection", "");
+ } else if (previousMode === "simple") {
+ const text = cleanUpText();
+ form.setFieldValue("limitWord", `${text.length}`);
+ form.setFieldValue("limitTime", "");
+ form.setFieldValue("limitSection", `${text.length}`);
+ }
+ });
+ };
+
+ const beforeShow = (isChained: boolean) => {
+ if (!isChained) {
+ initState();
+ } else {
+ handleIncomingData();
+ }
+ };
+
+ const afterShow = () => {
+ if (!isDisabled()) {
+ textareaRef?.focus();
+ }
+ };
+
+ return (
+ <>
+
+
+
+
+
+
+
+ >
+ );
+}
+
+function SettingsGroup(props: {
+ title: string;
+ icon: FaSolidIcon;
+ iconClass?: string;
+ sub: string;
+ children: JSXElement;
+}): JSXElement {
+ return (
+
+
+
+ {props.title}
+
+
{props.sub}
+ {props.children}
+
+ );
+}
diff --git a/frontend/src/ts/components/modals/Modals.tsx b/frontend/src/ts/components/modals/Modals.tsx
index 0c8a5c21c784..c219544da7b6 100644
--- a/frontend/src/ts/components/modals/Modals.tsx
+++ b/frontend/src/ts/components/modals/Modals.tsx
@@ -1,6 +1,10 @@
import { JSXElement } from "solid-js";
import { ContactModal } from "./ContactModal";
+import { CustomTextModal } from "./CustomTextModal";
+import { QuoteRateModal } from "./QuoteRateModal";
+import { QuoteReportModal } from "./QuoteReportModal";
+import { QuoteSearchModal } from "./QuoteSearchModal";
import { RegisterCaptchaModal } from "./RegisterCaptchaModal";
import { SimpleModal } from "./SimpleModal";
import { SupportModal } from "./SupportModal";
@@ -14,6 +18,10 @@ export function Modals(): JSXElement {
+
+
+
+
>
);
}
diff --git a/frontend/src/ts/components/modals/QuoteApproveModal.tsx b/frontend/src/ts/components/modals/QuoteApproveModal.tsx
new file mode 100644
index 000000000000..f304538afcb9
--- /dev/null
+++ b/frontend/src/ts/components/modals/QuoteApproveModal.tsx
@@ -0,0 +1,239 @@
+import { Quote } from "@monkeytype/schemas/quotes";
+import { createForm } from "@tanstack/solid-form";
+import { format } from "date-fns/format";
+import { JSXElement, createSignal, For, Show } from "solid-js";
+
+import Ape from "../../ape";
+import { hideLoaderBar, showLoaderBar } from "../../states/loader-bar";
+import {
+ showErrorNotification,
+ showSuccessNotification,
+} from "../../states/notifications";
+import { cn } from "../../utils/cn";
+import { AnimatedModal } from "../common/AnimatedModal";
+import { Button } from "../common/Button";
+import { Fa } from "../common/Fa";
+import { InputField } from "../ui/form/InputField";
+
+function QuoteApproveItem(props: {
+ quote: Quote;
+ onRemove: () => void;
+}): JSXElement {
+ const [disabled, setDisabled] = createSignal(false);
+
+ const form = createForm(() => ({
+ defaultValues: {
+ text: props.quote.text,
+ source: props.quote.source,
+ },
+ }));
+
+ const isEdited = (): boolean => form.state.isDirty;
+
+ const undo = (): void => {
+ form.reset();
+ };
+
+ const approve = async (): Promise => {
+ if (!confirm("Are you sure?")) return;
+ setDisabled(true);
+
+ showLoaderBar();
+ const response = await Ape.quotes.approveSubmission({
+ body: { quoteId: props.quote._id },
+ });
+ hideLoaderBar();
+
+ if (response.status !== 200) {
+ setDisabled(false);
+ showErrorNotification("Failed to approve quote", { response });
+ return;
+ }
+
+ showSuccessNotification(`Quote approved. ${response.body.message ?? ""}`);
+ props.onRemove();
+ };
+
+ const refuse = async (): Promise => {
+ if (!confirm("Are you sure?")) return;
+ setDisabled(true);
+
+ showLoaderBar();
+ const response = await Ape.quotes.rejectSubmission({
+ body: { quoteId: props.quote._id },
+ });
+ hideLoaderBar();
+
+ if (response.status !== 200) {
+ setDisabled(false);
+ showErrorNotification("Failed to refuse quote", { response });
+ return;
+ }
+
+ showSuccessNotification("Quote refused.");
+ props.onRemove();
+ };
+
+ const editAndApprove = async (): Promise => {
+ if (!confirm("Are you sure?")) return;
+ setDisabled(true);
+
+ showLoaderBar();
+ const response = await Ape.quotes.approveSubmission({
+ body: {
+ quoteId: props.quote._id,
+ editText: form.state.values.text,
+ editSource: form.state.values.source,
+ },
+ });
+ hideLoaderBar();
+
+ if (response.status !== 200) {
+ setDisabled(false);
+ showErrorNotification("Failed to approve quote", { response });
+ return;
+ }
+
+ showSuccessNotification(
+ `Quote edited and approved. ${response.body.message ?? ""}`,
+ );
+ props.onRemove();
+ };
+
+ return (
+
+
(
+
+ )}
+ />
+ (
+
+ )}
+ />
+
+
+
+
+
(
+
+ {field().state.value.length}
+
+ )}
+ />
+
+ {props.quote.language}
+
+
+ {" "}
+ {format(new Date(props.quote.timestamp), "dd MMM yyyy HH:mm")}
+
+
+
+ );
+}
+
+export function QuoteApproveModal(): JSXElement {
+ const [quotes, setQuotes] = createSignal([]);
+
+ const fetchQuotes = async (): Promise => {
+ showLoaderBar();
+ const response = await Ape.quotes.get();
+ hideLoaderBar();
+
+ if (response.status !== 200) {
+ showErrorNotification("Failed to get new quotes", { response });
+ return;
+ }
+
+ setQuotes(response.body.data ?? []);
+ };
+
+ const handleBeforeShow = (): void => {
+ setQuotes([]);
+ void fetchQuotes();
+ };
+
+ const removeQuote = (index: number): void => {
+ setQuotes((prev) => prev.filter((_, i) => i !== index));
+ };
+
+ return (
+
+
+ {
+ setQuotes([]);
+ void fetchQuotes();
+ }}
+ />
+
+
+
+ {(quote, index) => (
+ removeQuote(index())}
+ />
+ )}
+
+
+
+ );
+}
diff --git a/frontend/src/ts/components/modals/QuoteRateModal.tsx b/frontend/src/ts/components/modals/QuoteRateModal.tsx
new file mode 100644
index 000000000000..2e79b636f5f0
--- /dev/null
+++ b/frontend/src/ts/components/modals/QuoteRateModal.tsx
@@ -0,0 +1,210 @@
+import { isSafeNumber } from "@monkeytype/util/numbers";
+import { JSXElement, createSignal, For } from "solid-js";
+
+import Ape from "../../ape";
+import * as DB from "../../db";
+import { hideLoaderBar, showLoaderBar } from "../../states/loader-bar";
+import { hideModalAndClearChain } from "../../states/modals";
+import {
+ showNoticeNotification,
+ showErrorNotification,
+ showSuccessNotification,
+} from "../../states/notifications";
+import {
+ currentQuote,
+ quoteStats,
+ getQuoteStats,
+ updateQuoteStats,
+ getRatingAverage,
+} from "../../states/quote-rate";
+import { cn } from "../../utils/cn";
+import { qs } from "../../utils/dom";
+import { AnimatedModal } from "../common/AnimatedModal";
+import { Button } from "../common/Button";
+import { Fa } from "../common/Fa";
+import { Separator } from "../common/Separator";
+
+export function QuoteRateModal(): JSXElement {
+ const [rating, setRating] = createSignal(0);
+ const [hoverRating, setHoverRating] = createSignal(0);
+
+ const getLengthDesc = (): string => {
+ const quote = currentQuote();
+ if (!quote) return "-";
+ if (quote.group === 0) return "short";
+ if (quote.group === 1) return "medium";
+ if (quote.group === 2) return "long";
+ if (quote.group === 3) return "thicc";
+ return "-";
+ };
+
+ const displayRating = (): number => hoverRating() || rating();
+
+ const handleBeforeShow = (): void => {
+ const quote = currentQuote();
+ if (!quote) return;
+ setRating(0);
+ setHoverRating(0);
+ const snapshot = DB.getSnapshot();
+ const alreadyRated = snapshot?.quoteRatings?.[quote.language]?.[quote.id];
+ if (isSafeNumber(alreadyRated)) {
+ setRating(alreadyRated);
+ }
+ void getQuoteStats(quote);
+ };
+
+ const submit = async (): Promise => {
+ if (rating() === 0) {
+ showNoticeNotification("Please select a rating");
+ return;
+ }
+ const quote = currentQuote();
+ if (!quote) return;
+
+ hideModalAndClearChain("QuoteRate");
+
+ showLoaderBar();
+ const response = await Ape.quotes.addRating({
+ body: { quoteId: quote.id, language: quote.language, rating: rating() },
+ });
+ hideLoaderBar();
+
+ if (response.status !== 200) {
+ showErrorNotification("Failed to submit quote rating", { response });
+ return;
+ }
+
+ const snapshot = DB.getSnapshot();
+ if (!snapshot) return;
+ const quoteRatings = snapshot.quoteRatings ?? {};
+ const languageRatings = quoteRatings?.[quote.language] ?? {};
+ const stats = quoteStats() ?? {};
+
+ if (isSafeNumber(languageRatings?.[quote.id])) {
+ const oldRating = quoteRatings[quote.language]?.[quote.id] as number;
+ const diff = rating() - oldRating;
+ languageRatings[quote.id] = rating();
+ const newStats = {
+ ratings: stats?.ratings,
+ totalRating: isNaN(stats?.totalRating as number)
+ ? 0
+ : (stats?.totalRating as number) + diff,
+ quoteId: quote.id,
+ language: quote.language,
+ };
+ updateQuoteStats(newStats);
+ showSuccessNotification("Rating updated");
+ } else {
+ languageRatings[quote.id] = rating();
+ if (isSafeNumber(stats?.ratings) && isSafeNumber(stats.totalRating)) {
+ const newStats = {
+ ratings: stats.ratings + 1,
+ totalRating: stats.totalRating + rating(),
+ quoteId: quote.id,
+ language: quote.language,
+ };
+ updateQuoteStats(newStats);
+ } else {
+ updateQuoteStats({
+ ratings: 1,
+ totalRating: rating(),
+ quoteId: quote.id,
+ language: quote.language,
+ });
+ }
+ showSuccessNotification("Rating submitted");
+ }
+
+ snapshot.quoteRatings = quoteRatings;
+ DB.setSnapshot(snapshot);
+
+ const currentStats = quoteStats();
+ if (currentStats) {
+ const avg = getRatingAverage(currentStats);
+ updateQuoteStats({ ...currentStats, average: avg });
+ qs(".pageTest #result #rateQuoteButton .rating")?.setText(avg.toFixed(1));
+ qs(".pageTest #result #rateQuoteButton .icon")?.removeClass("far");
+ qs(".pageTest #result #rateQuoteButton .icon")?.addClass("fas");
+ }
+ };
+
+ return (
+
+
+ If you find a grammatical error or the quote has inappropriate language
+ - don{"'"}t give it a low rating! Please
+ report it instead. You can do so by closing this popup and clicking the{" "}
+ flag icon.
+
+
+
+
+ {currentQuote()?.text ?? "-"}
+
+
+
+
id
+ {currentQuote()?.id ?? "-"}
+
+
+
length
+ {getLengthDesc()}
+
+
+
source
+ {currentQuote()?.source ?? "-"}
+
+
+
+
+
+
+
+
ratings
+
+ {quoteStats()?.ratings?.toString() ?? "0"}
+
+
+
+
average
+
+ {quoteStats()?.average?.toFixed(1) ?? "-"}
+
+
+
+
your rating
+
+
+ {(star) => (
+ = star
+ ? "[--themable-button-text:var(--main-color)]"
+ : "",
+ )}
+ fa={{ icon: "fa-star", fixedWidth: true }}
+ onClick={() => setRating(star)}
+ onMouseEnter={() => setHoverRating(star)}
+ onMouseLeave={() => setHoverRating(0)}
+ />
+ )}
+
+
+
+
+
void submit()}
+ />
+
+
+ );
+}
diff --git a/frontend/src/ts/components/modals/QuoteReportModal.tsx b/frontend/src/ts/components/modals/QuoteReportModal.tsx
new file mode 100644
index 000000000000..0de7c72fae9f
--- /dev/null
+++ b/frontend/src/ts/components/modals/QuoteReportModal.tsx
@@ -0,0 +1,200 @@
+import { QuoteReportReason } from "@monkeytype/schemas/quotes";
+import { createForm } from "@tanstack/solid-form";
+import { JSXElement, createSignal } from "solid-js";
+
+import Ape from "../../ape";
+import { Config } from "../../config/store";
+import * as CaptchaController from "../../controllers/captcha-controller";
+import QuotesController from "../../controllers/quotes-controller";
+import { useRef } from "../../hooks/useRef";
+import { hideLoaderBar, showLoaderBar } from "../../states/loader-bar";
+import { hideModalAndClearChain } from "../../states/modals";
+import {
+ showNoticeNotification,
+ showErrorNotification,
+ showSuccessNotification,
+} from "../../states/notifications";
+import { quoteId } from "../../states/quote-report";
+import { removeLanguageSize } from "../../utils/strings";
+import { AnimatedModal } from "../common/AnimatedModal";
+import { Button } from "../common/Button";
+import { Separator } from "../common/Separator";
+import { fieldMandatory } from "../ui/form/utils";
+import SlimSelect from "../ui/SlimSelect";
+
+export function QuoteReportModal(): JSXElement {
+ const [captchaRef, captchaEl] = useRef();
+ const [quoteText, setQuoteText] = createSignal("");
+ const [captchaComplete, setCaptchaComplete] = createSignal(false);
+
+ const form = createForm(() => ({
+ defaultValues: {
+ reason: "Grammatical error" as QuoteReportReason,
+ comment: "",
+ },
+ onSubmit: async ({ value }) => {
+ const captchaResponse = CaptchaController.getResponse("quoteReportModal");
+ if (!captchaResponse) {
+ showNoticeNotification("Please complete the captcha");
+ return;
+ }
+
+ const id = quoteId().toString();
+ const quoteLanguage = removeLanguageSize(Config.language);
+
+ if (id === "" || id === "0") {
+ showNoticeNotification("Please select a quote");
+ return;
+ }
+
+ const characterDifference = value.comment.length - 250;
+ if (characterDifference > 0) {
+ showNoticeNotification(
+ `Report comment is ${characterDifference} character(s) too long`,
+ );
+ return;
+ }
+
+ showLoaderBar();
+ const response = await Ape.quotes.report({
+ body: {
+ quoteId: id,
+ quoteLanguage,
+ reason: value.reason,
+ comment: value.comment,
+ captcha: captchaResponse,
+ },
+ });
+ hideLoaderBar();
+
+ if (response.status !== 200) {
+ showErrorNotification("Failed to report quote", { response });
+ return;
+ }
+
+ showSuccessNotification("Report submitted. Thank you!");
+ hideModalAndClearChain("QuoteReport");
+ },
+ onSubmitInvalid: () => {
+ showNoticeNotification("Please fill in all fields");
+ },
+ }));
+
+ const handleBeforeShow = async (): Promise => {
+ setCaptchaComplete(false);
+ form.update({
+ ...form.options,
+ defaultValues: {
+ reason: "Grammatical error" as QuoteReportReason,
+ comment: "",
+ },
+ });
+ form.reset();
+
+ const language =
+ Config.language === "swiss_german" ? "german" : Config.language;
+ const { quotes } = await QuotesController.getQuotes(language);
+ const quote = quotes.find((q) => q.id === quoteId());
+ setQuoteText(quote?.text ?? "");
+ };
+
+ const handleAfterShow = (): void => {
+ const el = captchaEl();
+ if (el === undefined) return;
+ CaptchaController.render(el, "quoteReportModal", () => {
+ setCaptchaComplete(true);
+ });
+ };
+
+ const handleAfterHide = (): void => {
+ CaptchaController.reset("quoteReportModal");
+ };
+
+ return (
+
+ (
+
+
+
+ field().handleChange(
+ (val ?? "Grammatical error") as QuoteReportReason,
+ )
+ }
+ settings={{ showSearch: false }}
+ />
+
+ )}
+ />
+ () }}
+ children={(field) => (
+
+
+
+
+
+ {250 - field().state.value.length}
+
+
+
+ )}
+ />
+
+
+
+
+ );
+}
diff --git a/frontend/src/ts/components/modals/QuoteSearchModal.tsx b/frontend/src/ts/components/modals/QuoteSearchModal.tsx
new file mode 100644
index 000000000000..497792ed0165
--- /dev/null
+++ b/frontend/src/ts/components/modals/QuoteSearchModal.tsx
@@ -0,0 +1,568 @@
+import { createForm } from "@tanstack/solid-form";
+import {
+ JSXElement,
+ createSignal,
+ createEffect,
+ For,
+ Show,
+ on,
+} from "solid-js";
+import { debounce } from "throttle-debounce";
+
+import Ape from "../../ape";
+import { setConfig } from "../../config/setters";
+import { Config } from "../../config/store";
+import { isCaptchaAvailable } from "../../controllers/captcha-controller";
+import QuotesController, { Quote } from "../../controllers/quotes-controller";
+import * as DB from "../../db";
+import { isAuthenticated } from "../../firebase";
+import { hideLoaderBar, showLoaderBar } from "../../states/loader-bar";
+import {
+ hideModalAndClearChain,
+ isModalOpen,
+ showModal,
+} from "../../states/modals";
+import {
+ showNoticeNotification,
+ showErrorNotification,
+} from "../../states/notifications";
+import { showQuoteReportModal } from "../../states/quote-report";
+import { showSimpleModal } from "../../states/simple-modal";
+import * as TestLogic from "../../test/test-logic";
+import * as TestState from "../../test/test-state";
+import { cn } from "../../utils/cn";
+import { getLanguage } from "../../utils/json-data";
+import {
+ buildSearchService,
+ SearchService,
+ TextExtractor,
+} from "../../utils/search-service";
+import { highlightMatches } from "../../utils/strings";
+import { AnimatedModal } from "../common/AnimatedModal";
+import { Button } from "../common/Button";
+import { InputField } from "../ui/form/InputField";
+import SlimSelect from "../ui/SlimSelect";
+import { QuoteApproveModal } from "./QuoteApproveModal";
+import { QuoteSubmitModal } from "./QuoteSubmitModal";
+
+const PAGE_SIZE = 100;
+
+const searchServiceCache: Record> = {};
+
+function getSearchService(
+ language: string,
+ data: T[],
+ textExtractor: TextExtractor,
+): SearchService {
+ if (language in searchServiceCache) {
+ return searchServiceCache[language] as unknown as SearchService;
+ }
+ const newSearchService = buildSearchService(data, textExtractor);
+ searchServiceCache[language] =
+ newSearchService as unknown as (typeof searchServiceCache)[typeof language];
+ return newSearchService;
+}
+
+function exactSearch(quotes: Quote[], captured: RegExp[]): [Quote[], string[]] {
+ const matches: Quote[] = [];
+ const exactSearchQueryTerms: Set = new Set();
+
+ for (const quote of quotes) {
+ const textAndSource = quote.text + quote.source;
+ const currentMatches: string[] = [];
+ let noMatch = false;
+
+ for (const regex of captured) {
+ const match = textAndSource.match(regex);
+ if (!match) {
+ noMatch = true;
+ break;
+ }
+ currentMatches.push(RegExp.escape(match[0]));
+ }
+
+ if (!noMatch) {
+ currentMatches.forEach((m) => exactSearchQueryTerms.add(m));
+ matches.push(quote);
+ }
+ }
+
+ return [matches, Array.from(exactSearchQueryTerms)];
+}
+
+function getLengthDesc(quote: Quote): string {
+ if (quote.length < 101) return "short";
+ if (quote.length < 301) return "medium";
+ if (quote.length < 601) return "long";
+ return "thicc";
+}
+
+function Item(props: {
+ quote: Quote;
+ matchedTerms: string[];
+ dataBalloonDirection: string;
+ onSelect: () => void;
+ onReport: () => void;
+ onToggleFavorite: () => Promise;
+}): JSXElement {
+ const loggedOut = (): boolean => !isAuthenticated();
+ const [isFav, setIsFav] = createSignal(
+ // oxlint-disable-next-line solid/reactivity -- intentionally reading once as initial value
+ !loggedOut() && QuotesController.isQuoteFavorite(props.quote),
+ );
+
+ const handleToggleFavorite = async (): Promise => {
+ setIsFav((v) => !v);
+ const success = await props.onToggleFavorite();
+ if (!success) {
+ setIsFav((v) => !v);
+ }
+ };
+
+ return (
+ props.onSelect()}
+ >
+
+
+
+
+
length
+ {getLengthDesc(props.quote)}
+
+
+
+
+
+ {
+ e.stopPropagation();
+ props.onReport();
+ }}
+ balloon={{
+ text: "Report quote",
+ position: props.dataBalloonDirection as "left" | "right",
+ }}
+ />
+ {
+ e.stopPropagation();
+ void handleToggleFavorite();
+ }}
+ balloon={{
+ text: "Favorite quote",
+ position: props.dataBalloonDirection as "left" | "right",
+ }}
+ />
+
+
+
+
+
+ );
+}
+
+export function QuoteSearchModal(): JSXElement {
+ const form = createForm(() => ({
+ defaultValues: {
+ searchText: "",
+ },
+ }));
+
+ const [currentPage, setCurrentPage] = createSignal(1);
+ const [lengthFilter, setLengthFilter] = createSignal([]);
+ const [showFavoritesOnly, setShowFavoritesOnly] = createSignal(false);
+ const [customFilterMin, setCustomFilterMin] = createSignal(0);
+ const [customFilterMax, setCustomFilterMax] = createSignal(0);
+ const [hasCustomFilter, setHasCustomFilter] = createSignal(false);
+ const [quotes, setQuotes] = createSignal([]);
+ const [searchResults, setSearchResults] = createSignal<{
+ quotes: Quote[];
+ matchedTerms: string[];
+ }>({ quotes: [], matchedTerms: [] });
+ const [dataBalloonDirection, setDataBalloonDirection] = createSignal("left");
+ const [favVersion, setFavVersion] = createSignal(0);
+
+ const debouncedSearch = debounce(250, (text: string) => {
+ setCurrentPage(1);
+ performSearch(text);
+ });
+
+ const isOpen = (): boolean => isModalOpen("QuoteSearch");
+
+ const isQuoteMod = (): boolean => {
+ const quoteMod = DB.getSnapshot()?.quoteMod;
+ return (
+ quoteMod !== undefined &&
+ (quoteMod === true || (quoteMod as string) !== "")
+ );
+ };
+
+ const performSearch = (text: string): void => {
+ const allQuotes = quotes();
+ if (allQuotes.length === 0) {
+ setSearchResults({ quotes: [], matchedTerms: [] });
+ return;
+ }
+
+ let matches: Quote[] = [];
+ let matchedQueryTerms: string[] = [];
+
+ if (text === "") {
+ setSearchResults({ quotes: allQuotes, matchedTerms: [] });
+ return;
+ }
+
+ let exactSearchMatches: Quote[] = [];
+ let exactSearchMatchedQueryTerms: string[] = [];
+
+ const quotationsRegex = /"(.*?)"/g;
+ const exactSearchQueries = Array.from(text.matchAll(quotationsRegex));
+ const removedSearchText = text.replaceAll(quotationsRegex, "");
+
+ if (exactSearchQueries[0]) {
+ const searchQueriesRaw = exactSearchQueries.map(
+ (query) => new RegExp(RegExp.escape(query[1] ?? ""), "i"),
+ );
+ [exactSearchMatches, exactSearchMatchedQueryTerms] = exactSearch(
+ allQuotes,
+ searchQueriesRaw,
+ );
+ }
+
+ const quoteSearchService = getSearchService(
+ Config.language,
+ allQuotes,
+ (quote: Quote) => `${quote.text} ${quote.id} ${quote.source}`,
+ );
+
+ if (exactSearchMatches.length > 0 || removedSearchText === text) {
+ const ids = exactSearchMatches.map((m) => m.id);
+ ({ results: matches, matchedQueryTerms } = quoteSearchService.query(
+ removedSearchText,
+ ids,
+ ));
+ exactSearchMatches.forEach((m) => {
+ if (!matches.includes(m)) matches.push(m);
+ });
+ matchedQueryTerms = [
+ ...exactSearchMatchedQueryTerms,
+ ...matchedQueryTerms,
+ ];
+ }
+
+ setSearchResults({ quotes: matches, matchedTerms: matchedQueryTerms });
+ };
+
+ const filteredQuotes = (): Quote[] => {
+ favVersion();
+
+ let result = searchResults().quotes;
+
+ const lengths = lengthFilter();
+ if (lengths.length > 0) {
+ const groupFilter = new Set(
+ lengths.filter((v) => v !== "4").map((v) => parseInt(v, 10)),
+ );
+ const hasCustom = lengths.includes("4");
+
+ result = result.filter((quote) => {
+ if (groupFilter.has(quote.group)) return true;
+ if (
+ hasCustom &&
+ hasCustomFilter() &&
+ quote.length >= customFilterMin() &&
+ quote.length <= customFilterMax()
+ ) {
+ return true;
+ }
+ return false;
+ });
+ }
+
+ if (showFavoritesOnly()) {
+ result = result.filter((quote) =>
+ QuotesController.isQuoteFavorite(quote),
+ );
+ }
+
+ return result;
+ };
+
+ const totalPages = (): number =>
+ Math.max(1, Math.ceil(filteredQuotes().length / PAGE_SIZE));
+
+ const pageQuotes = (): Quote[] => {
+ const start = (currentPage() - 1) * PAGE_SIZE;
+ return filteredQuotes().slice(start, start + PAGE_SIZE);
+ };
+
+ const pageInfo = (): string => {
+ const filtered = filteredQuotes();
+ if (filtered.length === 0) return "No search results";
+ const start = (currentPage() - 1) * PAGE_SIZE + 1;
+ const end = Math.min(currentPage() * PAGE_SIZE, filtered.length);
+ return `${start} - ${end} of ${filtered.length}`;
+ };
+
+ createEffect(
+ on(lengthFilter, (lengths) => {
+ if (lengths.includes("4") && !hasCustomFilter()) {
+ showSimpleModal({
+ title: "Enter minimum and maximum number of words",
+ inputs: [
+ { type: "number", placeholder: "1" },
+ { type: "number", placeholder: "100" },
+ ],
+ buttonText: "save",
+ execFn: async (min: string, max: string) => {
+ const minNum = parseInt(min, 10);
+ const maxNum = parseInt(max, 10);
+ if (isNaN(minNum) || isNaN(maxNum)) {
+ return { status: "notice", message: "Invalid min/max values" };
+ }
+ setCustomFilterMin(minNum);
+ setCustomFilterMax(maxNum);
+ setHasCustomFilter(true);
+ return { status: "success", message: "Saved custom filter" };
+ },
+ });
+ }
+ }),
+ );
+
+ const handleBeforeShow = (): void => {
+ form.reset();
+ setCurrentPage(1);
+ setLengthFilter([]);
+ setShowFavoritesOnly(false);
+ setHasCustomFilter(false);
+ };
+
+ const handleAfterShow = async (): Promise => {
+ const quotesLanguage = await getLanguage(Config.language);
+ setDataBalloonDirection(quotesLanguage?.rightToLeft ? "right" : "left");
+ const { quotes: fetchedQuotes } = await QuotesController.getQuotes(
+ Config.language,
+ );
+ setQuotes(fetchedQuotes);
+ performSearch("");
+ };
+
+ const applyQuote = (quoteId: number): void => {
+ if (isNaN(quoteId) || quoteId < 0) {
+ showNoticeNotification("Quote ID must be at least 1");
+ return;
+ }
+ TestState.setSelectedQuoteId(quoteId);
+ setConfig("quoteLength", [-2]);
+ TestLogic.restart();
+ hideModalAndClearChain("QuoteSearch");
+ };
+
+ const toggleFavorite = async (quote: Quote): Promise => {
+ const alreadyFavorited = QuotesController.isQuoteFavorite(quote);
+
+ try {
+ showLoaderBar();
+ await QuotesController.setQuoteFavorite(quote, !alreadyFavorited);
+ hideLoaderBar();
+ setFavVersion((v) => v + 1);
+ return true;
+ } catch (e) {
+ hideLoaderBar();
+ showErrorNotification(
+ alreadyFavorited
+ ? "Failed to remove quote from favorites"
+ : "Failed to add quote to favorites",
+ { error: e },
+ );
+ return false;
+ }
+ };
+
+ const handleSubmitClick = async (): Promise => {
+ if (!isCaptchaAvailable()) {
+ showErrorNotification(
+ "Captcha is not available. Please refresh the page or contact support if this issue persists.",
+ );
+ return;
+ }
+ showLoaderBar();
+ const getSubmissionEnabled = await Ape.quotes.isSubmissionEnabled();
+ const isEnabled =
+ (getSubmissionEnabled.status === 200 &&
+ getSubmissionEnabled.body.data?.isEnabled) ??
+ false;
+ hideLoaderBar();
+ if (!isEnabled) {
+ showNoticeNotification(
+ "Quote submission is disabled temporarily due to a large submission queue.",
+ { durationMs: 5000 },
+ );
+ return;
+ }
+ showModal("QuoteSubmit");
+ };
+
+ const handleBeforeHide = (): void => {
+ debouncedSearch.cancel();
+ };
+
+ return (
+ <>
+
+
+
Quote search
+
+
+ void handleSubmitClick()}
+ />
+
+
+ showModal("QuoteApprove")}
+ />
+
+
+
+
+
{
+ if (!isOpen()) return;
+ debouncedSearch(value);
+ },
+ }}
+ children={(field) => (
+
+ )}
+ />
+
+ setLengthFilter(val)}
+ settings={{
+ showSearch: false,
+ placeholderText: "filter by length",
+ }}
+ />
+
+
+ setShowFavoritesOnly((v) => !v)}
+ />
+
+
+
+
+ {(quote) => (
+ - applyQuote(quote.id)}
+ onReport={() => showQuoteReportModal(quote.id)}
+ // oxlint-disable-next-line solid/reactivity, typescript-eslint/promise-function-async -- fire-and-forget, no reactive tracking needed
+ onToggleFavorite={() => toggleFavorite(quote)}
+ />
+ )}
+
+
+
+
setCurrentPage((p) => p - 1)}
+ />
+
+ {pageInfo()}
+
+ = totalPages()}
+ onClick={() => setCurrentPage((p) => p + 1)}
+ />
+
+
+
+
+ >
+ );
+}
diff --git a/frontend/src/ts/components/modals/QuoteSubmitModal.tsx b/frontend/src/ts/components/modals/QuoteSubmitModal.tsx
new file mode 100644
index 000000000000..ce71959575d9
--- /dev/null
+++ b/frontend/src/ts/components/modals/QuoteSubmitModal.tsx
@@ -0,0 +1,179 @@
+import { Language } from "@monkeytype/schemas/languages";
+import { createForm } from "@tanstack/solid-form";
+import { JSXElement } from "solid-js";
+
+import Ape from "../../ape";
+import { Config } from "../../config/store";
+import { LanguageGroupNames } from "../../constants/languages";
+import * as CaptchaController from "../../controllers/captcha-controller";
+import { useRef } from "../../hooks/useRef";
+import { hideLoaderBar, showLoaderBar } from "../../states/loader-bar";
+import { hideModalAndClearChain } from "../../states/modals";
+import {
+ showNoticeNotification,
+ showErrorNotification,
+ showSuccessNotification,
+} from "../../states/notifications";
+import { removeLanguageSize } from "../../utils/strings";
+import { AnimatedModal } from "../common/AnimatedModal";
+import { Button } from "../common/Button";
+import { InputField } from "../ui/form/InputField";
+import { fieldMandatory } from "../ui/form/utils";
+import SlimSelect from "../ui/SlimSelect";
+
+export function QuoteSubmitModal(): JSXElement {
+ const [captchaRef, captchaEl] = useRef();
+
+ const languageOptions = LanguageGroupNames.filter(
+ (g) => g !== "swiss_german",
+ ).map((g) => ({
+ value: g,
+ text: g.replace(/_/g, " "),
+ }));
+
+ const form = createForm(() => ({
+ defaultValues: {
+ text: "",
+ source: "",
+ language: removeLanguageSize(Config.language) as string,
+ },
+ onSubmit: async ({ value }) => {
+ const captcha = CaptchaController.getResponse("submitQuote");
+
+ showLoaderBar();
+ const response = await Ape.quotes.add({
+ body: {
+ text: value.text,
+ source: value.source,
+ language: value.language as Language,
+ captcha,
+ },
+ });
+ hideLoaderBar();
+
+ if (response.status !== 200) {
+ showErrorNotification("Failed to submit quote", { response });
+ return;
+ }
+
+ showSuccessNotification("Quote submitted.");
+ CaptchaController.reset("submitQuote");
+ hideModalAndClearChain("QuoteSubmit");
+ },
+ onSubmitInvalid: () => {
+ showNoticeNotification("Please fill in all fields");
+ },
+ }));
+
+ const handleAfterShow = (): void => {
+ const el = captchaEl();
+ if (el === undefined) return;
+ CaptchaController.render(el, "submitQuote");
+ form.update({
+ ...form.options,
+ defaultValues: {
+ text: "",
+ source: "",
+ language: removeLanguageSize(Config.language) as string,
+ },
+ });
+ form.reset();
+ };
+
+ const handleAfterHide = (): void => {
+ CaptchaController.reset("submitQuote");
+ };
+
+ return (
+
+ () }}
+ children={(field) => (
+
+
+
+
+
+ {250 - field().state.value.length}
+
+
+
+ )}
+ />
+ () }}
+ children={(field) => (
+
+
+
+
+ )}
+ />
+ (
+
+
+ field().handleChange(val ?? "")}
+ />
+
+ )}
+ />
+
+
+
+
+ );
+}
diff --git a/frontend/src/ts/components/modals/SaveCustomTextModal.tsx b/frontend/src/ts/components/modals/SaveCustomTextModal.tsx
new file mode 100644
index 000000000000..45574dd00434
--- /dev/null
+++ b/frontend/src/ts/components/modals/SaveCustomTextModal.tsx
@@ -0,0 +1,111 @@
+import { createForm } from "@tanstack/solid-form";
+import { Accessor, JSXElement } from "solid-js";
+import { z } from "zod";
+
+import * as CustomTextState from "../../legacy-states/custom-text-name";
+import { hideModal } from "../../states/modals";
+import {
+ showNoticeNotification,
+ showErrorNotification,
+ showSuccessNotification,
+} from "../../states/notifications";
+import * as CustomText from "../../test/custom-text";
+import { AnimatedModal } from "../common/AnimatedModal";
+import { Checkbox } from "../ui/form/Checkbox";
+import { InputField } from "../ui/form/InputField";
+import { SubmitButton } from "../ui/form/SubmitButton";
+import { fromSchema } from "../ui/form/utils";
+
+const nameSchema = z
+ .string()
+ .min(1, "Name is required")
+ .max(32, "Name must be 32 characters or less")
+ .regex(
+ /^[\w\s-]+$/,
+ "Name can only contain letters, numbers, spaces, underscores and hyphens",
+ );
+
+export function SaveCustomTextModal(props: {
+ textToSave: Accessor;
+}): JSXElement {
+ const form = createForm(() => ({
+ defaultValues: {
+ name: "",
+ isLong: false,
+ },
+ onSubmit: ({ value }) => {
+ const text = props.textToSave();
+ if (text.length === 0) {
+ showNoticeNotification("Custom text can't be empty");
+ return;
+ }
+
+ const saved = CustomText.setCustomText(value.name, text, value.isLong);
+ if (saved) {
+ CustomTextState.setCustomTextName(value.name, value.isLong);
+ showSuccessNotification("Custom text saved");
+ hideModal("SaveCustomText");
+ } else {
+ showErrorNotification("Error saving custom text");
+ }
+ },
+ }));
+
+ return (
+ {
+ form.reset();
+ }}
+ >
+ {
+ const schemaErrors = fromSchema(nameSchema)({ value });
+ if (schemaErrors !== undefined) {
+ return schemaErrors;
+ }
+
+ const isLong = form.getFieldValue("isLong");
+ if (CustomText.getCustomTextNames(isLong).includes(value)) {
+ return "Duplicate name";
+ }
+
+ return undefined;
+ },
+ }}
+ children={(field) => }
+ />
+ {
+ void form.validateField("name", "change");
+ },
+ }}
+ children={(field) => (
+
+ )}
+ />
+
+ Disables editing this text but allows you to save progress by pressing
+ shift + enter or bailing out. You can then load this text again to
+ continue where you left off.
+
+
+
+
+ );
+}
diff --git a/frontend/src/ts/components/modals/SavedTextsModal.tsx b/frontend/src/ts/components/modals/SavedTextsModal.tsx
new file mode 100644
index 000000000000..230bbb1c2a4c
--- /dev/null
+++ b/frontend/src/ts/components/modals/SavedTextsModal.tsx
@@ -0,0 +1,165 @@
+import { createSignal, For, Index, JSXElement, Setter, Show } from "solid-js";
+
+import * as CustomTextState from "../../legacy-states/custom-text-name";
+import { hideModal } from "../../states/modals";
+import { showSimpleModal } from "../../states/simple-modal";
+import * as CustomText from "../../test/custom-text";
+import { AnimatedModal } from "../common/AnimatedModal";
+import { Button } from "../common/Button";
+import { Separator } from "../common/Separator";
+
+type CustomTextIncomingData =
+ | ({ set?: boolean; long?: boolean } & (
+ | { text: string; splitText?: never }
+ | { text?: never; splitText: string[] }
+ ))
+ | null;
+
+function getSavedText(name: string, long: boolean): string {
+ let text = CustomText.getCustomText(name, long);
+ if (long) {
+ text = text.slice(CustomText.getCustomTextLongProgress(name));
+ }
+ return text.join(" ");
+}
+
+export function SavedTextsModal(props: {
+ setChainedData: Setter;
+}): JSXElement {
+ const [names, setNames] = createSignal([]);
+ const [longNames, setLongNames] = createSignal([]);
+
+ // because the progress is stored in local storage,
+ // we need to trigger a refresh when it changes to update the reset button state
+ const [version, setVersion] = createSignal(0);
+
+ const refresh = () => {
+ setNames(CustomText.getCustomTextNames(false));
+ setLongNames(CustomText.getCustomTextNames(true));
+ setVersion((v) => v + 1);
+ };
+
+ const handleNameClick = (name: string, long: boolean) => {
+ CustomTextState.setCustomTextName(name, long);
+ const text = getSavedText(name, long);
+ props.setChainedData({ text, long });
+ hideModal("SavedTexts");
+ };
+
+ const handleDelete = (name: string, long: boolean) => {
+ showSimpleModal({
+ title: "Delete custom text",
+ text: `Are you sure you want to delete custom text ${name}?`,
+ buttonText: "delete",
+ execFn: async () => {
+ CustomText.deleteCustomText(name, long);
+ CustomTextState.setCustomTextName("", undefined);
+ refresh();
+ return {
+ status: "success",
+ message: "Custom text deleted",
+ };
+ },
+ });
+ };
+
+ const handleResetProgress = (name: string) => {
+ showSimpleModal({
+ title: "Reset progress for custom text",
+ text: `Are you sure you want to reset your progress for custom text ${name}?`,
+ buttonText: "reset",
+ execFn: async () => {
+ CustomText.setCustomTextLongProgress(name, 0);
+ const text = CustomText.getCustomText(name, true);
+ CustomText.setText(text);
+ refresh();
+ return {
+ status: "success",
+ message: "Custom text progress reset",
+ };
+ },
+ });
+ };
+
+ return (
+
+
+
0}
+ fallback={No saved custom texts found
}
+ >
+
+ {(name) => (
+
+ handleNameClick(name, false)}
+ />
+ handleDelete(name, false)}
+ />
+
+ )}
+
+
+
+
+ Saved long texts
+
+
+
0}
+ fallback={
+ No saved long custom texts found
+ }
+ >
+
+ {(name) => {
+ const hasProgress = () => {
+ version();
+ return CustomText.getCustomTextLongProgress(name()) > 0;
+ };
+ return (
+
+ handleNameClick(name(), true)}
+ />
+ handleResetProgress(name())}
+ />
+ handleDelete(name(), true)}
+ />
+
+ );
+ }}
+
+
+
+
+
+
+
+ Heads up! These texts are only stored locally. If you switch devices or
+ clear your local browser data they will be lost.
+
+
+ );
+}
diff --git a/frontend/src/ts/components/modals/WordFilterModal.tsx b/frontend/src/ts/components/modals/WordFilterModal.tsx
new file mode 100644
index 000000000000..2cc78942c7f2
--- /dev/null
+++ b/frontend/src/ts/components/modals/WordFilterModal.tsx
@@ -0,0 +1,365 @@
+import type { Language } from "@monkeytype/schemas/languages";
+import type { LayoutObject } from "@monkeytype/schemas/layouts";
+
+import { tryCatch } from "@monkeytype/util/trycatch";
+import { createForm } from "@tanstack/solid-form";
+import { createSignal, JSXElement, Setter } from "solid-js";
+
+import { LanguageList } from "../../constants/languages";
+import { LayoutsList } from "../../constants/layouts";
+import { hideLoaderBar, showLoaderBar } from "../../states/loader-bar";
+import { hideModal } from "../../states/modals";
+import {
+ showNoticeNotification,
+ showErrorNotification,
+} from "../../states/notifications";
+import * as JSONData from "../../utils/json-data";
+import * as Misc from "../../utils/misc";
+import { AnimatedModal } from "../common/AnimatedModal";
+import { Button } from "../common/Button";
+import { Separator } from "../common/Separator";
+import { Checkbox } from "../ui/form/Checkbox";
+import { InputField } from "../ui/form/InputField";
+import { SubmitButton } from "../ui/form/SubmitButton";
+import SlimSelect from "../ui/SlimSelect";
+
+type CustomTextIncomingData =
+ | ({ set?: boolean; long?: boolean } & (
+ | { text: string; splitText?: never }
+ | { text?: never; splitText: string[] }
+ ))
+ | null;
+
+type FilterPreset = {
+ display: string;
+ getIncludeString: (layout: LayoutObject) => string[][];
+} & (
+ | { exactMatch: true }
+ | {
+ exactMatch?: false;
+ getExcludeString?: (layout: LayoutObject) => string[][];
+ }
+);
+
+const presets: Record = {
+ homeKeys: {
+ display: "home keys",
+ getIncludeString: (layout) => {
+ const homeKeysLeft = layout.keys.row3.slice(0, 4);
+ const homeKeysRight = layout.keys.row3.slice(6, 10);
+ return [...homeKeysLeft, ...homeKeysRight];
+ },
+ exactMatch: true,
+ },
+ leftHand: {
+ display: "left hand",
+ getIncludeString: (layout) => {
+ const topRowInclude = layout.keys.row2.slice(0, 5);
+ const homeRowInclude = layout.keys.row3.slice(0, 5);
+ const bottomRowInclude = layout.keys.row4.slice(0, 5);
+ return [...topRowInclude, ...homeRowInclude, ...bottomRowInclude];
+ },
+ exactMatch: true,
+ },
+ rightHand: {
+ display: "right hand",
+ getIncludeString: (layout) => {
+ const topRowInclude = layout.keys.row2.slice(5);
+ const homeRowInclude = layout.keys.row3.slice(5);
+ const bottomRowInclude = layout.keys.row4.slice(4);
+ return [...topRowInclude, ...homeRowInclude, ...bottomRowInclude];
+ },
+ exactMatch: true,
+ },
+ homeRow: {
+ display: "home row",
+ getIncludeString: (layout) => layout.keys.row3,
+ exactMatch: true,
+ },
+ topRow: {
+ display: "top row",
+ getIncludeString: (layout) => layout.keys.row2,
+ exactMatch: true,
+ },
+ bottomRow: {
+ display: "bottom row",
+ getIncludeString: (layout) => layout.keys.row4,
+ exactMatch: true,
+ },
+};
+
+const languageOptions = LanguageList.map((lang) => ({
+ value: lang,
+ text: lang.replace(/_/gi, " "),
+}));
+
+const layoutOptions = LayoutsList.map((layout) => ({
+ value: layout,
+ text: layout.replace(/_/gi, " "),
+}));
+
+const presetOptions = Object.entries(presets).map(([id, preset]) => ({
+ value: id,
+ text: preset.display,
+}));
+
+export function WordFilterModal(props: {
+ setChainedData: Setter;
+}): JSXElement {
+ const [language, setLanguage] = createSignal(languageOptions[0]?.value ?? "");
+ const [layout, setLayout] = createSignal(layoutOptions[0]?.value ?? "");
+ const [preset, setPreset] = createSignal(presetOptions[0]?.value ?? "");
+ const [loading, setLoading] = createSignal(false);
+
+ let submitAction: "set" | "add" = "set";
+
+ const form = createForm(() => ({
+ defaultValues: {
+ include: "",
+ exclude: "",
+ minLength: "",
+ maxLength: "",
+ exactMatch: false,
+ },
+ onSubmit: async ({ value }) => {
+ setLoading(true);
+ showLoaderBar();
+ try {
+ const exactMatchOnly = value.exactMatch;
+ let filterin = Misc.escapeRegExp(value.include.trim());
+ filterin = filterin.replace(/\s+/gi, "|");
+
+ if (exactMatchOnly && filterin === "") {
+ showNoticeNotification("Include field is required for exact match");
+ return;
+ }
+
+ const regincl = exactMatchOnly
+ ? new RegExp("^[" + filterin + "]+$", "i")
+ : new RegExp(filterin, "i");
+
+ let filterout = Misc.escapeRegExp(value.exclude.trim());
+ filterout = filterout.replace(/\s+/gi, "|");
+ const regexcl = new RegExp(filterout, "i");
+
+ const { data: languageWordList, error } = await tryCatch(
+ JSONData.getLanguage(language() as Language),
+ );
+
+ if (error) {
+ showErrorNotification("Failed to filter language words", { error });
+ return;
+ }
+
+ const max = value.maxLength === "" ? 999 : parseInt(value.maxLength);
+ const min = value.minLength === "" ? 1 : parseInt(value.minLength);
+
+ const filteredWords: string[] = [];
+ for (const word of languageWordList.words) {
+ const test1 = regincl.test(word);
+ const test2 = exactMatchOnly ? false : regexcl.test(word);
+ if (
+ ((test1 && !test2) || (test1 && filterout === "")) &&
+ word.length <= max &&
+ word.length >= min
+ ) {
+ filteredWords.push(word);
+ }
+ }
+
+ if (filteredWords.length === 0) {
+ showNoticeNotification("No words found");
+ return;
+ }
+ props.setChainedData({
+ splitText: filteredWords,
+ set: submitAction === "set",
+ });
+ hideModal("WordFilter");
+ } finally {
+ hideLoaderBar();
+ setLoading(false);
+ }
+ },
+ }));
+
+ const isExactMatch = form.useStore((s) => s.values.exactMatch);
+
+ const applyPreset = async () => {
+ const presetToApply = presets[preset()];
+ if (presetToApply === undefined) {
+ showErrorNotification(`Preset ${preset()} not found`);
+ return;
+ }
+
+ const layoutData = await JSONData.getLayout(layout());
+ form.setFieldValue(
+ "include",
+ presetToApply
+ .getIncludeString(layoutData)
+ .map((x) => x[0])
+ .join(" "),
+ );
+
+ if (presetToApply.exactMatch === true) {
+ form.setFieldValue("exactMatch", true);
+ form.setFieldValue("exclude", "");
+ } else {
+ form.setFieldValue("exactMatch", false);
+ if (presetToApply.getExcludeString !== undefined) {
+ form.setFieldValue(
+ "exclude",
+ presetToApply
+ .getExcludeString(layoutData)
+ .map((x) => x[0])
+ .join(" "),
+ );
+ }
+ }
+ };
+
+ return (
+
+
+
+ );
+}
diff --git a/frontend/src/ts/components/ui/SlimSelect.tsx b/frontend/src/ts/components/ui/SlimSelect.tsx
index dfd7619a023d..ec1233a5357e 100644
--- a/frontend/src/ts/components/ui/SlimSelect.tsx
+++ b/frontend/src/ts/components/ui/SlimSelect.tsx
@@ -32,6 +32,7 @@ export type SlimSelectProps = {
cssClasses?: Config["cssClasses"];
children?: JSX.Element;
ref?: (instance: SlimSelectCore | null) => void;
+ disabled?: boolean;
} & (
| {
multiple?: never;
@@ -47,6 +48,7 @@ export type SlimSelectProps = {
export default function SlimSelect(props: SlimSelectProps): JSXElement {
let selectRef!: HTMLSelectElement;
+ let containerRef!: HTMLDivElement;
let slimSelect: SlimSelectCore | null = null;
// State tracking
@@ -243,7 +245,10 @@ export default function SlimSelect(props: SlimSelectProps): JSXElement {
const config: Config = {
select: selectRef,
data: getDataWithAll(buildData(getOptions(), getSelected())) as Option[],
- ...(props.settings && { settings: props.settings }),
+ settings: {
+ ...props.settings,
+ contentLocation: containerRef,
+ },
...(props.cssClasses && { cssClasses: props.cssClasses }),
events: {
...props.events,
@@ -339,6 +344,10 @@ export default function SlimSelect(props: SlimSelectProps): JSXElement {
lastOptionsReference = props.options;
props.ref?.(slimSelect);
+ if (props.disabled) {
+ slimSelect.disable();
+ }
+
if (props.selected !== undefined) {
syncSelectedToSlimSelect(getSelected(), false);
}
@@ -458,9 +467,28 @@ export default function SlimSelect(props: SlimSelectProps): JSXElement {
}
});
+ // Effect: Handle disabled prop changes
+ createEffect(() => {
+ if (!slimSelect) return;
+ if (props.disabled) {
+ slimSelect.disable();
+ } else {
+ slimSelect.enable();
+ }
+ });
+
return (
-
+ (containerRef = el)}
+ class="relative [&>.ss-content]:top-full! [&>.ss-content]:left-0! [&>.ss-content]:w-full!"
+ >
+
+
);
}
diff --git a/frontend/src/ts/components/ui/form/InputField.tsx b/frontend/src/ts/components/ui/form/InputField.tsx
index c3c9a3f2fb1e..2661d1d83925 100644
--- a/frontend/src/ts/components/ui/form/InputField.tsx
+++ b/frontend/src/ts/components/ui/form/InputField.tsx
@@ -12,6 +12,8 @@ export function InputField(props: {
type?: string;
disabled?: boolean;
class?: string;
+ dir?: "ltr" | "rtl" | "auto";
+ maxLength?: number;
onFocus?: () => void;
}): JSXElement {
return (
@@ -36,6 +38,8 @@ export function InputField(props: {
onInput={(e) => props.field().handleChange(e.target.value)}
disabled={props.disabled}
onFocus={() => props.onFocus?.()}
+ dir={props.dir}
+ maxLength={props.maxLength}
/>
diff --git a/frontend/src/ts/components/ui/form/TextareaField.tsx b/frontend/src/ts/components/ui/form/TextareaField.tsx
new file mode 100644
index 000000000000..16fd420e3e53
--- /dev/null
+++ b/frontend/src/ts/components/ui/form/TextareaField.tsx
@@ -0,0 +1,35 @@
+import { AnyFieldApi } from "@tanstack/solid-form";
+import { Accessor, JSXElement } from "solid-js";
+
+import { cn } from "../../../utils/cn";
+
+export function TextareaField(props: {
+ field: Accessor;
+ ref?: HTMLTextAreaElement | ((el: HTMLTextAreaElement) => void);
+ placeholder?: string;
+ disabled?: boolean;
+ class?: string;
+ onKeyDown?: (e: KeyboardEvent) => void;
+ onKeyPress?: (e: KeyboardEvent) => void;
+}): JSXElement {
+ return (
+
+ );
+}
diff --git a/frontend/src/ts/controllers/challenge-controller.ts b/frontend/src/ts/controllers/challenge-controller.ts
index 2e7c145ea129..84339c1dd4db 100644
--- a/frontend/src/ts/controllers/challenge-controller.ts
+++ b/frontend/src/ts/controllers/challenge-controller.ts
@@ -27,17 +27,18 @@ import { areUnsortedArraysEqual } from "../utils/arrays";
import { tryCatch } from "@monkeytype/util/trycatch";
import { Challenge } from "@monkeytype/schemas/challenges";
import { qs } from "../utils/dom";
+import { getLoadedChallenge, setLoadedChallenge } from "../states/test";
let challengeLoading = false;
export function clearActive(): void {
if (
- TestState.activeChallenge &&
+ getLoadedChallenge() !== null &&
!challengeLoading &&
!TestState.testRestarting
) {
showNoticeNotification("Challenge cleared");
- TestState.setActiveChallenge(null);
+ setLoadedChallenge(null);
}
}
@@ -148,7 +149,9 @@ function verifyRequirement(
}
export function verify(result: CompletedEvent): string | null {
- if (!TestState.activeChallenge) return null;
+ const loadedChallenge = getLoadedChallenge();
+
+ if (loadedChallenge === null) return null;
try {
const afk = (result.afkDuration / result.testDuration) * 100;
@@ -158,20 +161,18 @@ export function verify(result: CompletedEvent): string | null {
return null;
}
- if (TestState.activeChallenge.requirements === undefined) {
- showSuccessNotification(
- `${TestState.activeChallenge.display} challenge passed!`,
- );
- return TestState.activeChallenge.name;
+ if (loadedChallenge.requirements === undefined) {
+ showSuccessNotification(`${loadedChallenge.display} challenge passed!`);
+ return loadedChallenge.name || null;
} else {
let requirementsMet = true;
const failReasons: string[] = [];
for (const requirementType of Misc.typedKeys(
- TestState.activeChallenge.requirements,
+ loadedChallenge.requirements,
)) {
const [passed, requirementFailReasons] = verifyRequirement(
result,
- TestState.activeChallenge.requirements,
+ loadedChallenge.requirements,
requirementType,
);
if (!passed) {
@@ -180,20 +181,18 @@ export function verify(result: CompletedEvent): string | null {
failReasons.push(...requirementFailReasons);
}
if (requirementsMet) {
- if (TestState.activeChallenge.autoRole) {
+ if (loadedChallenge.autoRole) {
showSuccessNotification(
"You will receive a role shortly. Please don't post a screenshot in challenge submissions.",
{ durationMs: 5000 },
);
}
- showSuccessNotification(
- `${TestState.activeChallenge.display} challenge passed!`,
- );
- return TestState.activeChallenge.name;
+ showSuccessNotification(`${loadedChallenge.display} challenge passed!`);
+ return loadedChallenge.name;
} else {
showNoticeNotification(
`${
- TestState.activeChallenge.display
+ loadedChallenge.display
} challenge failed: ${failReasons.join(", ")}`,
);
return null;
@@ -380,7 +379,7 @@ export async function setup(challengeName: string): Promise {
} else {
showNoticeNotification("Challenge loaded. " + notitext);
}
- TestState.setActiveChallenge(challenge);
+ setLoadedChallenge(challenge);
challengeLoading = false;
return true;
} catch (e) {
diff --git a/frontend/src/ts/elements/modes-notice.ts b/frontend/src/ts/elements/modes-notice.ts
index 6f670371044c..01dd9c152656 100644
--- a/frontend/src/ts/elements/modes-notice.ts
+++ b/frontend/src/ts/elements/modes-notice.ts
@@ -12,6 +12,7 @@ import Format from "../singletons/format";
import { getActiveFunboxes, getActiveFunboxNames } from "../test/funbox/list";
import { escapeHTML, getMode2 } from "../utils/misc";
import { qsr } from "../utils/dom";
+import { getLoadedChallenge } from "../states/test";
configEvent.subscribe(({ key }) => {
const configKeys: ConfigEventKey[] = [
@@ -91,9 +92,10 @@ export async function update(): Promise {
);
}
- if (TestState.activeChallenge) {
+ const loadedChallenge = getLoadedChallenge();
+ if (loadedChallenge !== null) {
testModesNotice.appendHtml(
- `${TestState.activeChallenge.display}
`,
+ `${loadedChallenge.display}
`,
);
}
diff --git a/frontend/src/ts/event-handlers/test.ts b/frontend/src/ts/event-handlers/test.ts
index 1fc30c519a2b..108db58ee09c 100644
--- a/frontend/src/ts/event-handlers/test.ts
+++ b/frontend/src/ts/event-handlers/test.ts
@@ -10,10 +10,8 @@ import {
showNoticeNotification,
showErrorNotification,
} from "../states/notifications";
-import * as QuoteRateModal from "../modals/quote-rate";
-import * as QuoteReportModal from "../modals/quote-report";
-import * as QuoteSearchModal from "../modals/quote-search";
-import * as CustomTextModal from "../modals/custom-text";
+import { showQuoteRateModal } from "../states/quote-rate";
+import { showQuoteReportModal } from "../states/quote-report";
import * as PractiseWordsModal from "../modals/practise-words";
import { navigate } from "../controllers/route-controller";
import { getMode2 } from "../utils/misc";
@@ -21,6 +19,7 @@ import * as ShareTestSettingsPopup from "../modals/share-test-settings";
import { ConfigKey } from "@monkeytype/schemas/configs";
import { ListsObjectKeys } from "../commandline/lists";
import { qs } from "../utils/dom";
+import { showModal } from "../states/modals";
const testPage = qs(".pageTest");
@@ -81,7 +80,7 @@ qs(".pageTest #rateQuoteButton")?.on("click", async () => {
showErrorNotification("Failed to show quote rating popup: no quote");
return;
}
- QuoteRateModal.show(TestWords.currentQuote);
+ showQuoteRateModal(TestWords.currentQuote);
});
qs(".pageTest #reportQuoteButton")?.on("click", async () => {
@@ -89,19 +88,19 @@ qs(".pageTest #reportQuoteButton")?.on("click", async () => {
showErrorNotification("Failed to show quote report popup: no quote");
return;
}
- void QuoteReportModal.show(TestWords.currentQuote?.id);
+ showQuoteReportModal(TestWords.currentQuote?.id);
});
testPage?.onChild("click", "#testConfig .quoteLength .textButton", (event) => {
const target = event.childTarget as HTMLElement;
const len = parseInt(target?.getAttribute("quoteLength") ?? "0");
if (len === -2) {
- void QuoteSearchModal.show();
+ showModal("QuoteSearch");
}
});
testPage?.onChild("click", "#testConfig .customText .textButton", () => {
- CustomTextModal.show();
+ showModal("CustomText");
});
testPage?.onChild("click", "#practiseWordsButton", () => {
diff --git a/frontend/src/ts/modals/custom-generator.ts b/frontend/src/ts/modals/custom-generator.ts
deleted file mode 100644
index 1e061a597da6..000000000000
--- a/frontend/src/ts/modals/custom-generator.ts
+++ /dev/null
@@ -1,199 +0,0 @@
-import * as CustomText from "../test/custom-text";
-import { showNoticeNotification } from "../states/notifications";
-import SlimSelect from "slim-select";
-import AnimatedModal, {
- HideOptions,
- ShowOptions,
-} from "../utils/animated-modal";
-import { ElementWithUtils } from "../utils/dom";
-
-type Preset = {
- display: string;
- characters: string[];
-};
-
-const presets: Record = {
- alphas: {
- display: "a-z",
- characters: "abcdefghijklmnopqrstuvwxyz".split(""),
- },
- numbers: {
- display: "0-9",
- characters: "0123456789".split(""),
- },
- special: {
- display: "symbols",
- characters: "!@#$%^&*()_+-=[]{}|;:',.<>?/`~".split(""),
- },
- bigrams: {
- display: "bigrams",
- characters: [
- "th",
- "he",
- "in",
- "er",
- "an",
- "re",
- "on",
- "at",
- "en",
- "nd",
- "ed",
- "es",
- "or",
- "te",
- "st",
- "ar",
- "ou",
- "it",
- "al",
- "as",
- ],
- },
- trigrams: {
- display: "trigrams",
- characters: [
- "the",
- "and",
- "ing",
- "ion",
- "tio",
- "ent",
- "ati",
- "for",
- "her",
- "ter",
- "ate",
- "ver",
- "all",
- "con",
- "res",
- "are",
- "rea",
- "int",
- ],
- },
-};
-
-let _presetSelect: SlimSelect | undefined = undefined;
-
-export async function show(showOptions?: ShowOptions): Promise {
- void modal.show({
- ...showOptions,
- beforeAnimation: async (modalEl) => {
- _presetSelect = new SlimSelect({
- select: "#customGeneratorModal .presetInput",
- settings: {
- contentLocation: modalEl.native,
- },
- });
- },
- });
-}
-
-function applyPreset(): void {
- const modalEl = modal.getModal();
- const presetName = modalEl
- .qs("select.presetInput")
- ?.getValue();
-
- if (presetName !== undefined && presetName !== "" && presets[presetName]) {
- const preset = presets[presetName];
- modalEl
- .qsr(".characterInput")
- .setValue(preset.characters.join(" "));
- }
-}
-
-function hide(hideOptions?: HideOptions): void {
- void modal.hide({
- ...hideOptions,
- });
-}
-
-function generateWords(): string[] {
- const modalEl = modal.getModal();
- const characterInput = modalEl
- .qs(".characterInput")
- ?.getValue();
-
- const minLength =
- parseInt(
- modalEl.qs(".minLengthInput")?.getValue() as string,
- ) || 2;
- const maxLength =
- parseInt(
- modalEl.qs(".maxLengthInput")?.getValue() as string,
- ) || 5;
- const wordCount =
- parseInt(
- modalEl.qs(".wordCountInput")?.getValue() as string,
- ) || 100;
-
- if (characterInput === undefined || characterInput.trim() === "") {
- showNoticeNotification("Character set cannot be empty");
- return [];
- }
-
- const characters = characterInput.trim().split(/\s+/);
- const generatedWords: string[] = [];
-
- for (let i = 0; i < wordCount; i++) {
- const wordLength =
- Math.floor(Math.random() * (maxLength - minLength + 1)) + minLength;
- let word = "";
-
- for (let j = 0; j < wordLength; j++) {
- const randomChar =
- characters[Math.floor(Math.random() * characters.length)];
- word += randomChar;
- }
-
- generatedWords.push(word);
- }
-
- return generatedWords;
-}
-
-async function apply(set: boolean): Promise {
- const generatedWords = generateWords();
-
- if (generatedWords.length === 0) {
- return;
- }
-
- const customText = generatedWords.join(
- CustomText.getPipeDelimiter() ? "|" : " ",
- );
-
- hide({
- modalChainData: {
- text: customText,
- set,
- },
- });
-}
-
-async function setup(modalEl: ElementWithUtils): Promise {
- modalEl.qs(".setButton")?.on("click", () => {
- void apply(true);
- });
-
- modalEl.qs(".addButton")?.on("click", () => {
- void apply(false);
- });
-
- modalEl.qs(".generateButton")?.on("click", () => {
- applyPreset();
- });
-}
-
-type OutgoingData = {
- text: string;
- set: boolean;
-};
-
-const modal = new AnimatedModal({
- dialogId: "customGeneratorModal",
- setup,
-});
diff --git a/frontend/src/ts/modals/custom-text.ts b/frontend/src/ts/modals/custom-text.ts
deleted file mode 100644
index 2dd6146234bb..000000000000
--- a/frontend/src/ts/modals/custom-text.ts
+++ /dev/null
@@ -1,600 +0,0 @@
-import * as CustomText from "../test/custom-text";
-import * as CustomTextState from "../legacy-states/custom-text-name";
-import * as TestLogic from "../test/test-logic";
-import * as ChallengeController from "../controllers/challenge-controller";
-
-import { Config } from "../config/store";
-import { setConfig } from "../config/setters";
-import * as Strings from "../utils/strings";
-import * as WordFilterPopup from "./word-filter";
-import * as CustomGeneratorPopup from "./custom-generator";
-import * as PractiseWords from "../test/practise-words";
-import {
- showNoticeNotification,
- showErrorNotification,
-} from "../states/notifications";
-import * as SavedTextsPopup from "./saved-texts";
-import * as SaveCustomTextPopup from "./save-custom-text";
-import AnimatedModal, { ShowOptions } from "../utils/animated-modal";
-import { CustomTextMode } from "@monkeytype/schemas/util";
-import { qs, ElementWithUtils } from "../utils/dom";
-
-type State = {
- textarea: string;
- longCustomTextWarning: boolean;
- challengeWarning: boolean;
-
- customTextMode: "simple" | CustomTextMode;
- customTextLimits: {
- word: string;
- time: string;
- section: string;
- };
- removeFancyTypographyEnabled: boolean;
- replaceControlCharactersEnabled: boolean;
- customTextPipeDelimiter: boolean;
- replaceNewlines: "off" | "space" | "periodSpace";
- removeZeroWidthCharactersEnabled: boolean;
-};
-
-const state: State = {
- textarea: CustomText.getText().join(
- CustomText.getPipeDelimiter() ? "|" : " ",
- ),
- longCustomTextWarning: false,
- challengeWarning: false,
- customTextMode: "simple",
- customTextLimits: {
- word: "",
- time: "",
- section: "",
- },
- removeFancyTypographyEnabled: true,
- replaceControlCharactersEnabled: true,
- customTextPipeDelimiter: false,
- replaceNewlines: "off",
- removeZeroWidthCharactersEnabled: true,
-};
-
-function updateUI(): void {
- const modalEl = modal.getModal();
- modalEl.qsa(`.inputs .group[data-id="mode"] button`)?.removeClass("active");
- modalEl
- .qs(
- `.inputs .group[data-id="mode"] button[value="${state.customTextMode}"]`,
- )
- ?.addClass("active");
-
- modalEl.qs(`.inputs .group[data-id="limit"] input.words`)?.hide();
- modalEl.qs(`.inputs .group[data-id="limit"] input.sections`)?.hide();
-
- modalEl
- .qs(`.inputs .group[data-id="limit"] input.words`)
- ?.setValue(state.customTextLimits.word);
- modalEl
- .qs(`.inputs .group[data-id="limit"] input.time`)
- ?.setValue(state.customTextLimits.time);
- modalEl
- .qs(`.inputs .group[data-id="limit"] input.sections`)
- ?.setValue(state.customTextLimits.section);
- if (state.customTextLimits.word !== "") {
- modalEl.qs(`.inputs .group[data-id="limit"] input.words`)?.show();
- }
- if (state.customTextLimits.section !== "") {
- modalEl.qs(`.inputs .group[data-id="limit"] input.sections`)?.show();
- }
-
- if (state.customTextPipeDelimiter) {
- modalEl.qs(`.inputs .group[data-id="limit"] input.sections`)?.show();
- modalEl.qs(`.inputs .group[data-id="limit"] input.words`)?.hide();
- } else {
- modalEl.qs(`.inputs .group[data-id="limit"] input.words`)?.show();
- modalEl.qs(`.inputs .group[data-id="limit"] input.sections`)?.hide();
- }
-
- if (state.customTextMode === "simple") {
- modalEl.qs(`.inputs .group[data-id="limit"]`)?.addClass("disabled");
- modalEl
- .qsa(`.inputs .group[data-id="limit"] input`)
- ?.setValue("");
- modalEl.qsa(`.inputs .group[data-id="limit"] input`)?.disable();
- } else {
- modalEl.qs(`.inputs .group[data-id="limit"]`)?.removeClass("disabled");
- modalEl.qsa(`.inputs .group[data-id="limit"] input`)?.enable();
- }
-
- modalEl.qsa(`.inputs .group[data-id="fancy"] button`)?.removeClass("active");
- modalEl
- .qs(
- `.inputs .group[data-id="fancy"] button[value="${state.removeFancyTypographyEnabled}"]`,
- )
- ?.addClass("active");
-
- modalEl
- .qsa(`.inputs .group[data-id="control"] button`)
- ?.removeClass("active");
- modalEl
- .qs(
- `.inputs .group[data-id="control"] button[value="${state.replaceControlCharactersEnabled}"]`,
- )
- ?.addClass("active");
-
- modalEl
- .qsa(`.inputs .group[data-id="zeroWidth"] button`)
- ?.removeClass("active");
- modalEl
- .qs(
- `.inputs .group[data-id="zeroWidth"] button[value="${state.removeZeroWidthCharactersEnabled}"]`,
- )
- ?.addClass("active");
-
- modalEl
- .qsa(`.inputs .group[data-id="delimiter"] button`)
- ?.removeClass("active");
- modalEl
- .qs(
- `.inputs .group[data-id="delimiter"] button[value="${state.customTextPipeDelimiter}"]`,
- )
- ?.addClass("active");
-
- modalEl
- .qsa(`.inputs .group[data-id="newlines"] button`)
- ?.removeClass("active");
- modalEl
- .qs(
- `.inputs .group[data-id="newlines"] button[value="${state.replaceNewlines}"]`,
- )
- ?.addClass("active");
-
- modalEl.qs(`textarea`)?.setValue(state.textarea);
-
- if (state.longCustomTextWarning) {
- modalEl.qs(`.longCustomTextWarning`)?.show();
- modalEl
- .qs(`.randomWordsCheckbox input`)
- ?.setChecked(false);
- modalEl.qs(`.delimiterCheck input`)?.setChecked(false);
- modalEl.qs(`.typographyCheck`)?.setChecked(true);
- modalEl
- .qs(`.replaceNewlineWithSpace input`)
- ?.setChecked(false);
- modalEl.qs(`.inputs`)?.addClass("disabled");
- } else {
- modalEl.qs(`.longCustomTextWarning`)?.hide();
- modalEl.qs(`.inputs`)?.removeClass("disabled");
- }
-
- if (state.challengeWarning) {
- modalEl.qs(`.challengeWarning`)?.show();
- modalEl
- .qs(`.randomWordsCheckbox input`)
- ?.setChecked(false);
- modalEl.qs(`.delimiterCheck input`)?.setChecked(false);
- modalEl.qs(`.typographyCheck`)?.setChecked(true);
- modalEl
- .qs(`.replaceNewlineWithSpace input`)
- ?.setChecked(false);
- modalEl.qs(`.inputs`)?.addClass("disabled");
- } else {
- modalEl.qs(`.challengeWarning`)?.hide();
- modalEl.qs(`.inputs`)?.removeClass("disabled");
- }
-}
-
-async function beforeAnimation(
- modalEl: ElementWithUtils,
- modalChainData?: IncomingData,
-): Promise {
- state.customTextMode = CustomText.getMode();
-
- if (
- state.customTextMode === "repeat" &&
- CustomText.getLimitMode() !== "time" &&
- CustomText.getLimitValue() === CustomText.getText().length
- ) {
- state.customTextMode = "simple";
- }
-
- state.customTextLimits.word = "";
- state.customTextLimits.time = "";
- state.customTextLimits.section = "";
- if (CustomText.getLimitMode() === "word") {
- state.customTextLimits.word = `${CustomText.getLimitValue()}`;
- } else if (CustomText.getLimitMode() === "time") {
- state.customTextLimits.time = `${CustomText.getLimitValue()}`;
- } else if (CustomText.getLimitMode() === "section") {
- state.customTextLimits.section = `${CustomText.getLimitValue()}`;
- }
- state.customTextPipeDelimiter = CustomText.getPipeDelimiter();
-
- state.longCustomTextWarning = CustomTextState.isCustomTextLong() ?? false;
-
- if (modalChainData?.text !== undefined) {
- if (modalChainData.long !== true && CustomTextState.isCustomTextLong()) {
- CustomTextState.setCustomTextName("", undefined);
- showNoticeNotification("Disabled long custom text progress tracking", {
- durationMs: 5000,
- });
- state.longCustomTextWarning = false;
- }
-
- const newText =
- (modalChainData.set ?? true)
- ? modalChainData.text
- : state.textarea + " " + modalChainData.text;
- state.textarea = newText;
- state.customTextMode = "simple";
- state.customTextLimits.word = `${cleanUpText().length}`;
- state.customTextLimits.time = "";
- state.customTextLimits.section = "";
- }
-
- updateUI();
-}
-
-async function afterAnimation(): Promise {
- if (!state.challengeWarning && !state.longCustomTextWarning) {
- modal.getModal().qs(`textarea`)?.focus();
- }
-}
-
-export function show(showOptions?: ShowOptions): void {
- state.textarea = CustomText.getText()
- .join(CustomText.getPipeDelimiter() ? "|" : " ")
- .replace(/^ +/gm, "");
- void modal.show({
- ...(showOptions as ShowOptions),
- beforeAnimation,
- afterAnimation,
- });
-}
-
-function hide(): void {
- void modal.hide();
-}
-
-function handleFileOpen(): void {
- const file = qs("#fileInput")?.native.files?.[0];
- if (file) {
- if (file.type !== "text/plain") {
- showErrorNotification("File is not a text file", { durationMs: 5000 });
- return;
- }
-
- const reader = new FileReader();
- reader.readAsText(file, "UTF-8");
-
- reader.onload = (readerEvent): void => {
- const content = readerEvent.target?.result as string;
- state.textarea = content;
- updateUI();
- qs(`#fileInput`)?.setValue("");
- };
- reader.onerror = (): void => {
- showErrorNotification("Failed to read file", { durationMs: 5000 });
- };
- }
-}
-
-function cleanUpText(): string[] {
- let text = state.textarea;
-
- if (text === "") return [];
-
- text = text.normalize();
- // text = text.replace(/[\r]/gm, " ");
-
- //replace any characters that look like a space with an actual space
- text = text.replace(/[\u2000-\u200A\u202F\u205F\u00A0]/g, " ");
-
- if (state.removeZeroWidthCharactersEnabled) {
- //replace zero width characters
- text = text.replace(/[\u200B-\u200D\u2060\uFEFF]/g, "");
- }
-
- if (state.replaceControlCharactersEnabled) {
- text = Strings.replaceControlCharacters(text);
- }
-
- text = text.replace(/ +/gm, " ");
- text = text.replace(/( *(\r\n|\r|\n) *)/g, "\n ");
- if (state.removeFancyTypographyEnabled) {
- text = Strings.cleanTypographySymbols(text);
- }
-
- if (state.replaceNewlines !== "off") {
- const periods = state.replaceNewlines === "periodSpace";
- if (periods) {
- text = text.replace(/\n/gm, ". ");
- text = text.replace(/\.\. /gm, ". ");
- text = text.replace(/ +/gm, " ");
- } else {
- text = text.replace(/\n/gm, " ");
- text = text.replace(/ +/gm, " ");
- }
- }
-
- const words = text
- .split(state.customTextPipeDelimiter ? "|" : " ")
- .filter((word) => word !== "");
- return words;
-}
-
-function apply(): void {
- if (state.textarea === "") {
- showNoticeNotification("Text cannot be empty");
- return;
- }
-
- if (
- [
- state.customTextLimits.word,
- state.customTextLimits.time,
- state.customTextLimits.section,
- ].filter((limit) => limit !== "").length > 1
- ) {
- showNoticeNotification("You can only specify one limit", {
- durationMs: 5000,
- });
- return;
- }
-
- if (
- state.customTextMode !== "simple" &&
- state.customTextLimits.word === "" &&
- state.customTextLimits.time === "" &&
- state.customTextLimits.section === ""
- ) {
- showNoticeNotification("You need to specify a limit", {
- durationMs: 5000,
- });
- return;
- }
-
- if (
- state.customTextLimits.section === "0" ||
- state.customTextLimits.word === "0" ||
- state.customTextLimits.time === "0"
- ) {
- showNoticeNotification(
- "Infinite test! Make sure to use Bail Out from the command line to save your result.",
- {
- durationMs: 7000,
- },
- );
- }
-
- const text = cleanUpText();
-
- if (text.length === 0) {
- showNoticeNotification("Text cannot be empty");
- return;
- }
-
- if (state.customTextMode === "simple") {
- CustomText.setMode("repeat");
- state.customTextLimits.word = `${text.length}`;
- state.customTextLimits.time = "";
- state.customTextLimits.section = "";
- } else {
- CustomText.setMode(state.customTextMode);
- }
-
- CustomText.setPipeDelimiter(state.customTextPipeDelimiter);
- CustomText.setText(text);
-
- if (state.customTextMode === "simple" && state.customTextPipeDelimiter) {
- CustomText.setLimitMode("section");
- CustomText.setLimitValue(text.length);
- } else if (state.customTextLimits.word !== "") {
- CustomText.setLimitMode("word");
- CustomText.setLimitValue(parseInt(state.customTextLimits.word));
- } else if (state.customTextLimits.time !== "") {
- CustomText.setLimitMode("time");
- CustomText.setLimitValue(parseInt(state.customTextLimits.time));
- } else if (state.customTextLimits.section !== "") {
- CustomText.setLimitMode("section");
- CustomText.setLimitValue(parseInt(state.customTextLimits.section));
- }
-
- ChallengeController.clearActive();
- if (Config.mode !== "custom") {
- setConfig("mode", "custom");
- }
- PractiseWords.resetBefore();
- TestLogic.restart();
- hide();
-}
-
-function handleDelimiterChange(): void {
- let newtext = state.textarea
- .split(state.customTextPipeDelimiter ? " " : "|")
- .join(state.customTextPipeDelimiter ? "|" : " ");
- newtext = newtext.replace(/\n /g, "\n");
- state.textarea = newtext;
-}
-
-async function setup(modalEl: ElementWithUtils): Promise {
- modalEl.qs("#fileInput")?.on("change", handleFileOpen);
-
- modalEl.qsa(".group[data-id='mode'] button").on("click", (e) => {
- state.customTextMode = (e.currentTarget as HTMLButtonElement).value as
- | "simple"
- | "repeat"
- | "random";
- if (state.customTextMode === "simple") {
- const text = cleanUpText();
- state.customTextLimits.word = `${text.length}`;
- state.customTextLimits.time = "";
- state.customTextLimits.section = "";
- }
- updateUI();
- });
-
- modalEl.qsa(".group[data-id='fancy'] button").on("click", (e: MouseEvent) => {
- state.removeFancyTypographyEnabled =
- (e.currentTarget as HTMLButtonElement).value === "true";
- updateUI();
- });
-
- modalEl
- .qsa(".group[data-id='control'] button")
- .on("click", (e: MouseEvent) => {
- state.replaceControlCharactersEnabled =
- (e.currentTarget as HTMLButtonElement).value === "true";
- updateUI();
- });
-
- modalEl
- .qsa(".group[data-id='zeroWidth'] button")
- .on("click", (e: MouseEvent) => {
- state.removeZeroWidthCharactersEnabled =
- (e.currentTarget as HTMLButtonElement).value === "true";
- updateUI();
- });
-
- modalEl
- .qsa(".group[data-id='delimiter'] button")
- .on("click", (e: MouseEvent) => {
- state.customTextPipeDelimiter =
- (e.currentTarget as HTMLButtonElement).value === "true";
- if (state.customTextPipeDelimiter && state.customTextLimits.word !== "") {
- state.customTextLimits.word = "";
- }
- if (
- !state.customTextPipeDelimiter &&
- state.customTextLimits.section !== ""
- ) {
- state.customTextLimits.section = "";
- }
- handleDelimiterChange();
- updateUI();
- });
-
- modalEl
- .qsa(".group[data-id='newlines'] button")
- .on("click", (e: MouseEvent) => {
- state.replaceNewlines = (e.currentTarget as HTMLButtonElement).value as
- | "off"
- | "space"
- | "periodSpace";
- updateUI();
- });
-
- modalEl.qs(".group[data-id='limit'] input.words")?.on("input", (e) => {
- state.customTextLimits.word = (e.currentTarget as HTMLInputElement).value;
- state.customTextLimits.time = "";
- state.customTextLimits.section = "";
- updateUI();
- });
-
- modalEl.qs(".group[data-id='limit'] input.time")?.on("input", (e) => {
- state.customTextLimits.time = (e.currentTarget as HTMLInputElement).value;
- state.customTextLimits.word = "";
- state.customTextLimits.section = "";
- updateUI();
- });
-
- modalEl.qs(".group[data-id='limit'] input.sections")?.on("input", (e) => {
- state.customTextLimits.section = (
- e.currentTarget as HTMLInputElement
- ).value;
- state.customTextLimits.word = "";
- state.customTextLimits.time = "";
- updateUI();
- });
-
- const textarea = modalEl.qs("textarea");
- textarea?.on("input", (e) => {
- state.textarea = (e.currentTarget as HTMLTextAreaElement).value;
- });
- textarea?.on("keydown", (e) => {
- if (e.key !== "Tab") return;
- e.preventDefault();
-
- const area = e.currentTarget as HTMLTextAreaElement;
- const start: number = area.selectionStart;
- const end: number = area.selectionEnd;
-
- // set textarea value to: text before caret + tab + text after caret
- area.value =
- area.value.substring(0, start) + "\t" + area.value.substring(end);
-
- // put caret at right position again
- area.selectionStart = area.selectionEnd = start + 1;
-
- state.textarea = area.value;
- });
- textarea?.on("keypress", (e) => {
- if (state.longCustomTextWarning || state.challengeWarning) {
- e.preventDefault();
- return;
- }
- if (e.code === "Enter" && e.ctrlKey) {
- modal.getModal().qs(`.button.apply`)?.dispatch("click");
- }
- if (
- CustomTextState.isCustomTextLong() &&
- CustomTextState.getCustomTextName() !== ""
- ) {
- CustomTextState.setCustomTextName("", undefined);
- state.longCustomTextWarning = false;
- showNoticeNotification("Disabled long custom text progress tracking", {
- durationMs: 5000,
- });
- }
- });
- modalEl.qs(".button.apply")?.on("click", () => {
- apply();
- });
- modalEl.qs(".button.wordfilter")?.on("click", () => {
- void WordFilterPopup.show({
- modalChain: modal as AnimatedModal,
- });
- });
- modalEl.qs(".button.customGenerator")?.on("click", () => {
- void CustomGeneratorPopup.show({
- modalChain: modal as AnimatedModal,
- });
- });
- modalEl.qs(".button.showSavedTexts")?.on("click", () => {
- void SavedTextsPopup.show({
- modalChain: modal as AnimatedModal,
- });
- });
- modalEl.qs(".button.saveCustomText")?.on("click", () => {
- void SaveCustomTextPopup.show({
- modalChain: modal as AnimatedModal,
- modalChainData: { text: cleanUpText() },
- });
- });
- modalEl.qs(".longCustomTextWarning")?.on("click", () => {
- state.longCustomTextWarning = false;
- updateUI();
- });
- modalEl.qs(".challengeWarning")?.on("click", () => {
- state.challengeWarning = false;
- updateUI();
- });
-}
-
-type IncomingData = {
- text: string;
- set?: boolean;
- long?: boolean;
-};
-
-const modal = new AnimatedModal({
- dialogId: "customTextModal",
- setup,
- customEscapeHandler: async (): Promise => {
- hide();
- },
- customWrapperClickHandler: async (): Promise => {
- hide();
- },
- showOptionsWhenInChain: {
- beforeAnimation,
- afterAnimation,
- },
-});
diff --git a/frontend/src/ts/modals/mobile-test-config.ts b/frontend/src/ts/modals/mobile-test-config.ts
index 7dd85ccc5d79..e88a8cfe7d78 100644
--- a/frontend/src/ts/modals/mobile-test-config.ts
+++ b/frontend/src/ts/modals/mobile-test-config.ts
@@ -3,14 +3,13 @@ import { Config } from "../config/store";
import { setConfig, setQuoteLengthAll } from "../config/setters";
import * as CustomWordAmountPopup from "./custom-word-amount";
import * as CustomTestDurationPopup from "./custom-test-duration";
-import * as QuoteSearchModal from "./quote-search";
-import * as CustomTextPopup from "./custom-text";
import AnimatedModal from "../utils/animated-modal";
import { QuoteLength, QuoteLengthConfig } from "@monkeytype/schemas/configs";
import { Mode } from "@monkeytype/schemas/shared";
import { areUnsortedArraysEqual } from "../utils/arrays";
import * as ShareTestSettingsPopup from "./share-test-settings";
import { ElementWithUtils } from "../utils/dom";
+import { showModal } from "../states/modals";
function update(): void {
const el = modal.getModal();
@@ -126,9 +125,7 @@ async function setup(modalEl: ElementWithUtils): Promise {
TestLogic.restart();
}
} else if (lenAttr === "-2") {
- void QuoteSearchModal.show({
- modalChain: modal,
- });
+ showModal("QuoteSearch");
} else {
const len = parseInt(lenAttr, 10) as QuoteLength;
let arr: QuoteLengthConfig = [];
@@ -146,9 +143,7 @@ async function setup(modalEl: ElementWithUtils): Promise {
});
modalEl.qs(".customChange")?.on("click", () => {
- CustomTextPopup.show({
- modalChain: modal,
- });
+ showModal("CustomText");
});
modalEl.qs(".punctuation")?.on("click", () => {
diff --git a/frontend/src/ts/modals/quote-approve.ts b/frontend/src/ts/modals/quote-approve.ts
deleted file mode 100644
index d204c1002f39..000000000000
--- a/frontend/src/ts/modals/quote-approve.ts
+++ /dev/null
@@ -1,239 +0,0 @@
-import Ape from "../ape";
-
-import { showLoaderBar, hideLoaderBar } from "../states/loader-bar";
-import {
- showErrorNotification,
- showSuccessNotification,
-} from "../states/notifications";
-import { format } from "date-fns/format";
-import AnimatedModal, { ShowOptions } from "../utils/animated-modal";
-import { Quote } from "@monkeytype/schemas/quotes";
-import { escapeHTML } from "../utils/misc";
-import { createElementWithUtils, ElementWithUtils } from "../utils/dom";
-
-let quotes: Quote[] = [];
-
-function updateList(): void {
- const modalEl = modal.getModal();
- modalEl.qsr(".quotes").empty();
- quotes.forEach((quote, index) => {
- const quoteEl = createElementWithUtils("div");
- quoteEl.setHtml(`
-
-
-
-
-
-
-
-
-
-
-
${quote.text.length}
-
${
- quote.language
- }
-
${format(
- new Date(quote.timestamp),
- "dd MMM yyyy HH:mm",
- )}
-
-
- `);
-
- modalEl.qsr(".quotes").append(quoteEl);
- quoteEl.qsr(".source").on("input", () => {
- modalEl.qsr(`.quote[data-id="${index}"] .undo`).enable();
- modalEl.qsr(`.quote[data-id="${index}"] .approve`).hide();
- modalEl.qsr(`.quote[data-id="${index}"] .edit`).show();
- });
- quoteEl.qsr(".text").on("input", () => {
- modalEl.qsr(`.quote[data-id="${index}"] .undo`).enable();
- modalEl.qsr(`.quote[data-id="${index}"] .approve`).hide();
- modalEl.qsr(`.quote[data-id="${index}"] .edit`).show();
- updateQuoteLength(index);
- });
- quoteEl.qsr(".undo").on("click", () => {
- undoQuote(index);
- });
- quoteEl.qsr(".approve").on("click", () => {
- void approveQuote(index, quote._id);
- });
- quoteEl.qsr(".refuse").on("click", () => {
- void refuseQuote(index, quote._id);
- });
- quoteEl.qsr(".edit").on("click", () => {
- void editQuote(index, quote._id);
- });
- });
-}
-
-function updateQuoteLength(index: number): void {
- const modalEl = modal.getModal();
- const len = (
- modalEl
- .qsr(`.quote[data-id="${index}"] .text`)
- .getValue() as string
- )?.length;
- modalEl
- .qsr(`.quote[data-id="${index}"] .length`)
- .setHtml(`${len}`);
- if (len < 60) {
- modalEl.qsr(`.quote[data-id="${index}"] .length`).addClass("red");
- } else {
- modalEl.qsr(`.quote[data-id="${index}"] .length`).removeClass("red");
- }
-}
-
-async function getQuotes(): Promise {
- showLoaderBar();
- const response = await Ape.quotes.get();
- hideLoaderBar();
-
- if (response.status !== 200) {
- showErrorNotification("Failed to get new quotes", { response });
- return;
- }
-
- quotes = response.body.data ?? [];
- updateList();
-}
-
-export async function show(showOptions?: ShowOptions): Promise {
- void modal.show({
- ...showOptions,
- beforeAnimation: async () => {
- quotes = [];
- void getQuotes();
- },
- });
-}
-
-// function hide(clearModalChain = false): void {
-// void modal.hide({
-// clearModalChain
-// })
-// }
-
-function resetButtons(index: number): void {
- const quote = modal.getModal().qsr(`.quotes .quote[data-id="${index}"]`);
- quote.qsa("button").enable();
- if (quote.qsr(".edit").hasClass("hidden")) {
- quote.qsr(".undo").disable();
- }
-}
-
-function undoQuote(index: number): void {
- const modalEl = modal.getModal();
- modalEl
- .qsr(`.quote[data-id="${index}"] .text`)
- .setValue(quotes[index]?.text ?? "");
- modalEl
- .qsr(`.quote[data-id="${index}"] .source`)
- .setValue(quotes[index]?.source ?? "");
- modalEl.qsr(`.quote[data-id="${index}"] .undo`).disable();
- modalEl.qsr(`.quote[data-id="${index}"] .approve`).show();
- modalEl.qsr(`.quote[data-id="${index}"] .edit`).hide();
- updateQuoteLength(index);
-}
-
-async function approveQuote(index: number, dbid: string): Promise {
- if (!confirm("Are you sure?")) return;
- const quote = modal.getModal().qsr(`.quotes .quote[data-id="${index}"]`);
- quote.qsa("button").disable();
- quote.qsa("textarea, input").disable();
-
- showLoaderBar();
- const response = await Ape.quotes.approveSubmission({
- body: { quoteId: dbid },
- });
- hideLoaderBar();
-
- if (response.status !== 200) {
- resetButtons(index);
- quote.qsa("textarea, input").enable();
- showErrorNotification("Failed to approve quote", { response });
- return;
- }
-
- showSuccessNotification(`Quote approved. ${response.body.message ?? ""}`);
- quotes.splice(index, 1);
- updateList();
-}
-
-async function refuseQuote(index: number, dbid: string): Promise {
- if (!confirm("Are you sure?")) return;
- const quote = modal.getModal().qsr(`.quotes .quote[data-id="${index}"]`);
- quote.qsa("button").disable();
- quote.qsa("textarea, input").disable();
-
- showLoaderBar();
- const response = await Ape.quotes.rejectSubmission({
- body: { quoteId: dbid },
- });
- hideLoaderBar();
-
- if (response.status !== 200) {
- resetButtons(index);
- quote.qsa("textarea, input").enable();
- showErrorNotification("Failed to refuse quote", { response });
- return;
- }
-
- showSuccessNotification("Quote refused.");
- quotes.splice(index, 1);
- updateList();
-}
-
-async function editQuote(index: number, dbid: string): Promise {
- if (!confirm("Are you sure?")) return;
- const modalEl = modal.getModal();
- const editText = modalEl
- .qsr(`.quote[data-id="${index}"] .text`)
- .getValue() as string;
- const editSource = modalEl
- .qsr(`.quote[data-id="${index}"] .source`)
- .getValue() as string;
- const quote = modalEl.qsr(`.quotes .quote[data-id="${index}"]`);
- quote.qsa("button").disable();
- quote.qsa("textarea, input").disable();
-
- showLoaderBar();
- const response = await Ape.quotes.approveSubmission({
- body: {
- quoteId: dbid,
- editText,
- editSource,
- },
- });
- hideLoaderBar();
-
- if (response.status !== 200) {
- resetButtons(index);
- quote.qsa("textarea, input").enable();
- showErrorNotification("Failed to approve quote", { response });
- return;
- }
-
- showSuccessNotification(
- `Quote edited and approved. ${response.body.message ?? ""}`,
- );
- quotes.splice(index, 1);
- updateList();
-}
-
-async function setup(modalEl: ElementWithUtils): Promise {
- modalEl.qs("button.refreshList")?.on("click", () => {
- modalEl.qsr(".quotes").empty();
- void getQuotes();
- });
-}
-
-const modal = new AnimatedModal({
- dialogId: "quoteApproveModal",
- setup,
-});
diff --git a/frontend/src/ts/modals/quote-filter.ts b/frontend/src/ts/modals/quote-filter.ts
deleted file mode 100644
index 1a7ef5529eda..000000000000
--- a/frontend/src/ts/modals/quote-filter.ts
+++ /dev/null
@@ -1,51 +0,0 @@
-import { SimpleModal } from "../elements/simple-modal";
-
-export let minFilterLength: number = 0;
-export let maxFilterLength: number = 0;
-export let removeCustom: boolean = false;
-
-export function setRemoveCustom(value: boolean): void {
- removeCustom = value;
-}
-
-function refresh(): void {
- const refreshEvent = new CustomEvent("refresh");
- document.dispatchEvent(refreshEvent);
-}
-
-export const quoteFilterModal = new SimpleModal({
- id: "quoteFilter",
- title: "Enter minimum and maximum number of words",
- inputs: [
- {
- placeholder: "1",
- type: "number",
- },
- {
- placeholder: "100",
- type: "number",
- },
- ],
- buttonText: "save",
- execFn: async (_thisPopup, min, max) => {
- const minNum = parseInt(min, 10);
- const maxNum = parseInt(max, 10);
- if (isNaN(minNum) || isNaN(maxNum)) {
- return {
- status: "notice",
- message: "Invalid min/max values",
- };
- }
-
- minFilterLength = minNum;
- maxFilterLength = maxNum;
- refresh();
-
- let message: string = "Saved custom filter";
- return { status: "success", message };
- },
- afterClickAway: () => {
- setRemoveCustom(true);
- refresh();
- },
-});
diff --git a/frontend/src/ts/modals/quote-rate.ts b/frontend/src/ts/modals/quote-rate.ts
deleted file mode 100644
index d787cb249d02..000000000000
--- a/frontend/src/ts/modals/quote-rate.ts
+++ /dev/null
@@ -1,252 +0,0 @@
-import { Language } from "@monkeytype/schemas/languages";
-import Ape from "../ape";
-import { Quote } from "../controllers/quotes-controller";
-import * as DB from "../db";
-import { hideLoaderBar, showLoaderBar } from "../states/loader-bar";
-import {
- showNoticeNotification,
- showErrorNotification,
- showSuccessNotification,
-} from "../states/notifications";
-import AnimatedModal, { ShowOptions } from "../utils/animated-modal";
-import { isSafeNumber } from "@monkeytype/util/numbers";
-import { qs, ElementWithUtils } from "../utils/dom";
-
-let rating = 0;
-
-type QuoteStats = {
- average?: number;
- ratings?: number;
- totalRating?: number;
- quoteId?: number;
- language?: Language;
-};
-
-let quoteStats: QuoteStats | null | Record = null;
-let currentQuote: Quote | null = null;
-
-export function clearQuoteStats(): void {
- quoteStats = null;
-}
-
-function reset(): void {
- const modalEl = modal.getModal();
- modalEl.qsr(`.quote .text`).setText("-");
- modalEl.qsr(`.quote .source .val`).setText("-");
- modalEl.qsr(`.quote .id .val`).setText("-");
- modalEl.qsr(`.quote .length .val`).setText("-");
- modalEl.qsr(".ratingCount .val").setText("-");
- modalEl.qsr(".ratingAverage .val").setText("-");
-}
-
-function getRatingAverage(quoteStats: QuoteStats): number {
- if (
- isSafeNumber(quoteStats.ratings) &&
- isSafeNumber(quoteStats.totalRating) &&
- quoteStats.ratings > 0 &&
- quoteStats.totalRating > 0
- ) {
- return Math.round((quoteStats.totalRating / quoteStats.ratings) * 10) / 10;
- }
-
- return 0;
-}
-
-export async function getQuoteStats(
- quote?: Quote,
-): Promise {
- if (!quote) {
- return;
- }
-
- showLoaderBar();
- currentQuote = quote;
- const response = await Ape.quotes.getRating({
- query: { quoteId: currentQuote.id, language: currentQuote.language },
- });
- hideLoaderBar();
-
- if (response.status !== 200) {
- showErrorNotification("Failed to get quote ratings", { response });
- return;
- }
-
- if (response.body.data === null) {
- return {} as QuoteStats;
- }
-
- quoteStats = response.body.data as QuoteStats;
- if (quoteStats !== undefined && quoteStats.average === undefined) {
- quoteStats.average = getRatingAverage(quoteStats);
- }
-
- return quoteStats;
-}
-
-function refreshStars(force?: number): void {
- const modalEl = modal.getModal();
- const limit = force ?? rating;
- modalEl.qsa(`.star`).removeClass("active");
- for (let i = 1; i <= limit; i++) {
- modalEl.qsr(`.star[data-rating="${i}"]`).addClass("active");
- }
-}
-
-async function updateRatingStats(): Promise {
- if (!quoteStats) await getQuoteStats();
- const modalEl = modal.getModal();
- const ratings = quoteStats?.ratings;
- modalEl
- .qsr(".ratingCount .val")
- .setText(ratings === undefined ? "0" : ratings.toString());
- modalEl
- .qsr(".ratingAverage .val")
- .setText(quoteStats?.average?.toFixed(1) ?? "-");
-}
-
-function updateData(): void {
- if (!currentQuote) return;
- let lengthDesc;
- if (currentQuote.group === 0) {
- lengthDesc = "short";
- } else if (currentQuote.group === 1) {
- lengthDesc = "medium";
- } else if (currentQuote.group === 2) {
- lengthDesc = "long";
- } else if (currentQuote.group === 3) {
- lengthDesc = "thicc";
- }
- const modalEl = modal.getModal();
- modalEl.qsr(`.quote .text`).setText(currentQuote.text);
- modalEl.qsr(`.quote .source .val`).setText(currentQuote.source);
- modalEl.qsr(`.quote .id .val`).setText(`${currentQuote.id}`);
- modalEl.qsr(`.quote .length .val`).setText(lengthDesc as string);
- void updateRatingStats();
-}
-
-export function show(quote: Quote, showOptions?: ShowOptions): void {
- void modal.show({
- ...showOptions,
- beforeAnimation: async () => {
- reset();
- currentQuote = quote;
- rating = 0;
- const snapshot = DB.getSnapshot();
- const alreadyRated =
- snapshot?.quoteRatings?.[currentQuote.language]?.[currentQuote.id];
- if (isSafeNumber(alreadyRated)) {
- rating = alreadyRated;
- }
- refreshStars();
- updateData();
- },
- });
-}
-
-function hide(clearChain = false): void {
- void modal.hide({
- clearModalChain: clearChain,
- });
-}
-
-async function submit(): Promise {
- if (rating === 0) {
- showNoticeNotification("Please select a rating");
- return;
- }
- if (!currentQuote) {
- return;
- }
-
- hide(true);
-
- showLoaderBar();
- const response = await Ape.quotes.addRating({
- body: { quoteId: currentQuote.id, language: currentQuote.language, rating },
- });
- hideLoaderBar();
-
- if (response.status !== 200) {
- showErrorNotification("Failed to submit quote rating", { response });
- return;
- }
-
- const snapshot = DB.getSnapshot();
- if (!snapshot) return;
- const quoteRatings = snapshot.quoteRatings ?? {};
-
- const languageRatings = quoteRatings?.[currentQuote.language] ?? {};
-
- if (isSafeNumber(languageRatings?.[currentQuote.id])) {
- const oldRating = quoteRatings[currentQuote.language]?.[
- currentQuote.id
- ] as number;
- const diff = rating - oldRating;
- languageRatings[currentQuote.id] = rating;
- quoteStats = {
- ratings: quoteStats?.ratings,
- totalRating: isNaN(quoteStats?.totalRating as number)
- ? 0
- : (quoteStats?.totalRating as number) + diff,
- quoteId: currentQuote.id,
- language: currentQuote.language,
- } as QuoteStats;
- showSuccessNotification("Rating updated");
- } else {
- languageRatings[currentQuote.id] = rating;
- if (
- isSafeNumber(quoteStats?.ratings) &&
- isSafeNumber(quoteStats.totalRating)
- ) {
- quoteStats.ratings++;
- quoteStats.totalRating += rating;
- } else {
- quoteStats = {
- ratings: 1,
- totalRating: rating,
- quoteId: currentQuote.id,
- language: currentQuote.language,
- } as QuoteStats;
- }
- showSuccessNotification("Rating submitted");
- }
-
- snapshot.quoteRatings = quoteRatings;
- DB.setSnapshot(snapshot);
-
- quoteStats.average = getRatingAverage(quoteStats);
- qs(".pageTest #result #rateQuoteButton .rating")?.setText(
- quoteStats.average?.toFixed(1),
- );
- qs(".pageTest #result #rateQuoteButton .icon")?.removeClass("far");
- qs(".pageTest #result #rateQuoteButton .icon")?.addClass("fas");
-}
-
-async function setup(modalEl: ElementWithUtils): Promise {
- modalEl.qs(".submitButton")?.on("click", () => {
- void submit();
- });
-
- const stars = modalEl.qsa(".stars button.star");
- stars.on("click", (e) => {
- const ratingValue = parseInt(
- (e.currentTarget as HTMLElement).getAttribute("data-rating") as string,
- );
- rating = ratingValue;
- refreshStars();
- });
- stars.on("mouseenter", (e) => {
- const ratingHover = parseInt(
- (e.currentTarget as HTMLElement).getAttribute("data-rating") as string,
- );
- refreshStars(ratingHover);
- });
- stars.on("mouseleave", () => {
- refreshStars();
- });
-}
-
-const modal = new AnimatedModal({
- dialogId: "quoteRateModal",
- setup,
-});
diff --git a/frontend/src/ts/modals/quote-report.ts b/frontend/src/ts/modals/quote-report.ts
deleted file mode 100644
index d977becbe5a6..000000000000
--- a/frontend/src/ts/modals/quote-report.ts
+++ /dev/null
@@ -1,155 +0,0 @@
-import { ElementWithUtils, qsr } from "../utils/dom";
-import Ape from "../ape";
-import { Config } from "../config/store";
-import { showLoaderBar, hideLoaderBar } from "../states/loader-bar";
-import {
- showNoticeNotification,
- showErrorNotification,
- showSuccessNotification,
-} from "../states/notifications";
-import QuotesController, { Quote } from "../controllers/quotes-controller";
-import * as CaptchaController from "../controllers/captcha-controller";
-import { removeLanguageSize } from "../utils/strings";
-import SlimSelect from "slim-select";
-import AnimatedModal, { ShowOptions } from "../utils/animated-modal";
-import { CharacterCounter } from "../elements/character-counter";
-import { QuoteReportReason } from "@monkeytype/schemas/quotes";
-
-type State = {
- quoteToReport?: Quote;
- reasonSelect?: SlimSelect | undefined;
-};
-
-const state: State = {
- quoteToReport: undefined,
- reasonSelect: undefined,
-};
-
-export async function show(
- quoteId: number,
- showOptions?: ShowOptions,
-): Promise {
- if (!CaptchaController.isCaptchaAvailable()) {
- showErrorNotification(
- "Could not show quote report popup: Captcha is not available. This could happen due to a blocked or failed network request. Please refresh the page or contact support if this issue persists.",
- );
- return;
- }
-
- void modal.show({
- mode: "dialog",
- ...showOptions,
- beforeAnimation: async (modalEl) => {
- CaptchaController.render(
- modalEl.qsr(".g-recaptcha").native,
- "quoteReportModal",
- );
-
- const language =
- Config.language === "swiss_german" ? "german" : Config.language;
-
- const { quotes } = await QuotesController.getQuotes(language);
- state.quoteToReport = quotes.find((quote) => {
- return quote.id === quoteId;
- });
-
- modalEl.qsr(".quote").setText(state.quoteToReport?.text as string);
- modalEl.qsr(".reason").setValue("Grammatical error");
- modalEl.qsr(".comment").setValue("");
-
- state.reasonSelect = new SlimSelect({
- select: "#quoteReportModal .reason",
- settings: {
- showSearch: false,
- },
- });
-
- new CharacterCounter(modalEl.qsr(".comment"), 250);
- },
- });
-}
-
-async function hide(clearChain = false): Promise {
- void modal.hide({
- clearModalChain: clearChain,
- });
-}
-
-async function submitReport(): Promise {
- const captchaResponse = CaptchaController.getResponse("quoteReportModal");
- if (!captchaResponse) {
- showNoticeNotification("Please complete the captcha");
- return;
- }
-
- const quoteId = state.quoteToReport?.id.toString();
- const quoteLanguage = removeLanguageSize(Config.language);
- const reason = qsr(
- "#quoteReportModal select.reason",
- ).getValue() as QuoteReportReason;
- const comment = qsr(
- "#quoteReportModal .comment",
- ).getValue() as string;
- const captcha = captchaResponse;
-
- if (quoteId === undefined || quoteId === "") {
- showNoticeNotification("Please select a quote");
- return;
- }
-
- if (!reason) {
- showNoticeNotification("Please select a valid report reason");
- return;
- }
-
- if (!comment) {
- showNoticeNotification("Please provide a comment");
- return;
- }
-
- const characterDifference = comment.length - 250;
- if (characterDifference > 0) {
- showNoticeNotification(
- `Report comment is ${characterDifference} character(s) too long`,
- );
- return;
- }
-
- showLoaderBar();
- const response = await Ape.quotes.report({
- body: {
- quoteId,
- quoteLanguage,
- reason,
- comment,
- captcha,
- },
- });
- hideLoaderBar();
-
- if (response.status !== 200) {
- showErrorNotification("Failed to report quote", { response });
- return;
- }
-
- showSuccessNotification("Report submitted. Thank you!");
- void hide(true);
-}
-
-async function setup(modalEl: ElementWithUtils): Promise {
- modalEl.qs("button")?.on("click", async () => {
- await submitReport();
- });
-}
-
-async function cleanup(): Promise {
- CaptchaController.reset("quoteReportModal");
- state.reasonSelect?.destroy();
- state.reasonSelect = undefined;
-}
-
-const modal = new AnimatedModal({
- dialogId: "quoteReportModal",
- setup,
- cleanup,
-});
diff --git a/frontend/src/ts/modals/quote-search.ts b/frontend/src/ts/modals/quote-search.ts
deleted file mode 100644
index 1b7d6d910a76..000000000000
--- a/frontend/src/ts/modals/quote-search.ts
+++ /dev/null
@@ -1,612 +0,0 @@
-import { Config } from "../config/store";
-import { setConfig } from "../config/setters";
-import * as DB from "../db";
-import {
- showNoticeNotification,
- showErrorNotification,
-} from "../states/notifications";
-import * as QuoteSubmitPopup from "./quote-submit";
-import * as QuoteApprovePopup from "./quote-approve";
-import * as QuoteFilterPopup from "./quote-filter";
-import * as QuoteReportModal from "./quote-report";
-import {
- buildSearchService,
- SearchService,
- TextExtractor,
-} from "../utils/search-service";
-import QuotesController, { Quote } from "../controllers/quotes-controller";
-import { isAuthenticated } from "../firebase";
-import { debounce } from "throttle-debounce";
-import Ape from "../ape";
-
-import { showLoaderBar, hideLoaderBar } from "../states/loader-bar";
-import SlimSelect from "slim-select";
-import * as TestState from "../test/test-state";
-import AnimatedModal, { ShowOptions } from "../utils/animated-modal";
-import * as TestLogic from "../test/test-logic";
-import { highlightMatches } from "../utils/strings";
-import { getLanguage } from "../utils/json-data";
-import { qsr, ElementWithUtils } from "../utils/dom";
-
-const searchServiceCache: Record> = {};
-
-const pageSize = 100;
-let currentPageNumber = 1;
-let usingCustomLength = true;
-let dataBalloonDirection = "left";
-let quotes: Quote[];
-let lengthFilterSelectionForChain: string[] | null = null;
-
-async function updateQuotes(): Promise {
- ({ quotes } = await QuotesController.getQuotes(Config.language));
-}
-
-async function updateTooltipDirection(): Promise {
- const quotesLanguage = await getLanguage(Config.language);
- const quotesLanguageIsRTL = quotesLanguage?.rightToLeft ?? false;
- dataBalloonDirection = quotesLanguageIsRTL ? "right" : "left";
-}
-
-function getSearchService(
- language: string,
- data: T[],
- textExtractor: TextExtractor,
-): SearchService {
- if (language in searchServiceCache) {
- return searchServiceCache[language] as unknown as SearchService;
- }
-
- const newSearchService = buildSearchService(data, textExtractor);
- searchServiceCache[language] =
- newSearchService as unknown as (typeof searchServiceCache)[typeof language];
-
- return newSearchService;
-}
-
-function applyQuoteLengthFilter(quotes: Quote[]): Quote[] {
- if (!modal.isOpen()) return [];
- const quoteLengthDropdown = modal
- .getModal()
- .qs("select.quoteLengthFilter");
- const quoteLengthFilterValue =
- getLengthFilterSelectionFromModal(quoteLengthDropdown);
-
- if (quoteLengthFilterValue.length === 0) {
- usingCustomLength = true;
- return quotes;
- }
-
- const quoteLengthFilter = new Set(
- quoteLengthFilterValue.map((filterValue) => parseInt(filterValue, 10)),
- );
-
- const customFilterIndex = quoteLengthFilterValue.indexOf("4");
-
- if (customFilterIndex !== -1) {
- if (QuoteFilterPopup.removeCustom) {
- QuoteFilterPopup.setRemoveCustom(false);
- const selectElement = quoteLengthDropdown?.native as
- | HTMLSelectElement
- | null
- | undefined;
-
- if (!selectElement) {
- return quotes;
- }
-
- //@ts-expect-error SlimSelect adds slim to the element
- const ss = selectElement.slim as SlimSelect | undefined;
-
- if (ss !== undefined) {
- const currentSelected = ss.getSelected();
-
- // remove custom selection
- const customIndex = currentSelected.indexOf("4");
- if (customIndex > -1) {
- currentSelected.splice(customIndex, 1);
- }
-
- ss.setSelected(currentSelected);
- }
- } else {
- if (usingCustomLength) {
- QuoteFilterPopup.quoteFilterModal.show(undefined, {});
- usingCustomLength = false;
- } else {
- const filteredQuotes = quotes.filter(
- (quote) =>
- (quote.length >= QuoteFilterPopup.minFilterLength &&
- quote.length <= QuoteFilterPopup.maxFilterLength) ||
- quoteLengthFilter.has(quote.group),
- );
-
- return filteredQuotes;
- }
- }
- } else {
- usingCustomLength = true;
- }
-
- const filteredQuotes = quotes.filter((quote) =>
- quoteLengthFilter.has(quote.group),
- );
-
- return filteredQuotes;
-}
-
-function applyQuoteFavFilter(quotes: Quote[]): Quote[] {
- if (!modal.isOpen()) return [];
- const showFavOnly = (
- document.querySelector(".toggleFavorites") as HTMLDivElement
- ).classList.contains("active");
-
- const filteredQuotes = quotes.filter((quote) => {
- if (showFavOnly) {
- return QuotesController.isQuoteFavorite(quote);
- }
-
- return true;
- });
-
- return filteredQuotes;
-}
-
-function buildQuoteSearchResult(
- quote: Quote,
- matchedSearchTerms: string[],
-): string {
- let lengthDesc;
- if (quote.length < 101) {
- lengthDesc = "short";
- } else if (quote.length < 301) {
- lengthDesc = "medium";
- } else if (quote.length < 601) {
- lengthDesc = "long";
- } else {
- lengthDesc = "thicc";
- }
-
- const loggedOut = !isAuthenticated();
- const isFav = !loggedOut && QuotesController.isQuoteFavorite(quote);
-
- return `
-
-
-
- ${highlightMatches(quote.text, matchedSearchTerms)}
-
-
-
-
id
-
- ${highlightMatches(quote.id.toString(), matchedSearchTerms)}
-
-
-
-
-
length
- ${lengthDesc}
-
-
-
-
source
- ${highlightMatches(quote.source, matchedSearchTerms)}
-
-
-
-
-
-
-
-
-
-
-
- `;
-}
-
-function exactSearch(quotes: Quote[], captured: RegExp[]): [Quote[], string[]] {
- const matches: Quote[] = [];
- const exactSearchQueryTerms: Set = new Set();
-
- for (const quote of quotes) {
- const textAndSource = quote.text + quote.source;
- const currentMatches = [];
- let noMatch = false;
-
- for (const regex of captured) {
- const match = textAndSource.match(regex);
-
- if (!match) {
- noMatch = true;
- break;
- }
-
- currentMatches.push(RegExp.escape(match[0]));
- }
-
- if (!noMatch) {
- currentMatches.forEach((match) => exactSearchQueryTerms.add(match));
- matches.push(quote);
- }
- }
-
- return [matches, Array.from(exactSearchQueryTerms)];
-}
-
-async function updateResults(searchText: string): Promise {
- if (!modal.isOpen()) return;
-
- if (quotes === undefined) {
- ({ quotes } = await QuotesController.getQuotes(Config.language));
- }
-
- let matches: Quote[] = [];
- let matchedQueryTerms: string[] = [];
- let exactSearchMatches: Quote[] = [];
- let exactSearchMatchedQueryTerms: string[] = [];
-
- const quotationsRegex = /"(.*?)"/g;
- const exactSearchQueries = Array.from(searchText.matchAll(quotationsRegex));
- const removedSearchText = searchText.replaceAll(quotationsRegex, "");
-
- if (exactSearchQueries[0]) {
- const searchQueriesRaw = exactSearchQueries.map(
- (query) => new RegExp(RegExp.escape(query[1] ?? ""), "i"),
- );
-
- [exactSearchMatches, exactSearchMatchedQueryTerms] = exactSearch(
- quotes,
- searchQueriesRaw,
- );
- }
-
- const quoteSearchService = getSearchService(
- Config.language,
- quotes,
- (quote: Quote) => {
- return `${quote.text} ${quote.id} ${quote.source}`;
- },
- );
-
- if (exactSearchMatches.length > 0 || removedSearchText === searchText) {
- const ids = exactSearchMatches.map((match) => match.id);
-
- ({ results: matches, matchedQueryTerms } = quoteSearchService.query(
- removedSearchText,
- ids,
- ));
-
- exactSearchMatches.forEach((match) => {
- if (!matches.includes(match)) matches.push(match);
- });
-
- matchedQueryTerms = [...exactSearchMatchedQueryTerms, ...matchedQueryTerms];
- }
-
- const quotesToShow = applyQuoteLengthFilter(
- applyQuoteFavFilter(searchText === "" ? quotes : matches),
- );
-
- const resultsList = qsr("#quoteSearchResults");
- resultsList.empty();
-
- const totalPages = Math.ceil(quotesToShow.length / pageSize);
-
- if (currentPageNumber >= totalPages) {
- qsr("#quoteSearchPageNavigator .nextPage").disable();
- } else {
- qsr("#quoteSearchPageNavigator .nextPage").enable();
- }
-
- if (currentPageNumber <= 1) {
- qsr("#quoteSearchPageNavigator .prevPage").disable();
- } else {
- qsr("#quoteSearchPageNavigator .prevPage").enable();
- }
-
- if (quotesToShow.length === 0) {
- modal.getModal().qsr(".pageInfo").setHtml("No search results");
- return;
- }
-
- const startIndex = (currentPageNumber - 1) * pageSize;
- const endIndex = Math.min(currentPageNumber * pageSize, quotesToShow.length);
-
- modal
- .getModal()
- .qsr(".pageInfo")
- .setHtml(`${startIndex + 1} - ${endIndex} of ${quotesToShow.length}`);
-
- quotesToShow.slice(startIndex, endIndex).forEach((quote) => {
- const quoteSearchResult = buildQuoteSearchResult(quote, matchedQueryTerms);
- resultsList.appendHtml(quoteSearchResult);
- });
-
- const searchResults = modal.getModal().qsa(".searchResult");
- searchResults.qs(".textButton.favorite")?.on("click", (e) => {
- e.stopPropagation();
- const quoteId = parseInt(
- (e.currentTarget as HTMLElement)?.closest(".searchResult")
- ?.dataset?.["quoteId"] as string,
- );
- if (quoteId === undefined || isNaN(quoteId)) {
- showErrorNotification(
- "Could not toggle quote favorite: quote id is not a number",
- );
- return;
- }
- void toggleFavoriteForQuote(`${quoteId}`);
- });
- searchResults.qs(".textButton.report")?.on("click", (e) => {
- e.stopPropagation();
- const quoteId = parseInt(
- (e.currentTarget as HTMLElement)?.closest(".searchResult")
- ?.dataset?.["quoteId"] as string,
- );
- if (quoteId === undefined || isNaN(quoteId)) {
- showErrorNotification(
- "Could not open quote report modal: quote id is not a number",
- );
- return;
- }
- void QuoteReportModal.show(quoteId, {
- modalChain: modal,
- });
- });
- searchResults.on("click", (e) => {
- const quoteId = parseInt(
- (e.currentTarget as HTMLElement)?.closest(".searchResult")
- ?.dataset?.["quoteId"] as string,
- );
- TestState.setSelectedQuoteId(quoteId);
- apply(quoteId);
- });
-}
-
-let lengthSelect: SlimSelect | undefined = undefined;
-
-function getLengthFilterSelectionFromModal(
- quoteLengthDropdown?: ElementWithUtils | null,
-): string[] {
- const dropdown =
- quoteLengthDropdown ??
- modal.getModal().qs("select.quoteLengthFilter");
- return dropdown
- ? Array.from(dropdown.native.selectedOptions).map((el) => el.value)
- : [];
-}
-
-function initLengthSelect(initialSelection?: string[] | null): void {
- lengthSelect = new SlimSelect({
- select: "#quoteSearchModal .quoteLengthFilter",
-
- settings: {
- showSearch: false,
- placeholderText: "filter by length",
- contentLocation: modal.getModal().native,
- },
- data: [
- {
- text: "short",
- value: "0",
- },
- {
- text: "medium",
- value: "1",
- },
- {
- text: "long",
- value: "2",
- },
- {
- text: "thicc",
- value: "3",
- },
- {
- text: "custom",
- value: "4",
- },
- ],
- });
-
- if (initialSelection !== undefined && initialSelection !== null) {
- lengthSelect.setSelected(initialSelection);
- }
-}
-
-export async function show(showOptions?: ShowOptions): Promise {
- void modal.show({
- ...showOptions,
- focusFirstInput: true,
- beforeAnimation: async (modalEl) => {
- lengthFilterSelectionForChain = null;
- usingCustomLength = true;
- if (!isAuthenticated()) {
- modalEl.qsr(".goToQuoteSubmit").hide();
- modalEl.qsr(".toggleFavorites").hide();
- } else {
- modalEl.qsr(".goToQuoteSubmit").show();
- modalEl.qsr(".toggleFavorites").show();
- }
-
- const quoteMod = DB.getSnapshot()?.quoteMod;
- const isQuoteMod =
- quoteMod !== undefined &&
- (quoteMod === true || (quoteMod as string) !== "");
-
- if (isQuoteMod) {
- modalEl.qsr(".goToQuoteApprove").show();
- } else {
- modalEl.qsr(".goToQuoteApprove").hide();
- }
-
- initLengthSelect();
- },
- afterAnimation: async () => {
- await updateTooltipDirection();
- await updateQuotes();
- },
- });
-}
-
-function hide(clearChain = false): void {
- void modal.hide({
- clearModalChain: clearChain,
- });
-}
-
-function apply(val: number): void {
- if (isNaN(val)) {
- val = parseInt(
- (document.getElementById("searchBox") as HTMLInputElement).value,
- );
- }
- if (val !== null && !isNaN(val) && val >= 0) {
- setConfig("quoteLength", [-2]);
- TestState.setSelectedQuoteId(val);
- } else {
- showNoticeNotification("Quote ID must be at least 1");
- return;
- }
- TestLogic.restart();
- hide(true);
-}
-
-const searchForQuotes = debounce(250, (): void => {
- if (!modal.isOpen()) return;
- const searchText = (document.getElementById("searchBox") as HTMLInputElement)
- .value;
- currentPageNumber = 1;
- void updateResults(searchText);
-});
-
-async function toggleFavoriteForQuote(quoteId: string): Promise {
- const quoteLang = Config.language;
-
- if (quoteLang === undefined || quoteId === "") {
- showErrorNotification("Could not get quote stats!");
- return;
- }
-
- const quote = {
- language: quoteLang,
- id: parseInt(quoteId, 10),
- } as Quote;
-
- const alreadyFavorited = QuotesController.isQuoteFavorite(quote);
-
- const button = modal
- .getModal()
- .qsr(`.searchResult[data-quote-id="${quoteId}"] .textButton.favorite i`);
- const dbSnapshot = DB.getSnapshot();
- if (!dbSnapshot) return;
-
- if (alreadyFavorited) {
- try {
- showLoaderBar();
- await QuotesController.setQuoteFavorite(quote, false);
- hideLoaderBar();
- button.removeClass("fas").addClass("far");
- } catch (e) {
- hideLoaderBar();
- showErrorNotification("Failed to remove quote from favorites", {
- error: e,
- });
- }
- } else {
- try {
- showLoaderBar();
- await QuotesController.setQuoteFavorite(quote, true);
- hideLoaderBar();
- button.removeClass("far").addClass("fas");
- } catch (e) {
- hideLoaderBar();
- showErrorNotification("Failed to add quote to favorites", { error: e });
- }
- }
-}
-
-async function setup(modalEl: ElementWithUtils): Promise {
- modalEl.qs(".searchBox")?.on("input", (e) => {
- searchForQuotes();
- });
- modalEl.qs("button.toggleFavorites")?.on("click", (e) => {
- if (!isAuthenticated()) {
- // notify("You need to be logged in to use this feature!");
- return;
- }
-
- (e.currentTarget as HTMLElement)?.classList.toggle("active");
- searchForQuotes();
- });
- modalEl.qs(".goToQuoteApprove")?.on("click", (e) => {
- void QuoteApprovePopup.show({
- modalChain: modal,
- });
- });
- modalEl.qs(".goToQuoteSubmit")?.on("click", async (e) => {
- showLoaderBar();
- const getSubmissionEnabled = await Ape.quotes.isSubmissionEnabled();
- const isSubmissionEnabled =
- (getSubmissionEnabled.status === 200 &&
- getSubmissionEnabled.body.data?.isEnabled) ??
- false;
- hideLoaderBar();
- if (!isSubmissionEnabled) {
- showNoticeNotification(
- "Quote submission is disabled temporarily due to a large submission queue.",
- {
- durationMs: 5000,
- },
- );
- return;
- }
- void QuoteSubmitPopup.show({
- modalChain: modal,
- });
- });
- modalEl.qs(".quoteLengthFilter")?.on("change", searchForQuotes);
- modalEl.qs(".nextPage")?.on("click", () => {
- const searchText = (
- document.getElementById("searchBox") as HTMLInputElement
- ).value;
- currentPageNumber++;
- void updateResults(searchText);
- });
- modalEl.qs(".prevPage")?.on("click", () => {
- const searchText = (
- document.getElementById("searchBox") as HTMLInputElement
- ).value;
- currentPageNumber--;
- void updateResults(searchText);
- });
-
- document?.addEventListener("refresh", () => {
- const searchText = (
- document.getElementById("searchBox") as HTMLInputElement
- ).value;
- void updateResults(searchText);
- });
-}
-
-async function cleanup(): Promise {
- lengthFilterSelectionForChain = getLengthFilterSelectionFromModal();
- lengthSelect?.destroy();
- lengthSelect = undefined;
-}
-
-const modal = new AnimatedModal({
- dialogId: "quoteSearchModal",
- setup,
- cleanup,
- showOptionsWhenInChain: {
- beforeAnimation: async (): Promise => {
- initLengthSelect(lengthFilterSelectionForChain);
- },
- },
-});
diff --git a/frontend/src/ts/modals/quote-submit.ts b/frontend/src/ts/modals/quote-submit.ts
deleted file mode 100644
index e75854ee6182..000000000000
--- a/frontend/src/ts/modals/quote-submit.ts
+++ /dev/null
@@ -1,131 +0,0 @@
-import { ElementWithUtils } from "../utils/dom";
-import Ape from "../ape";
-
-import { showLoaderBar, hideLoaderBar } from "../states/loader-bar";
-import {
- showNoticeNotification,
- showErrorNotification,
- showSuccessNotification,
-} from "../states/notifications";
-import * as CaptchaController from "../controllers/captcha-controller";
-import * as Strings from "../utils/strings";
-import { Config } from "../config/store";
-import SlimSelect from "slim-select";
-import AnimatedModal, { ShowOptions } from "../utils/animated-modal";
-import { CharacterCounter } from "../elements/character-counter";
-import { Language } from "@monkeytype/schemas/languages";
-import { LanguageGroupNames } from "../constants/languages";
-
-let dropdownReady = false;
-async function initDropdown(): Promise {
- if (dropdownReady) return;
-
- for (const group of LanguageGroupNames) {
- if (group === "swiss_german") continue;
- modal
- .getModal()
- .qsr(".newQuoteLanguage")
- .appendHtml(
- ``,
- );
- }
- dropdownReady = true;
-}
-
-let select: SlimSelect | undefined = undefined;
-
-async function submitQuote(): Promise {
- const modalEl = modal.getModal();
- const text = modalEl
- .qsr(".newQuoteText")
- .getValue() as string;
- const source = modalEl
- .qsr(".newQuoteSource")
- .getValue() as string;
- const language = modalEl
- .qsr("select.newQuoteLanguage")
- .getValue() as Language;
- const captcha = CaptchaController.getResponse("submitQuote");
-
- if (!text || !source || !language) {
- showNoticeNotification("Please fill in all fields");
- return;
- }
-
- showLoaderBar();
- const response = await Ape.quotes.add({
- body: { text, source, language, captcha },
- });
- hideLoaderBar();
-
- if (response.status !== 200) {
- showErrorNotification("Failed to submit quote", { response });
- return;
- }
-
- showSuccessNotification("Quote submitted.");
- modalEl.qsr(".newQuoteText").setValue("");
- modalEl.qsr(".newQuoteSource").setValue("");
- CaptchaController.reset("submitQuote");
-}
-
-export async function show(showOptions: ShowOptions): Promise {
- if (!CaptchaController.isCaptchaAvailable()) {
- showErrorNotification(
- "Could not show quote submit popup: Captcha is not available. This could happen due to a blocked or failed network request. Please refresh the page or contact support if this issue persists.",
- );
- return;
- }
-
- void modal.show({
- ...showOptions,
- mode: "dialog",
- focusFirstInput: true,
- afterAnimation: async (modalEl) => {
- CaptchaController.render(
- modalEl.qsr(".g-recaptcha").native,
- "submitQuote",
- );
- await initDropdown();
-
- select = new SlimSelect({
- select: "#quoteSubmitModal .newQuoteLanguage",
- });
-
- modalEl
- .qsr("select.newQuoteLanguage")
- .setValue(Strings.removeLanguageSize(Config.language));
- modalEl
- .qsr("select.newQuoteLanguage")
- .dispatch("change");
- modalEl.qsr("input").setValue("");
-
- new CharacterCounter(modalEl.qsr(".newQuoteText"), 250);
- },
- });
-}
-
-function hide(clearModalChain: boolean): void {
- void modal.hide({
- clearModalChain,
- });
-}
-
-async function setup(modalEl: ElementWithUtils): Promise {
- modalEl.qs("button")?.on("click", () => {
- void submitQuote();
- hide(true);
- });
-}
-
-async function cleanup(): Promise {
- CaptchaController.reset("submitQuote");
- select?.destroy();
- select = undefined;
-}
-
-const modal = new AnimatedModal({
- dialogId: "quoteSubmitModal",
- setup,
- cleanup,
-});
diff --git a/frontend/src/ts/modals/save-custom-text.ts b/frontend/src/ts/modals/save-custom-text.ts
deleted file mode 100644
index d60ce7e2efa5..000000000000
--- a/frontend/src/ts/modals/save-custom-text.ts
+++ /dev/null
@@ -1,112 +0,0 @@
-import * as CustomText from "../test/custom-text";
-import {
- showNoticeNotification,
- showErrorNotification,
- showSuccessNotification,
-} from "../states/notifications";
-import * as CustomTextState from "../legacy-states/custom-text-name";
-import AnimatedModal, { ShowOptions } from "../utils/animated-modal";
-import { ValidatedHtmlInputElement } from "../elements/input-validation";
-import { z } from "zod";
-import { ElementWithUtils, qsr } from "../utils/dom";
-
-type IncomingData = {
- text: string[];
-};
-
-type State = {
- textToSave: string[];
-};
-
-const state: State = {
- textToSave: [],
-};
-
-const validatedInput = new ValidatedHtmlInputElement(
- qsr("#saveCustomTextModal .textName"),
- {
- debounceDelay: 500,
- schema: z
- .string()
- .min(1)
- .max(32)
- .regex(/^[\w\s-]+$/, {
- message:
- "Name can only contain letters, numbers, spaces, underscores and hyphens",
- }),
- isValid: async (value) => {
- const checkbox = modal
- .getModal()
- .qsr(".isLongText")
- .isChecked() as boolean;
- const names = CustomText.getCustomTextNames(checkbox);
- return !names.includes(value) ? true : "Duplicate name";
- },
- callback: (result) => {
- const modalEl = modal.getModal();
- if (result.status === "success") {
- modalEl.qsr("button.save").enable();
- } else {
- modalEl.qsr("button.save").disable();
- }
- },
- },
-);
-
-export async function show(options: ShowOptions): Promise {
- state.textToSave = [];
- void modal.show({
- ...options,
- beforeAnimation: async (modalEl, modalChainData) => {
- state.textToSave = modalChainData?.text ?? [];
- modalEl.qsr(".textName").setValue("");
- modalEl.qsr(".isLongText").setChecked(false);
- modalEl.qsr("button.save").disable();
- },
- });
-}
-
-function save(): boolean {
- const modalEl = modal.getModal();
- const name = modalEl.qsr(".textName").getValue() as string;
- const checkbox = modalEl
- .qsr(".isLongText")
- .isChecked() as boolean;
-
- if (!name) {
- showNoticeNotification("Custom text needs a name");
- return false;
- }
-
- if (state.textToSave.length === 0) {
- showNoticeNotification("Custom text can't be empty");
- return false;
- }
-
- const saved = CustomText.setCustomText(name, state.textToSave, checkbox);
- if (saved) {
- CustomTextState.setCustomTextName(name, checkbox);
- showSuccessNotification("Custom text saved");
- return true;
- } else {
- showErrorNotification("Error saving custom text");
- return false;
- }
-}
-
-async function setup(modalEl: ElementWithUtils): Promise {
- modalEl.on("submit", (e) => {
- e.preventDefault();
- if (validatedInput.getValidationResult().status === "success" && save()) {
- void modal.hide();
- }
- });
- modalEl.qs(".isLongText")?.on("input", (e) => {
- validatedInput.triggerValidation();
- });
-}
-
-const modal = new AnimatedModal({
- dialogId: "saveCustomTextModal",
- setup,
-});
diff --git a/frontend/src/ts/modals/saved-texts.ts b/frontend/src/ts/modals/saved-texts.ts
deleted file mode 100644
index 9d33feb9ce40..000000000000
--- a/frontend/src/ts/modals/saved-texts.ts
+++ /dev/null
@@ -1,146 +0,0 @@
-import * as CustomText from "../test/custom-text";
-import * as CustomTextState from "../legacy-states/custom-text-name";
-import { escapeHTML } from "../utils/misc";
-import AnimatedModal, {
- HideOptions,
- ShowOptions,
-} from "../utils/animated-modal";
-import { showPopup } from "./simple-modals";
-import { showErrorNotification } from "../states/notifications";
-
-async function fill(): Promise {
- const modalEl = modal.getModal();
- const names = CustomText.getCustomTextNames();
- const listEl = modalEl.qsr(".list").empty();
- let list = "";
- if (names.length === 0) {
- list += "No saved custom texts found
";
- } else {
- for (const name of names) {
- list += `
-
${escapeHTML(name)}
-
-
-
-
`;
- }
- }
- listEl.setHtml(list);
-
- const longNames = CustomText.getCustomTextNames(true);
- const longListEl = modalEl.qsr(".listLong").empty();
- let longList = "";
- if (longNames.length === 0) {
- longList += "No saved long custom texts found
";
- } else {
- for (const name of longNames) {
- longList += `
-
${escapeHTML(name)}
-
reset
-
-
-
-
`;
- }
- }
- longListEl.setHtml(longList);
-
- modalEl.qsa(".list .savedText .button.delete")?.on("click", (e) => {
- const name = (e.target as HTMLElement)
- .closest(".savedText")
- ?.getAttribute("data-name");
- if (name === null || name === undefined) {
- showErrorNotification("Failed to show delete modal: no name found");
- return;
- }
- showPopup("deleteCustomText", [name], {
- modalChain: modal as AnimatedModal,
- });
- });
-
- modalEl.qsa(".listLong .savedLongText .button.delete")?.on("click", (e) => {
- const name = (e.target as HTMLElement)
- .closest(".savedLongText")
- ?.getAttribute("data-name");
- if (name === null || name === undefined) {
- showErrorNotification("Failed to show delete modal: no name found");
- return;
- }
- showPopup("deleteCustomTextLong", [name], {
- modalChain: modal as AnimatedModal,
- });
- });
-
- modalEl
- .qsa(".listLong .savedLongText .button.resetProgress")
- ?.on("click", (e) => {
- const name = (e.target as HTMLElement)
- .closest(".savedLongText")
- ?.getAttribute("data-name");
- if (name === null || name === undefined) {
- showErrorNotification("Failed to show delete modal: no name found");
- return;
- }
- showPopup("resetProgressCustomTextLong", [name], {
- modalChain: modal as AnimatedModal,
- });
- });
-
- modalEl.qsa(".list .savedText .button.name")?.on("click", (e) => {
- const name = (e.target as HTMLElement).textContent;
- CustomTextState.setCustomTextName(name, false);
- const text = getSavedText(name, false);
- hide({ modalChainData: { text, long: false } });
- });
-
- modalEl.qsa(".listLong .savedLongText .button.name")?.on("click", (e) => {
- const name = (e.target as HTMLElement).textContent;
- CustomTextState.setCustomTextName(name, true);
- const text = getSavedText(name, true);
- hide({ modalChainData: { text, long: true } });
- });
-}
-
-export async function show(options: ShowOptions): Promise {
- void modal.show({
- ...options,
- beforeAnimation: async () => {
- void fill();
- },
- });
-}
-
-function hide(hideOptions?: HideOptions): void {
- void modal.hide({
- ...hideOptions,
- });
-}
-
-function getSavedText(name: string, long: boolean): string {
- let text = CustomText.getCustomText(name, long);
- if (long) {
- text = text.slice(CustomText.getCustomTextLongProgress(name));
- }
- return text.join(" ");
-}
-
-async function setup(): Promise {
- //
-}
-
-type OutgoingData = {
- text: string;
- long: boolean;
-};
-
-const modal = new AnimatedModal({
- dialogId: "savedTextsModal",
- setup,
- showOptionsWhenInChain: {
- beforeAnimation: async (): Promise => {
- void fill();
- },
- },
-});
diff --git a/frontend/src/ts/modals/simple-modals-base.ts b/frontend/src/ts/modals/simple-modals-base.ts
index ce00ace8d3b9..19d2b5bc51eb 100644
--- a/frontend/src/ts/modals/simple-modals-base.ts
+++ b/frontend/src/ts/modals/simple-modals-base.ts
@@ -19,9 +19,6 @@ export type PopupKey =
| "revokeAllTokens"
| "unlinkDiscord"
| "editApeKey"
- | "deleteCustomText"
- | "deleteCustomTextLong"
- | "resetProgressCustomTextLong"
| "updateCustomTheme"
| "deleteCustomTheme"
| "devGenerateData";
@@ -43,9 +40,6 @@ export const list: Record = {
revokeAllTokens: undefined,
unlinkDiscord: undefined,
editApeKey: undefined,
- deleteCustomText: undefined,
- deleteCustomTextLong: undefined,
- resetProgressCustomTextLong: undefined,
updateCustomTheme: undefined,
deleteCustomTheme: undefined,
devGenerateData: undefined,
diff --git a/frontend/src/ts/modals/simple-modals.ts b/frontend/src/ts/modals/simple-modals.ts
index 873b785ea424..bf791d4e2c5c 100644
--- a/frontend/src/ts/modals/simple-modals.ts
+++ b/frontend/src/ts/modals/simple-modals.ts
@@ -6,7 +6,6 @@ import { setConfig } from "../config/setters";
import { showNoticeNotification } from "../states/notifications";
import * as Settings from "../pages/settings";
import * as ThemePicker from "../elements/settings/theme-picker";
-import * as CustomText from "../test/custom-text";
import { FirebaseError } from "firebase/app";
import {
isAuthenticated,
@@ -24,7 +23,6 @@ import {
import { reloadAfter } from "../utils/misc";
import { isDevEnvironment } from "../utils/env";
import { createErrorMessage } from "../utils/error";
-import * as CustomTextState from "../legacy-states/custom-text-name";
import * as ThemeController from "../controllers/theme-controller";
import * as AccountSettings from "../pages/account-settings";
import {
@@ -977,66 +975,6 @@ list.unlinkDiscord = new SimpleModal({
},
});
-list.deleteCustomText = new SimpleModal({
- id: "deleteCustomText",
- title: "Delete custom text",
- text: "Are you sure?",
- buttonText: "delete",
- execFn: async (_thisPopup): Promise => {
- CustomText.deleteCustomText(_thisPopup.parameters[0] as string, false);
- CustomTextState.setCustomTextName("", undefined);
-
- return {
- status: "success",
- message: "Custom text deleted",
- };
- },
- beforeInitFn: (_thisPopup): void => {
- _thisPopup.text = `Are you sure you want to delete custom text ${_thisPopup.parameters[0]}?`;
- },
-});
-
-list.deleteCustomTextLong = new SimpleModal({
- id: "deleteCustomTextLong",
- title: "Delete custom text",
- text: "Are you sure?",
- buttonText: "delete",
- execFn: async (_thisPopup): Promise => {
- CustomText.deleteCustomText(_thisPopup.parameters[0] as string, true);
- CustomTextState.setCustomTextName("", undefined);
-
- return {
- status: "success",
- message: "Custom text deleted",
- };
- },
- beforeInitFn: (_thisPopup): void => {
- _thisPopup.text = `Are you sure you want to delete custom text ${_thisPopup.parameters[0]}?`;
- },
-});
-
-list.resetProgressCustomTextLong = new SimpleModal({
- id: "resetProgressCustomTextLong",
- title: "Reset progress for custom text",
- text: "Are you sure?",
- buttonText: "reset",
- execFn: async (_thisPopup): Promise => {
- CustomText.setCustomTextLongProgress(_thisPopup.parameters[0] as string, 0);
- const text = CustomText.getCustomText(
- _thisPopup.parameters[0] as string,
- true,
- );
- CustomText.setText(text);
- return {
- status: "success",
- message: "Custom text progress reset",
- };
- },
- beforeInitFn: (_thisPopup): void => {
- _thisPopup.text = `Are you sure you want to reset your progress for custom text ${_thisPopup.parameters[0]}?`;
- },
-});
-
list.updateCustomTheme = new SimpleModal({
id: "updateCustomTheme",
title: "Update custom theme",
diff --git a/frontend/src/ts/modals/word-filter.ts b/frontend/src/ts/modals/word-filter.ts
deleted file mode 100644
index 0b2749768324..000000000000
--- a/frontend/src/ts/modals/word-filter.ts
+++ /dev/null
@@ -1,350 +0,0 @@
-import * as Misc from "../utils/misc";
-import * as JSONData from "../utils/json-data";
-import * as CustomText from "../test/custom-text";
-import {
- showNoticeNotification,
- showErrorNotification,
-} from "../states/notifications";
-import SlimSelect from "slim-select";
-import AnimatedModal, {
- HideOptions,
- ShowOptions,
-} from "../utils/animated-modal";
-import { LayoutsList } from "../constants/layouts";
-import { tryCatch } from "@monkeytype/util/trycatch";
-import { LanguageList } from "../constants/languages";
-import { Language } from "@monkeytype/schemas/languages";
-import { LayoutObject } from "@monkeytype/schemas/layouts";
-import { qs, qsr } from "../utils/dom";
-
-type FilterPreset = {
- display: string;
- getIncludeString: (layout: LayoutObject) => string[][];
-} & (
- | {
- exactMatch: true;
- }
- | {
- exactMatch?: false;
- getExcludeString?: (layout: LayoutObject) => string[][];
- }
-);
-
-const exactMatchCheckbox = qs(
- "#wordFilterModal #exactMatchOnly",
-);
-
-const presets: Record = {
- homeKeys: {
- display: "home keys",
- getIncludeString: (layout) => {
- const homeKeysLeft = layout.keys.row3.slice(0, 4);
- const homeKeysRight = layout.keys.row3.slice(6, 10);
- return [...homeKeysLeft, ...homeKeysRight];
- },
- exactMatch: true,
- },
- leftHand: {
- display: "left hand",
- getIncludeString: (layout) => {
- const topRowInclude = layout.keys.row2.slice(0, 5);
- const homeRowInclude = layout.keys.row3.slice(0, 5);
- const bottomRowInclude = layout.keys.row4.slice(0, 5);
- return [...topRowInclude, ...homeRowInclude, ...bottomRowInclude];
- },
- exactMatch: true,
- },
- rightHand: {
- display: "right hand",
- getIncludeString: (layout) => {
- const topRowInclude = layout.keys.row2.slice(5);
- const homeRowInclude = layout.keys.row3.slice(5);
- const bottomRowInclude = layout.keys.row4.slice(4);
- return [...topRowInclude, ...homeRowInclude, ...bottomRowInclude];
- },
- exactMatch: true,
- },
- homeRow: {
- display: "home row",
- getIncludeString: (layout) => {
- return layout.keys.row3;
- },
- exactMatch: true,
- },
- topRow: {
- display: "top row",
- getIncludeString: (layout) => {
- return layout.keys.row2;
- },
- exactMatch: true,
- },
- bottomRow: {
- display: "bottom row",
- getIncludeString: (layout) => {
- return layout.keys.row4;
- },
- exactMatch: true,
- },
-};
-
-async function initSelectOptions(): Promise {
- const modalEl = modal.getModal();
- modalEl.qsr(".languageInput").empty();
- modalEl.qsr(".layoutInput").empty();
- modalEl.qsr(".presetInput").empty();
-
- LanguageList.forEach((language) => {
- const prettyLang = language.replace(/_/gi, " ");
- modalEl.qsr(".languageInput").appendHtml(`
-
- `);
- });
-
- for (const layout of LayoutsList) {
- const prettyLayout = layout.replace(/_/gi, " ");
- modalEl.qsr(".layoutInput").appendHtml(`
-
- `);
- }
-
- for (const [presetId, preset] of Object.entries(presets)) {
- modalEl
- .qsr(".presetInput")
- .appendHtml(``);
- }
-}
-
-let languageSelect: SlimSelect | undefined = undefined;
-let layoutSelect: SlimSelect | undefined = undefined;
-let presetSelect: SlimSelect | undefined = undefined;
-
-export async function show(showOptions?: ShowOptions): Promise {
- void modal.show({
- ...showOptions,
- beforeAnimation: async (modalEl) => {
- languageSelect = new SlimSelect({
- select: "#wordFilterModal .languageInput",
- settings: {
- contentLocation: modalEl.native,
- },
- });
- layoutSelect = new SlimSelect({
- select: "#wordFilterModal .layoutInput",
- settings: {
- contentLocation: modal.getModal().native,
- },
- });
- presetSelect = new SlimSelect({
- select: "#wordFilterModal .presetInput",
- settings: {
- contentLocation: modal.getModal().native,
- },
- });
- modalEl.qs(".loadingIndicator")?.show();
- enableButtons();
- },
- });
-}
-
-function hide(hideOptions?: HideOptions): void {
- void modal.hide({
- ...hideOptions,
- });
-}
-
-async function filter(language: Language): Promise {
- const modalEl = modal.getModal();
- const exactMatchOnly = exactMatchCheckbox?.isChecked() as boolean;
- let filterin = modalEl
- .qsr(".wordIncludeInput")
- .getValue() as string;
- filterin = Misc.escapeRegExp(filterin?.trim());
- filterin = filterin.replace(/\s+/gi, "|");
- let regincl;
-
- if (exactMatchOnly) {
- regincl = new RegExp("^[" + filterin + "]+$", "i");
- } else {
- regincl = new RegExp(filterin, "i");
- }
-
- let filterout = modalEl
- .qsr(".wordExcludeInput")
- .getValue() as string;
- filterout = Misc.escapeRegExp(filterout.trim());
- filterout = filterout.replace(/\s+/gi, "|");
- const regexcl = new RegExp(filterout, "i");
- const filteredWords = [];
-
- const { data: languageWordList, error } = await tryCatch(
- JSONData.getLanguage(language),
- );
- if (error) {
- showErrorNotification("Failed to filter language words", { error });
- return [];
- }
-
- const maxLengthInput = modalEl
- .qsr(".wordMaxInput")
- .getValue() as string;
- const minLengthInput = modalEl
- .qsr(".wordMinInput")
- .getValue() as string;
- let maxLength;
- let minLength;
- if (maxLengthInput === "") {
- maxLength = 999;
- } else {
- maxLength = parseInt(maxLengthInput);
- }
- if (minLengthInput === "") {
- minLength = 1;
- } else {
- minLength = parseInt(minLengthInput);
- }
- for (const word of languageWordList.words) {
- const test1 = regincl.test(word);
- const test2 = exactMatchOnly ? false : regexcl.test(word);
- if (
- ((test1 && !test2) || (test1 && filterout === "")) &&
- word.length <= maxLength &&
- word.length >= minLength
- ) {
- filteredWords.push(word);
- }
- }
- return filteredWords;
-}
-
-async function apply(set: boolean): Promise {
- const language = modal
- .getModal()
- .qsr("select.languageInput")
- .getValue() as Language;
- const filteredWords = await filter(language);
-
- if (filteredWords.length === 0) {
- showNoticeNotification("No words found");
- enableButtons();
- return;
- }
-
- const customText = filteredWords.join(
- CustomText.getPipeDelimiter() ? "|" : " ",
- );
-
- hide({
- modalChainData: {
- text: customText,
- set,
- },
- });
-}
-
-function setExactMatchInput(disable: boolean): void {
- const wordExcludeInputEl = modal
- .getModal()
- .qsr("#wordExcludeInput");
-
- if (disable) {
- wordExcludeInputEl.setValue("");
- wordExcludeInputEl.disable();
- } else {
- wordExcludeInputEl.enable();
- }
-
- exactMatchCheckbox?.setChecked(disable);
-}
-
-function disableButtons(): void {
- modal.getModal().qsa("button").disable();
-}
-
-function enableButtons(): void {
- modal.getModal().qsa("button").enable();
-}
-
-async function setup(): Promise {
- await initSelectOptions();
-
- const modalEl = modal.getModal();
-
- modalEl.qsr("button.generateButton").on("click", async () => {
- const presetName = modalEl
- .qsr("select.presetInput")
- .getValue() as string;
- const layoutName = modalEl
- .qsr("select.layoutInput")
- .getValue() as string;
-
- const presetToApply = presets[presetName];
-
- if (presetToApply === undefined) {
- showErrorNotification(`Preset ${presetName} not found`);
- return;
- }
-
- const layout = await JSONData.getLayout(layoutName);
-
- qsr("#wordIncludeInput").setValue(
- presetToApply
- .getIncludeString(layout)
- .map((x) => x[0])
- .join(" "),
- );
-
- if (presetToApply.exactMatch === true) {
- setExactMatchInput(true);
- } else {
- setExactMatchInput(false);
- if (presetToApply.getExcludeString !== undefined) {
- qsr("#wordExcludeInput").setValue(
- presetToApply
- .getExcludeString(layout)
- .map((x) => x[0])
- .join(" "),
- );
- }
- }
- });
-
- exactMatchCheckbox?.on("change", () => {
- setExactMatchInput(exactMatchCheckbox.isChecked() as boolean);
- });
-
- modalEl.qsr("button.addButton").on("click", () => {
- modalEl.qs(".loadingIndicator")?.show();
- disableButtons();
- setTimeout(() => {
- void apply(false);
- }, 0);
- });
-
- modalEl.qsr("button.setButton").on("click", () => {
- modalEl.qs(".loadingIndicator")?.show();
- disableButtons();
- setTimeout(() => {
- void apply(true);
- }, 0);
- });
-}
-
-async function cleanup(): Promise {
- languageSelect?.destroy();
- layoutSelect?.destroy();
- presetSelect?.destroy();
- languageSelect = undefined;
- layoutSelect = undefined;
- presetSelect = undefined;
-}
-
-type OutgoingData = {
- text: string;
- set: boolean;
-};
-
-const modal = new AnimatedModal({
- dialogId: "wordFilterModal",
- setup,
- cleanup,
-});
diff --git a/frontend/src/ts/states/modals.ts b/frontend/src/ts/states/modals.ts
index ffada9e73b32..42ac0e8b9f52 100644
--- a/frontend/src/ts/states/modals.ts
+++ b/frontend/src/ts/states/modals.ts
@@ -9,7 +9,17 @@ export type ModalId =
| "DevInboxPicker"
| "RegisterCaptcha"
| "Alerts"
- | "SimpleModal";
+ | "SimpleModal"
+ | "CustomText"
+ | "SaveCustomText"
+ | "SavedTexts"
+ | "WordFilter"
+ | "CustomGenerator"
+ | "QuoteSearch"
+ | "QuoteRate"
+ | "QuoteReport"
+ | "QuoteSubmit"
+ | "QuoteApprove";
export type ModalVisibility = {
visible: boolean;
diff --git a/frontend/src/ts/states/quote-rate.ts b/frontend/src/ts/states/quote-rate.ts
new file mode 100644
index 000000000000..7e3275cbad94
--- /dev/null
+++ b/frontend/src/ts/states/quote-rate.ts
@@ -0,0 +1,76 @@
+import { createSignal } from "solid-js";
+import { Language } from "@monkeytype/schemas/languages";
+import Ape from "../ape";
+import { Quote } from "../controllers/quotes-controller";
+import { showErrorNotification } from "./notifications";
+import { showModal } from "./modals";
+import { isSafeNumber } from "@monkeytype/util/numbers";
+
+type QuoteStats = {
+ average?: number;
+ ratings?: number;
+ totalRating?: number;
+ quoteId?: number;
+ language?: Language;
+};
+
+const [currentQuote, setCurrentQuote] = createSignal(null);
+const [quoteStats, setQuoteStats] = createSignal<
+ QuoteStats | null | Record
+>(null);
+
+export { currentQuote, quoteStats };
+
+export function clearQuoteStats(): void {
+ setQuoteStats(null);
+}
+
+export function getRatingAverage(stats: QuoteStats): number {
+ if (
+ isSafeNumber(stats.ratings) &&
+ isSafeNumber(stats.totalRating) &&
+ stats.ratings > 0 &&
+ stats.totalRating > 0
+ ) {
+ return Math.round((stats.totalRating / stats.ratings) * 10) / 10;
+ }
+ return 0;
+}
+
+export async function getQuoteStats(
+ quote?: Quote,
+): Promise {
+ if (!quote) return;
+
+ setCurrentQuote(quote);
+ const response = await Ape.quotes.getRating({
+ query: { quoteId: quote.id, language: quote.language },
+ });
+
+ if (response.status !== 200) {
+ showErrorNotification("Failed to get quote ratings", { response });
+ return;
+ }
+
+ if (response.body.data === null) {
+ setQuoteStats({});
+ return {} as QuoteStats;
+ }
+
+ const stats = response.body.data as QuoteStats;
+ if (stats !== undefined && stats.average === undefined) {
+ stats.average = getRatingAverage(stats);
+ }
+
+ setQuoteStats(stats);
+ return stats;
+}
+
+export function updateQuoteStats(stats: QuoteStats): void {
+ setQuoteStats(stats);
+}
+
+export function showQuoteRateModal(quote: Quote): void {
+ setCurrentQuote(quote);
+ showModal("QuoteRate");
+}
diff --git a/frontend/src/ts/states/quote-report.ts b/frontend/src/ts/states/quote-report.ts
new file mode 100644
index 000000000000..6c9091b0df95
--- /dev/null
+++ b/frontend/src/ts/states/quote-report.ts
@@ -0,0 +1,19 @@
+import { createSignal } from "solid-js";
+import { isCaptchaAvailable } from "../controllers/captcha-controller";
+import { showModal } from "./modals";
+import { showErrorNotification } from "./notifications";
+
+const [quoteId, setQuoteId] = createSignal(0);
+
+export { quoteId };
+
+export function showQuoteReportModal(id: number): void {
+ if (!isCaptchaAvailable()) {
+ showErrorNotification(
+ "Captcha is not available. Please refresh the page or contact support if this issue persists.",
+ );
+ return;
+ }
+ setQuoteId(id);
+ showModal("QuoteReport");
+}
diff --git a/frontend/src/ts/states/test.ts b/frontend/src/ts/states/test.ts
new file mode 100644
index 000000000000..1edc2ac0b853
--- /dev/null
+++ b/frontend/src/ts/states/test.ts
@@ -0,0 +1,5 @@
+import { Challenge } from "@monkeytype/schemas/challenges";
+import { createSignal } from "solid-js";
+
+export const [getLoadedChallenge, setLoadedChallenge] =
+ createSignal(null);
diff --git a/frontend/src/ts/test/result.ts b/frontend/src/ts/test/result.ts
index e258a4337e65..bf43b7f27262 100644
--- a/frontend/src/ts/test/result.ts
+++ b/frontend/src/ts/test/result.ts
@@ -16,7 +16,7 @@ import {
addNotificationWithLevel,
} from "../states/notifications";
import { isAuthenticated } from "../firebase";
-import * as quoteRateModal from "../modals/quote-rate";
+import { getQuoteStats } from "../states/quote-rate";
import * as GlarsesMode from "../legacy-states/glarses-mode";
import * as SlowTimer from "../legacy-states/slow-timer";
import * as DateTime from "../utils/date-and-time";
@@ -902,8 +902,7 @@ export function updateRateQuote(randomQuote: Quote | null): void {
?.removeClass("far")
?.addClass("fas");
}
- quoteRateModal
- .getQuoteStats(randomQuote)
+ getQuoteStats(randomQuote)
.then((quoteStats) => {
qs(".pageTest #result #rateQuoteButton .rating")?.setText(
quoteStats?.average?.toFixed(1) ?? "",
diff --git a/frontend/src/ts/test/test-logic.ts b/frontend/src/ts/test/test-logic.ts
index 22eaf83e5f20..3ac6d4394b51 100644
--- a/frontend/src/ts/test/test-logic.ts
+++ b/frontend/src/ts/test/test-logic.ts
@@ -24,7 +24,7 @@ import * as DB from "../db";
import * as Replay from "./replay";
import * as TodayTracker from "./today-tracker";
import * as ChallengeContoller from "../controllers/challenge-controller";
-import * as QuoteRateModal from "../modals/quote-rate";
+import { clearQuoteStats } from "../states/quote-rate";
import * as Result from "./result";
import { getActivePage, restartTestEvent } from "../states/core";
import * as TestInput from "./test-input";
@@ -301,7 +301,7 @@ export function restart(options = {} as RestartOptions): void {
Caret.resetPosition();
PaceCaret.reset();
TestInput.input.setKoreanStatus(false);
- QuoteRateModal.clearQuoteStats();
+ clearQuoteStats();
CompositionState.setComposing(false);
CompositionState.setData("");
diff --git a/frontend/src/ts/test/test-state.ts b/frontend/src/ts/test/test-state.ts
index c7c91884399f..436b10780496 100644
--- a/frontend/src/ts/test/test-state.ts
+++ b/frontend/src/ts/test/test-state.ts
@@ -1,10 +1,8 @@
-import { Challenge } from "@monkeytype/schemas/challenges";
import { promiseWithResolvers } from "../utils/misc";
export let isRepeated = false;
export let isPaceRepeat = false;
export let isActive = false;
-export let activeChallenge: null | Challenge = null;
export let bailedOut = false;
export let selectedQuoteId = 1;
export let activeWordIndex = 0;
@@ -26,10 +24,6 @@ export function setActive(tf: boolean): void {
isActive = tf;
}
-export function setActiveChallenge(val: null | Challenge): void {
- activeChallenge = val;
-}
-
export function setBailedOut(tf: boolean): void {
bailedOut = tf;
}