Edit result tags
diff --git a/frontend/src/styles/popups.scss b/frontend/src/styles/popups.scss
index b205e273e190..f0fa36a4f34c 100644
--- a/frontend/src/styles/popups.scss
+++ b/frontend/src/styles/popups.scss
@@ -302,41 +302,3 @@ body.darkMode {
}
}
}
-
-#streakHourOffsetModal {
- .modal {
- max-width: 500px;
- .red {
- color: var(--error-color);
- }
- .group {
- display: grid;
- grid-template-columns: 1fr 1fr 1fr;
- gap: 0.5rem;
- justify-items: center;
- align-items: center;
- font-size: 2em;
- button {
- width: 100%;
- }
- input {
- text-align: center;
- }
- }
- .preview {
- & > div:first-child {
- margin-bottom: 1rem;
- }
- .row {
- display: flex;
- gap: 1rem;
- }
- div:first-child {
- flex-grow: 1;
- }
- div:last-child {
- align-self: center;
- }
- }
- }
-}
diff --git a/frontend/src/ts/commandline/lists/font-family.ts b/frontend/src/ts/commandline/lists/font-family.ts
index 37ef6c1628c1..98cf96e981a1 100644
--- a/frontend/src/ts/commandline/lists/font-family.ts
+++ b/frontend/src/ts/commandline/lists/font-family.ts
@@ -1,4 +1,5 @@
-import { Command } from "../types";
+import { FontNameSchema } from "@monkeytype/schemas/fonts";
+import { Command, withValidation } from "../types";
import { buildCommandForConfigKey } from "../util";
import FileStorage from "../../utils/file-storage";
@@ -6,6 +7,7 @@ import { showNoticeNotification } from "../../states/notifications";
import { Config } from "../../config/store";
import { setConfig } from "../../config/setters";
import { applyFontFamily } from "../../ui";
+
const fromMeta = buildCommandForConfigKey("fontFamily");
if (fromMeta.subgroup) {
@@ -17,12 +19,15 @@ if (fromMeta.subgroup) {
subgroup: {
title: "Custom font...",
list: [
- {
+ withValidation({
id: "customFontName",
display: "Custom name...",
icon: "fa-font",
alias: "custom font name",
input: true,
+ validation: {
+ schema: FontNameSchema,
+ },
defaultValue: (): string => {
return Config.fontFamily.replace(/_/g, " ");
},
@@ -31,7 +36,7 @@ if (fromMeta.subgroup) {
const fontName = input.replaceAll(/ /g, "_");
setConfig("fontFamily", fontName);
},
- },
+ }),
{
id: "customLocalFont",
display: "Local font...",
diff --git a/frontend/src/ts/components/modals/Modals.tsx b/frontend/src/ts/components/modals/Modals.tsx
index 632e0cc13172..81b34905e493 100644
--- a/frontend/src/ts/components/modals/Modals.tsx
+++ b/frontend/src/ts/components/modals/Modals.tsx
@@ -16,6 +16,7 @@ import { QuoteSearchModal } from "./QuoteSearchModal";
import { RegisterCaptchaModal } from "./RegisterCaptchaModal";
import { ShareTestSettings } from "./ShareTestSettings";
import { SimpleModal } from "./SimpleModal";
+import { StreakHourOffsetModal } from "./StreakHourOffsetModal";
import { SupportModal } from "./SupportModal";
import { VersionHistoryModal } from "./VersionHistoryModal";
@@ -40,6 +41,7 @@ export function Modals(): JSXElement {
+
>
);
}
diff --git a/frontend/src/ts/components/modals/StreakHourOffsetModal.tsx b/frontend/src/ts/components/modals/StreakHourOffsetModal.tsx
new file mode 100644
index 000000000000..8bace5e0809d
--- /dev/null
+++ b/frontend/src/ts/components/modals/StreakHourOffsetModal.tsx
@@ -0,0 +1,174 @@
+import { StreakHourOffsetSchema } from "@monkeytype/schemas/users";
+import { createForm } from "@tanstack/solid-form";
+
+import Ape from "../../ape";
+import { Snapshot } from "../../constants/default-snapshot";
+import { getSnapshot, setSnapshot } from "../../db";
+import { hideLoaderBar, showLoaderBar } from "../../states/loader-bar";
+import { hideModal } from "../../states/modals";
+import {
+ showErrorNotification,
+ showNoticeNotification,
+ showSuccessNotification,
+} from "../../states/notifications";
+import { AnimatedModal } from "../common/AnimatedModal";
+import { Button } from "../common/Button";
+import { InputField } from "../ui/form/InputField";
+import { SubmitButton } from "../ui/form/SubmitButton";
+import { allFieldsMandatory, fromSchema } from "../ui/form/utils";
+
+export function StreakHourOffsetModal() {
+ const form = createForm(() => ({
+ defaultValues: {
+ offset: "0.0",
+ },
+ onSubmitInvalid: () => {
+ showNoticeNotification("Please fill in all fields");
+ },
+ validators: {
+ onChange: allFieldsMandatory(),
+ },
+ onSubmit: async ({ value }) => {
+ const { offset } = value;
+ const hourOffset = parseFloat(offset);
+
+ showLoaderBar();
+
+ const response = await Ape.users.setStreakHourOffset({
+ body: { hourOffset },
+ });
+ hideLoaderBar();
+
+ if (response.status !== 200) {
+ showErrorNotification("Failed to set streak hour offset", { response });
+ hideModal("StreakHourOffset");
+ return;
+ }
+ showSuccessNotification("Streak hour offset set");
+ const snap = getSnapshot() as Snapshot;
+
+ snap.streakHourOffset = hourOffset;
+ setSnapshot(snap);
+ hideModal("StreakHourOffset");
+ },
+ }));
+
+ return (
+
+
+ Streaks reset at midnight UTC by default. If this is not convenient for
+ you (for example if it means that streaks reset in the middle of the
+ day), you can change the hour offset here.
+
+
+ This will not take daylight savings time into consideration!
+
+
+ You can only do this once!
+
+
+
+ );
+}
+
+function clampOffset(value: string): number {
+ const num = Number.parseFloat(value);
+ if (isNaN(num)) return 0;
+ return Math.max(-11, Math.min(12, num));
+}
+
+function getNewDate(offset: string): string {
+ const inputValue = Number.parseFloat(offset);
+ if (isNaN(inputValue)) return "-";
+ const newDate = new Date();
+ newDate.setUTCHours(0);
+ newDate.setUTCMinutes(0);
+ newDate.setUTCSeconds(0);
+ newDate.setUTCMilliseconds(0);
+
+ newDate.setHours(newDate.getHours() - -1 * Math.floor(inputValue)); //idk why, but it only works when i subtract (so i have to negate inputValue)
+ newDate.setMinutes(
+ newDate.getMinutes() - -1 * ((((inputValue % 1) + 1) % 1) * 60),
+ );
+ return newDate.toLocaleTimeString();
+}
+
+function getDate(): string {
+ const date = new Date();
+ date.setUTCHours(0, 0, 0, 0);
+ return date.toLocaleTimeString();
+}
diff --git a/frontend/src/ts/components/modals/WordFilterModal.tsx b/frontend/src/ts/components/modals/WordFilterModal.tsx
index 01a041682c14..a16dc8cf63b5 100644
--- a/frontend/src/ts/components/modals/WordFilterModal.tsx
+++ b/frontend/src/ts/components/modals/WordFilterModal.tsx
@@ -3,10 +3,17 @@ 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 {
+ createMemo,
+ createResource,
+ createSignal,
+ JSXElement,
+ Setter,
+} from "solid-js";
import { LanguageList } from "../../constants/languages";
import { LayoutsList } from "../../constants/layouts";
+import { createDebouncedSignal } from "../../hooks/createDebouncedSignal";
import { hideLoaderBar, showLoaderBar } from "../../states/loader-bar";
import { hideModal } from "../../states/modals";
import {
@@ -104,6 +111,74 @@ const presetOptions = Object.entries(presets).map(([id, preset]) => ({
text: preset.display,
}));
+type FilterFormValues = {
+ include: string;
+ exclude: string;
+ minLength: string;
+ maxLength: string;
+ regex: string;
+ exactMatch: boolean;
+};
+
+type FilterResult = { words: string[] } | { error: string };
+
+function filterWordList(
+ value: FilterFormValues,
+ words: string[],
+): FilterResult {
+ const exactMatchOnly = value.exactMatch;
+
+ // Source - https://stackoverflow.com/a/874742
+ // Retrieved 2026-06-23, License - CC BY-SA 3.0
+ // Separates string into regex expression
+ let reglit = new RegExp("");
+ try {
+ const flags = value.regex.replace(/.*\/([gimy]*)$/, "$1");
+ const pattern = value.regex.replace(new RegExp(`^/(.*?)/${flags}$`), "$1");
+ reglit = new RegExp(pattern, flags);
+ } catch (error) {
+ if (error instanceof SyntaxError) {
+ return { error: "Invaid Regex Expression" };
+ }
+ return { error: String(error) };
+ }
+
+ let filterin = Misc.escapeRegExp(value.include.trim());
+ filterin = filterin.replace(/\s+/gi, "|");
+
+ if (exactMatchOnly && filterin === "") {
+ return { error: "Include field is required for exact match" };
+ }
+ 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 max = value.maxLength === "" ? 999 : parseInt(value.maxLength);
+ const min = value.minLength === "" ? 1 : parseInt(value.minLength);
+
+ const filteredWords: string[] = [];
+ for (const word of words) {
+ const testincl = regincl.test(word);
+ const testexcl =
+ exactMatchOnly || filterout === "" ? false : regexcl.test(word);
+ const testlit = exactMatchOnly ? true : reglit.test(word);
+ if (
+ testincl &&
+ !testexcl &&
+ testlit &&
+ word.length <= max &&
+ word.length >= min
+ ) {
+ filteredWords.push(word);
+ }
+ }
+ return { words: filteredWords };
+}
+
export function WordFilterModal(props: {
setChainedData: Setter
;
}): JSXElement {
@@ -120,29 +195,13 @@ export function WordFilterModal(props: {
exclude: "",
minLength: "",
maxLength: "",
+ regex: "",
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),
);
@@ -152,28 +211,18 @@ export function WordFilterModal(props: {
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);
- }
+ const result = filterWordList(value, languageWordList.words);
+ if ("error" in result) {
+ showNoticeNotification(result.error);
+ return;
}
- if (filteredWords.length === 0) {
+ if (result.words.length === 0) {
showNoticeNotification("No words found");
return;
}
props.setChainedData({
- splitText: filteredWords,
+ splitText: result.words,
set: submitAction === "set",
});
hideModal("WordFilter");
@@ -186,6 +235,22 @@ export function WordFilterModal(props: {
const isExactMatch = form.useStore((s) => s.values.exactMatch);
+ const [languageWords] = createResource(language, async (lang) => {
+ const { data } = await tryCatch(JSONData.getLanguage(lang as Language));
+ return data?.words ?? null;
+ });
+
+ const formValues = form.useStore((s) => s.values);
+
+ // debounce the preview so it doesn't refilter the whole word list on every keystroke
+ const debouncedValues = createDebouncedSignal(formValues, 250);
+
+ const matchResult = createMemo(() => {
+ const words = languageWords();
+ if (words === null || words === undefined) return null;
+ return filterWordList(debouncedValues(), words);
+ });
+
const applyPreset = async () => {
const presetToApply = presets[preset()];
if (presetToApply === undefined) {
@@ -205,6 +270,7 @@ export function WordFilterModal(props: {
if (presetToApply.exactMatch === true) {
form.setFieldValue("exactMatch", true);
form.setFieldValue("exclude", "");
+ form.setFieldValue("regex", "");
} else {
form.setFieldValue("exactMatch", false);
if (presetToApply.getExcludeString !== undefined) {
@@ -239,9 +305,9 @@ export function WordFilterModal(props: {
/>
- You can manually filter words by length, words or characters
- (separated by spaces) on the left side. On the right side you can
- generate filters based on a preset and selected layout.
+ You can manually filter words by length, regular expressions, words,
+ or characters (separated by spaces) on the left side. On the right
+ side you can generate filters based on a preset and selected layout.
@@ -258,6 +324,15 @@ export function WordFilterModal(props: {
+
+
+
+ {(field) => (
+
+ )}
+
+
+
{(field) => }
@@ -268,6 +343,7 @@ export function WordFilterModal(props: {
onChange: ({ value }) => {
if (value) {
form.setFieldValue("exclude", "");
+ form.setFieldValue("regex", "");
}
return undefined;
},
@@ -321,7 +397,14 @@ export function WordFilterModal(props: {
/>
-
+
+ {(() => {
+ const result = matchResult();
+ if (result === null) return "loading words...";
+ if ("error" in result) return result.error;
+ return `${result.words.length} words found`;
+ })()}
+
{
'"Set" replaces the current custom word list with the filter result, "Add" appends the filter result to the current custom word list.'
diff --git a/frontend/src/ts/components/pages/account-settings/AccountTab.tsx b/frontend/src/ts/components/pages/account-settings/AccountTab.tsx
index 40436b03386b..99e8ee1350a9 100644
--- a/frontend/src/ts/components/pages/account-settings/AccountTab.tsx
+++ b/frontend/src/ts/components/pages/account-settings/AccountTab.tsx
@@ -1,8 +1,8 @@
import { Show } from "solid-js";
import Ape from "../../../ape";
-import * as StreakHourOffsetModal from "../../../modals/streak-hour-offset";
import { showLoaderBar } from "../../../states/loader-bar";
+import { showModal } from "../../../states/modals";
import { showErrorNotification } from "../../../states/notifications";
import { getSnapshot } from "../../../states/snapshot";
import { Button } from "../../common/Button";
@@ -126,7 +126,7 @@ function UpdateStreakOffset() {
>
button={{
text: "update hour offset",
- onClick: () => StreakHourOffsetModal.show(),
+ onClick: () => showModal("StreakHourOffset"),
}}
disabled={getSnapshot()?.streakHourOffset !== undefined}
disabledDescription=<>
diff --git a/frontend/src/ts/components/pages/settings/custom-setting/FontFamily.tsx b/frontend/src/ts/components/pages/settings/custom-setting/FontFamily.tsx
index f3d789623ee2..122ed1d47a86 100644
--- a/frontend/src/ts/components/pages/settings/custom-setting/FontFamily.tsx
+++ b/frontend/src/ts/components/pages/settings/custom-setting/FontFamily.tsx
@@ -1,4 +1,5 @@
import { ConfigSchema } from "@monkeytype/schemas/configs";
+import { FontNameSchema } from "@monkeytype/schemas/fonts";
import { createResource, For, JSXElement, Show } from "solid-js";
import { z } from "zod";
@@ -170,7 +171,7 @@ export function FontFamily(): JSXElement {
text: "Make sure you have the font installed on your computer before applying",
buttonText: "apply",
schema: z.object({
- fontName: z.string(),
+ fontName: FontNameSchema,
}),
inputs: {
fontName: {
diff --git a/frontend/src/ts/components/ui/form/FieldIndicator.tsx b/frontend/src/ts/components/ui/form/FieldIndicator.tsx
index 0ec357983036..a0d831054b9f 100644
--- a/frontend/src/ts/components/ui/form/FieldIndicator.tsx
+++ b/frontend/src/ts/components/ui/form/FieldIndicator.tsx
@@ -8,6 +8,7 @@ import { LoadingCircle } from "../../common/LoadingCircle";
export type FieldIndicatorProps = {
field: AnyFieldApi;
+ alwaysShow?: boolean;
};
export function FieldIndicator(props: FieldIndicatorProps) {
@@ -45,9 +46,10 @@ export function FieldIndicator(props: FieldIndicatorProps) {
diff --git a/frontend/src/ts/components/ui/form/InputField.tsx b/frontend/src/ts/components/ui/form/InputField.tsx
index 78c995343edf..cb72a3f5268f 100644
--- a/frontend/src/ts/components/ui/form/InputField.tsx
+++ b/frontend/src/ts/components/ui/form/InputField.tsx
@@ -27,6 +27,7 @@ export function InputField(props: {
min?: number;
max?: number;
step?: string | number;
+ alwaysShowFieldIndicator?: boolean;
}): JSXElement {
const [shake, setShake] = createSignal(false);
@@ -105,7 +106,10 @@ export function InputField(props: {
step={props.step?.toString()}
/>
-
+
);
diff --git a/frontend/src/ts/constants/languages.ts b/frontend/src/ts/constants/languages.ts
index 86b4d39ee3b5..5d4239379651 100644
--- a/frontend/src/ts/constants/languages.ts
+++ b/frontend/src/ts/constants/languages.ts
@@ -387,6 +387,7 @@ export const LanguageGroups: Record