diff --git a/frontend/__tests__/components/common/AnimatedModal.spec.tsx b/frontend/__tests__/components/common/AnimatedModal.spec.tsx index b57934428ace..6b6d3540fbb1 100644 --- a/frontend/__tests__/components/common/AnimatedModal.spec.tsx +++ b/frontend/__tests__/components/common/AnimatedModal.spec.tsx @@ -47,7 +47,6 @@ describe("AnimatedModal", () => { const { dialog } = renderModal({}); expect(dialog).toHaveAttribute("id", "SupportModal"); - expect(dialog).toHaveClass("hidden"); }); it("renders children inside modal div", () => { diff --git a/frontend/src/html/pages/settings.html b/frontend/src/html/pages/settings.html deleted file mode 100644 index c5dedf55751f..000000000000 --- a/frontend/src/html/pages/settings.html +++ /dev/null @@ -1,1962 +0,0 @@ - diff --git a/frontend/src/html/popups.html b/frontend/src/html/popups.html index 0461edfa0343..06f85c47192d 100644 --- a/frontend/src/html/popups.html +++ b/frontend/src/html/popups.html @@ -49,82 +49,6 @@ - - + + + + ); +} + +function SettingsSection(props: { + title: string; + description: string | JSXElement; + checked: boolean; + disabled?: boolean; + hideCheckbox?: boolean; + onChange?: (checked: boolean) => void; +}): JSXElement { + return ( + + ); +} diff --git a/frontend/src/ts/components/modals/Modals.tsx b/frontend/src/ts/components/modals/Modals.tsx index 08503c2ba401..44de2d887a34 100644 --- a/frontend/src/ts/components/modals/Modals.tsx +++ b/frontend/src/ts/components/modals/Modals.tsx @@ -1,10 +1,13 @@ import { JSXElement } from "solid-js"; import { ContactModal } from "./ContactModal"; +import { CookiesModal } from "./CookiesModal"; import { CustomTestDurationModal } from "./CustomTestDurationModal"; import { CustomTextModal } from "./CustomTextModal"; import { CustomWordAmountModal } from "./CustomWordAmountModal"; import { MobileTestConfigModal } from "./MobileTestConfigModal"; +import { AddPresetModal } from "./preset/AddPresetModal"; +import { EditPresetModal } from "./preset/EditPresetModal"; import { QuoteRateModal } from "./QuoteRateModal"; import { QuoteReportModal } from "./QuoteReportModal"; import { QuoteSearchModal } from "./QuoteSearchModal"; @@ -30,6 +33,9 @@ export function Modals(): JSXElement { + + + ); } diff --git a/frontend/src/ts/components/modals/SavedTextsModal.tsx b/frontend/src/ts/components/modals/SavedTextsModal.tsx index 230bbb1c2a4c..c8baed8a7142 100644 --- a/frontend/src/ts/components/modals/SavedTextsModal.tsx +++ b/frontend/src/ts/components/modals/SavedTextsModal.tsx @@ -156,7 +156,7 @@ export function SavedTextsModal(props: { -
+
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/SimpleModal.tsx b/frontend/src/ts/components/modals/SimpleModal.tsx index cd53d9a62432..2c2666dfb0fb 100644 --- a/frontend/src/ts/components/modals/SimpleModal.tsx +++ b/frontend/src/ts/components/modals/SimpleModal.tsx @@ -116,6 +116,17 @@ function FieldInput(props: { type={props.input.type} placeholder={props.input.placeholder} disabled={props.input.disabled} + readOnly={ + props.input.type === "text" + ? (props.input as { readOnly?: boolean }).readOnly + : undefined + } + clickToSelect={ + props.input.type === "text" + ? (props.input as { clickToSelect?: boolean }).clickToSelect + : undefined + } + class={props.input.class} autocomplete="off" /> } @@ -125,19 +136,26 @@ function FieldInput(props: { field={props.field} label={(props.input as { label: string }).label} disabled={props.input.disabled} + class={props.input.class} /> @@ -145,7 +163,11 @@ function FieldInput(props: {
{(text) => (
diff --git a/frontend/src/ts/components/modals/preset/AddPresetModal.tsx b/frontend/src/ts/components/modals/preset/AddPresetModal.tsx new file mode 100644 index 000000000000..bd8181e5971d --- /dev/null +++ b/frontend/src/ts/components/modals/preset/AddPresetModal.tsx @@ -0,0 +1,151 @@ +import { + ConfigGroupName, + ConfigGroupNameSchema, +} from "@monkeytype/schemas/configs"; +import { PresetNameSchema, PresetType } from "@monkeytype/schemas/presets"; +import { createForm } from "@tanstack/solid-form"; +import { createSignal, JSXElement } from "solid-js"; + +import { addPreset } from "../../../collections/presets"; +import { createEffectOn } from "../../../hooks/effects"; +import { hideLoaderBar, showLoaderBar } from "../../../states/loader-bar"; +import { hideModal, isModalOpen } from "../../../states/modals"; +import { + showErrorNotification, + showNoticeNotification, + showSuccessNotification, +} from "../../../states/notifications"; +import { normalizeName } from "../../../utils/strings"; +import { AnimatedModal } from "../../common/AnimatedModal"; +import { Checkbox } from "../../ui/form/Checkbox"; +import { InputField } from "../../ui/form/InputField"; +import { SubmitButton } from "../../ui/form/SubmitButton"; +import { allFieldsMandatory, fromSchema } from "../../ui/form/utils"; +import { FullOrPartial } from "./FullOrPartial"; +import { + getActiveSettingGroups, + getCheckboxes, + getConfigChanges, +} from "./preset-modal-utils"; + +export function AddPresetModal(): JSXElement { + const [presetType, setPresetType] = createSignal("full"); + + const form = createForm(() => ({ + defaultValues: { + presetName: "", + ...Object.fromEntries( + ConfigGroupNameSchema.options.map((key) => [key, true]), + ), + } as { presetName: string } & Record, + /* + validators: { + onChange: ({ value }) => { + if ( + presetType() === "partial" && + Object.values(getCheckboxes(value)).every((v) => !v) + ) { + return "At least one setting group must be active while saving partial presets"; + } + return undefined; + }, + },*/ + validators: { + onChange: allFieldsMandatory(), + }, + onSubmit: async ({ value }) => { + const parsedName = PresetNameSchema.safeParse( + normalizeName(value.presetName), + ); + if (!parsedName.success) { + showNoticeNotification("Preset name is not valid"); + return; + } + + const checkboxes = getCheckboxes(value); + + //obsolete if we add form level validation + if ( + presetType() === "partial" && + Object.values(checkboxes).every((v) => !v) + ) { + showNoticeNotification( + "At least one setting group must be active while saving partial presets", + ); + return; + } + + const configChanges = getConfigChanges(presetType(), checkboxes); + const activeSettingGroups = getActiveSettingGroups(checkboxes); + + hideModal("AddPresetModal"); + showLoaderBar(); + + try { + await addPreset({ + name: parsedName.data, + config: configChanges, + settingGroups: + presetType() === "partial" ? activeSettingGroups : undefined, + }); + showSuccessNotification("Preset added", { durationMs: 2000 }); + } catch (e) { + showErrorNotification( + e instanceof Error ? e.message : "Failed to add preset", + ); + } + + hideLoaderBar(); + }, + })); + + createEffectOn( + () => isModalOpen("AddPresetModal"), + (open) => { + if (open) { + form.reset(); + setPresetType("full"); + } + }, + ); + + // const formErrorMap = form.useStore((state) => state.errorMap); + + return ( + +
{ + e.preventDefault(); + e.stopPropagation(); + void form.handleSubmit(); + }} + > + ( + + )} + /> + ( + + {(field) => } + + )} + /> + {/* + +
{formErrorMap().onChange}
+
+ */} + + +
+ ); +} diff --git a/frontend/src/ts/components/modals/preset/EditPresetModal.tsx b/frontend/src/ts/components/modals/preset/EditPresetModal.tsx new file mode 100644 index 000000000000..7a592f3321af --- /dev/null +++ b/frontend/src/ts/components/modals/preset/EditPresetModal.tsx @@ -0,0 +1,177 @@ +import { + ConfigGroupName, + ConfigGroupNameSchema, +} from "@monkeytype/schemas/configs"; +import { PresetNameSchema, PresetType } from "@monkeytype/schemas/presets"; +import { createForm } from "@tanstack/solid-form"; +import { createSignal, JSXElement, Show } from "solid-js"; + +import { + __nonReactive as __nonReactivePresets, + editPreset, +} from "../../../collections/presets"; +import { createEffectOn } from "../../../hooks/effects"; +import { editPresetData } from "../../../states/edit-preset-modal"; +import { hideLoaderBar, showLoaderBar } from "../../../states/loader-bar"; +import { hideModal, isModalOpen } from "../../../states/modals"; +import { + showErrorNotification, + showNoticeNotification, + showSuccessNotification, +} from "../../../states/notifications"; +import { normalizeName } from "../../../utils/strings"; +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"; +import { FullOrPartial } from "./FullOrPartial"; +import { + getActiveSettingGroups, + getCheckboxes, + getConfigChanges, +} from "./preset-modal-utils"; + +export function EditPresetModal(): JSXElement { + const [presetType, setPresetType] = createSignal("full"); + + const form = createForm(() => ({ + defaultValues: { + presetName: "", + updateConfig: false, + ...Object.fromEntries( + ConfigGroupNameSchema.options.map((key) => [key, true]), + ), + } as { presetName: string; updateConfig: boolean } & Record< + ConfigGroupName, + boolean + >, + onSubmit: async ({ value }) => { + const data = editPresetData(); + if (data === null) return; + + const parsedName = PresetNameSchema.safeParse( + normalizeName(value.presetName), + ); + if (!parsedName.success) { + showNoticeNotification("Preset name is not valid"); + return; + } + + const checkboxes = getCheckboxes(value); + if ( + value.updateConfig && + presetType() === "partial" && + Object.values(checkboxes).every((v) => !v) + ) { + showNoticeNotification( + "At least one setting group must be active while saving partial presets", + ); + return; + } + + hideModal("EditPresetModal"); + showLoaderBar(); + + try { + if (value.updateConfig) { + const configChanges = getConfigChanges(presetType(), checkboxes); + const activeSettingGroups: ConfigGroupName[] | null = + presetType() === "partial" + ? getActiveSettingGroups(checkboxes) + : null; + await editPreset({ + presetId: data.presetId, + name: parsedName.data, + config: configChanges, + settingGroups: activeSettingGroups, + }); + } else { + await editPreset({ + presetId: data.presetId, + name: parsedName.data, + }); + } + showSuccessNotification("Preset updated"); + } catch (e) { + showErrorNotification( + e instanceof Error ? e.message : "Failed to edit preset", + ); + } + + hideLoaderBar(); + }, + })); + + createEffectOn( + () => isModalOpen("EditPresetModal"), + (open) => { + if (!open) return; + const data = editPresetData(); + if (data === null) return; + + form.reset(); + form.setFieldValue("presetName", data.name); + + const preset = __nonReactivePresets.getPreset(data.presetId); + if (preset === undefined) return; + + if (preset.settingGroups === undefined || preset.settingGroups === null) { + setPresetType("full"); + for (const key of ConfigGroupNameSchema.options) { + form.setFieldValue(key, true); + } + } else { + setPresetType("partial"); + for (const key of ConfigGroupNameSchema.options) { + form.setFieldValue(key, preset.settingGroups.includes(key)); + } + } + }, + ); + + const isUpdateConfig = () => { + const formValues = form.useStore((s) => s.values); + return formValues().updateConfig; + }; + + return ( + +
{ + e.preventDefault(); + e.stopPropagation(); + void form.handleSubmit(); + }} + > + ( + + )} + /> + + {(field) => ( + + )} + + + ( + + {(field) => } + + )} + /> + + + +
+ ); +} diff --git a/frontend/src/ts/components/modals/preset/FullOrPartial.tsx b/frontend/src/ts/components/modals/preset/FullOrPartial.tsx new file mode 100644 index 000000000000..ca17286505b2 --- /dev/null +++ b/frontend/src/ts/components/modals/preset/FullOrPartial.tsx @@ -0,0 +1,38 @@ +import { ConfigGroupNameSchema } from "@monkeytype/schemas/configs"; +import { PresetType } from "@monkeytype/schemas/presets"; +import { For, JSXElement, Show } from "solid-js"; + +import { camelCaseToWords } from "../../../utils/strings"; +import { Button } from "../../common/Button"; + +export function FullOrPartial(props: { + type: PresetType; + onTypeChange: (type: PresetType) => void; + renderCheckbox: (group: string, label: string) => JSXElement; +}): JSXElement { + return ( +
+
preset type
+
+
+ +
partial groups
+
+ + {(group) => props.renderCheckbox(group, camelCaseToWords(group))} + +
+
+
+ ); +} diff --git a/frontend/src/ts/components/modals/preset/preset-modal-utils.ts b/frontend/src/ts/components/modals/preset/preset-modal-utils.ts new file mode 100644 index 000000000000..511540b1e871 --- /dev/null +++ b/frontend/src/ts/components/modals/preset/preset-modal-utils.ts @@ -0,0 +1,68 @@ +import { + ConfigGroupName, + ConfigGroupNameSchema, + ConfigKey, + Config as ConfigType, +} from "@monkeytype/schemas/configs"; +import { PresetType } from "@monkeytype/schemas/presets"; + +import { __nonReactive as __nonReactiveTags } from "../../../collections/tags"; +import { configMetadata } from "../../../config/metadata"; +import { getConfigChanges as getConfigChangesFromConfig } from "../../../config/utils"; +import { getDefaultConfig } from "../../../constants/default-config"; + +function getSettingGroup(configFieldName: ConfigKey): ConfigGroupName { + return configMetadata[configFieldName].group; +} + +function getPartialConfigChanges( + configChanges: Partial, + checkboxes: Record, +): Partial { + const activeConfigChanges: Partial = {}; + const defaultConfig = getDefaultConfig(); + + (Object.keys(defaultConfig) as ConfigKey[]) + .filter((settingName) => checkboxes[getSettingGroup(settingName)]) + .forEach((settingName) => { + const newValue = configChanges[settingName] ?? defaultConfig[settingName]; + // @ts-expect-error cant figure this one out, but it works + activeConfigChanges[settingName] = newValue; + }); + return activeConfigChanges; +} + +export function getActiveSettingGroups( + checkboxes: Record, +): ConfigGroupName[] { + return (Object.entries(checkboxes) as [ConfigGroupName, boolean][]) + .filter(([, value]) => value) + .map(([key]) => key); +} + +export function getConfigChanges( + presetType: PresetType, + checkboxes: Record, +): Partial { + const activeConfigChanges = + presetType === "partial" + ? getPartialConfigChanges(getConfigChangesFromConfig(), checkboxes) + : getConfigChangesFromConfig(); + const activeTagIds: string[] = __nonReactiveTags + .getActiveTags() + .map((tag) => tag._id); + + const setTags = presetType === "full" || checkboxes.behavior; + return { + ...activeConfigChanges, + ...(setTags && { tags: activeTagIds }), + }; +} + +export function getCheckboxes( + value: Record, +): Record { + return Object.fromEntries( + ConfigGroupNameSchema.options.map((key) => [key, value[key]]), + ) as Record; +} diff --git a/frontend/src/ts/components/mount.tsx b/frontend/src/ts/components/mount.tsx index 5d547416884c..b0ce72d8e261 100644 --- a/frontend/src/ts/components/mount.tsx +++ b/frontend/src/ts/components/mount.tsx @@ -18,12 +18,14 @@ import { LeaderboardPage } from "./pages/leaderboard/LeaderboardPage"; import { LoginPage } from "./pages/login/LoginPage"; import { ProfilePage } from "./pages/profile/ProfilePage"; import { ProfileSearchPage } from "./pages/profile/ProfileSearchPage"; +import { Settings } from "./pages/settings/Settings"; import { TestConfig } from "./pages/test/TestConfig"; import { Popups } from "./popups/Popups"; const components: Record JSXElement> = { footer: () =>
, aboutpage: () => , + settingspage: () => , accountpage: () => , loginpage: () => , leaderboardpage: () => , @@ -38,6 +40,7 @@ const components: Record JSXElement> = { devtools: () => , testconfig: () => , commandlinehotkey: () => , + solidSettings: () => , }; function mountToMountpoint(name: string, component: () => JSXElement): void { diff --git a/frontend/src/ts/components/pages/login/Register.tsx b/frontend/src/ts/components/pages/login/Register.tsx index 7a531b1e6123..3eedcc384a8c 100644 --- a/frontend/src/ts/components/pages/login/Register.tsx +++ b/frontend/src/ts/components/pages/login/Register.tsx @@ -174,10 +174,7 @@ export function Register(): JSXElement { { - void field.fieldApi.form.validateField("emailVerify", "change"); - return fromSchema(UserEmailSchema)(field); - }, + onChange: fromSchema(UserEmailSchema), onChangeAsyncDebounceMs: 0, onChangeAsync: async (field) => handleResult(field.fieldApi, await emailIsValid(field.value)), @@ -204,6 +201,7 @@ export function Register(): JSXElement { field.value === field.fieldApi.form.getFieldValue("email") ? undefined @@ -221,15 +219,9 @@ export function Register(): JSXElement { { - void field.fieldApi.form.validateField( - "passwordVerify", - "change", - ); - return fromSchema( - isDevEnvironment() ? z.string().min(6) : PasswordSchema, - )(field); - }, + onChange: fromSchema( + isDevEnvironment() ? z.string().min(6) : PasswordSchema, + ), }} children={(field) => ( field.value === field.fieldApi.form.getFieldValue("password") ? undefined diff --git a/frontend/src/ts/components/pages/settings/QuickNav.tsx b/frontend/src/ts/components/pages/settings/QuickNav.tsx new file mode 100644 index 000000000000..5a79c3bd0274 --- /dev/null +++ b/frontend/src/ts/components/pages/settings/QuickNav.tsx @@ -0,0 +1,92 @@ +import { JSXElement } from "solid-js"; + +import { cn } from "../../../utils/cn"; +import { Button } from "../../common/Button"; + +export function QuickNav(): JSXElement { + const buttonClass = "px-3 py-3"; + return ( +
+
+
+
+ ); +} diff --git a/frontend/src/ts/components/pages/settings/Setting.tsx b/frontend/src/ts/components/pages/settings/Setting.tsx new file mode 100644 index 000000000000..4de64430c3d5 --- /dev/null +++ b/frontend/src/ts/components/pages/settings/Setting.tsx @@ -0,0 +1,84 @@ +import { JSXElement, Show } from "solid-js"; +import { z } from "zod"; +import { serialize } from "zod-urlsearchparams"; + +import { + showErrorNotification, + showSuccessNotification, +} from "../../../states/notifications"; +import { cn } from "../../../utils/cn"; +import { Button } from "../../common/Button"; +import { FaProps } from "../../common/Fa"; +import { H3 } from "../../common/Headers"; + +type Props = { + key: string; + title: string; + fa: FaProps; + description: string | JSXElement; + inputs?: JSXElement; + fullWidthInputs?: JSXElement; +}; + +export function Setting(props: Props): JSXElement { + return ( +
+
+

+

+ +
+ +
{props.description}
+
+
{props.inputs}
+
+
+ {props.fullWidthInputs} +
+ ); +} diff --git a/frontend/src/ts/components/pages/settings/Settings.tsx b/frontend/src/ts/components/pages/settings/Settings.tsx new file mode 100644 index 000000000000..12e4f31c7097 --- /dev/null +++ b/frontend/src/ts/components/pages/settings/Settings.tsx @@ -0,0 +1,459 @@ +import { Config, ConfigSchema } from "@monkeytype/schemas/configs"; +import { createForm } from "@tanstack/solid-form"; +import { createResource, createSignal, For, JSXElement, Show } from "solid-js"; +import { z } from "zod"; + +import { resetConfig } from "../../../config/lifecycle"; +import { configMetadata, OptionMetadata } from "../../../config/metadata"; +import { setConfig } from "../../../config/setters"; +import { getConfig } from "../../../config/store"; +import { + playTimeWarning, + previewClick, + previewError, +} from "../../../controllers/sound-controller"; +import { useLocalStorage } from "../../../hooks/useLocalStorage"; +import { useSavedIndicator } from "../../../hooks/useSavedIndicator"; +import { isAuthenticated } from "../../../states/core"; +import { showModal } from "../../../states/modals"; +import { showSimpleModal } from "../../../states/simple-modal"; +// import { hotkeys } from "../../../states/hotkeys"; +import { cn } from "../../../utils/cn"; +import fileStorage from "../../../utils/file-storage"; +import { wordsToCamelCase } from "../../../utils/strings"; +// import { isFirefox } from "../../../utils/misc"; +import { getOptions } from "../../../utils/zod"; +import { Anime, AnimeShow } from "../../common/anime"; +import { Button } from "../../common/Button"; +import { Fa } from "../../common/Fa"; +import { CommandlineHotkey } from "../../hotkeys/CommandlineHotkey"; +import { InputField } from "../../ui/form/InputField"; +import { fromSchema } from "../../ui/form/utils"; +import { AnimationFpsLimit } from "./custom-setting/AnimationFpsLimit"; +import { AutoSwitchTheme } from "./custom-setting/AutoSwitchTheme"; +import { CustomBackground } from "./custom-setting/CustomBackground"; +import { CustomBackgroundFilters } from "./custom-setting/CustomBackgroundFilters"; +// import { Kbd } from "../../common/Kbd"; +import { CustomLayoutfluid } from "./custom-setting/CustomLayoutfluid"; +import { CustomPolyglot } from "./custom-setting/CustomPolyglot"; +import { FontFamily } from "./custom-setting/FontFamily"; +import { Funbox } from "./custom-setting/Funbox"; +import { ImportExport } from "./custom-setting/ImportExport"; +import { KeymapLayout } from "./custom-setting/KeymapLayout"; +import { KeymapSize } from "./custom-setting/KeymapSize"; +import { Language } from "./custom-setting/Language"; +import { Layout } from "./custom-setting/Layout"; +import { MaxLineWidth } from "./custom-setting/MaxLineWidth"; +import { MinAcc } from "./custom-setting/MinAcc"; +import { MinBurst } from "./custom-setting/MinBurst"; +import { MinSpeed } from "./custom-setting/MinSpeed"; +import { PaceCaret } from "./custom-setting/PaceCaret"; +import { Presets } from "./custom-setting/Presets"; +import { SoundVolume } from "./custom-setting/SoundVolume"; +import { Tags } from "./custom-setting/Tags"; +import { Theme } from "./custom-setting/Theme"; +import { QuickNav } from "./QuickNav"; +import { Setting } from "./Setting"; + +export function Settings(): JSXElement { + const [hasLocalBg] = createResource( + () => fileStorage.track("LocalBackgroundFile"), + async () => fileStorage.hasFile("LocalBackgroundFile"), + ); + + return ( +
+ + +
+ tip: You can also change all these settings quickly using the command + line +
( ) +
+
+ +
+
+ + + + + + + + + + + + + + + + + + + +
+
+ + + + + + + + + + + + +
+
+ + { + if (option === "off") return; + void previewClick(option); + }} + /> + { + if (option === "off") return; + void previewError(option); + }} + /> + { + if (option === "off") return; + void playTimeWarning(); + }} + /> +
+
+ + + + + +
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+ + + + + + + + + +
+
+ + + + +
+
+ + + { + showModal("Cookies"); + }} + > + open + + } + /> + + + Resets settings to the default (but doesn't touch your tags + and presets). +
+
You can't undo this!
+
+ } + fa={{ + icon: "fa-undo", + }} + inputs={ + + } + /> + +
+ + +
+ ); +} + +function AccountSettingsNotice(): JSXElement { + const [dismissed, setDismissed] = useLocalStorage({ + key: "accountSettingsMessageDismissed", + schema: z.boolean(), + fallback: false, + }); + return ( + +
+ +
+ Account settings have moved. You can now access them by hovering over + the account button in the top right corner, then clicking + "Account settings". +
+
+
+ ); +} + +function Section(props: { title: string; children: JSXElement }): JSXElement { + const [isOpen, setIsOpen] = createSignal(true); + + return ( +
+ + + {props.children} +
+
+
+ ); +} + +function AutoSetting(props: { + key: T; + inputs?: JSXElement; + wide?: boolean; + onOptionClick?: (value: Config[T]) => void; +}): JSXElement { + const savedIndicator = useSavedIndicator(); + + const form = createForm(() => ({ + defaultValues: { + [props.key]: getConfig[props.key], + }, + onSubmit: ({ value }) => { + const val = parseInt(String(value[props.key])); + if (val === getConfig[props.key]) return; + savedIndicator.flash(); + setConfig(props.key, val as Config[T]); + }, + })); + + const autoInputs = () => { + if ( + ConfigSchema.shape[props.key]._def.typeName === + z.ZodFirstPartyTypeKind.ZodNumber + ) { + return ( +
+
{ + e.preventDefault(); + e.stopPropagation(); + void form.handleSubmit(); + }} + > + { + const val = parseInt(String(value)); + if (isNaN(val)) { + return "Must be a number"; + } + return fromSchema( + ConfigSchema.shape[props.key] as z.ZodNumber, + )({ + value: val, + }); + }, + onBlur: () => { + void form.handleSubmit(); + }, + }} + children={(field) => ( +
+ + +
+ )} + /> + +
+ ); + } + + const options = getOptions(ConfigSchema.shape[props.key])?.filter((opt) => { + const optionsMeta = ( + configMetadata[props.key] as { + optionsMetadata?: Record | undefined; + } + ).optionsMetadata; + + const optionMeta = optionsMeta?.[String(opt)]; + + return optionMeta?.visible !== false; + }); + + if (options !== undefined) { + return ( +
+ + {(option) => { + const text = () => { + const optionsMeta = configMetadata[props.key] + .optionsMetadata as + | Record + | undefined; + const match = optionsMeta?.[String(option)]; + if (match?.displayString !== undefined) { + return match.displayString; + } + + if (option === true) { + return "on"; + } + if (option === false) { + return "off"; + } + + return (option as string).toString().replace(/_/g, " "); + }; + return ( + + ); + }} + +
+ ); + } + return undefined; + }; + + return ( + + ); +} diff --git a/frontend/src/ts/components/pages/settings/custom-setting/AnimationFpsLimit.tsx b/frontend/src/ts/components/pages/settings/custom-setting/AnimationFpsLimit.tsx new file mode 100644 index 000000000000..b2425367441e --- /dev/null +++ b/frontend/src/ts/components/pages/settings/custom-setting/AnimationFpsLimit.tsx @@ -0,0 +1,86 @@ +import { createForm } from "@tanstack/solid-form"; +import { JSXElement } from "solid-js"; + +import { fpsLimitSchema, getfpsLimit, setfpsLimit } from "../../../../anim"; +import { useSavedIndicator } from "../../../../hooks/useSavedIndicator"; +import { Button } from "../../../common/Button"; +import { Separator } from "../../../common/Separator"; +import { InputField } from "../../../ui/form/InputField"; +import { fromSchema } from "../../../ui/form/utils"; +import { Setting } from "../Setting"; + +export function AnimationFpsLimit(): JSXElement { + const savedIndicator = useSavedIndicator(); + const form = createForm(() => ({ + defaultValues: { + fpsLimit: "", + }, + onSubmit: ({ value }) => { + const val = parseInt(String(value.fpsLimit)); + if (val === getfpsLimit()) return; + setfpsLimit(val); + savedIndicator.flash(); + }, + })); + + return ( + +
+ } + /> + ); +} diff --git a/frontend/src/ts/components/pages/settings/custom-setting/AutoSwitchTheme.tsx b/frontend/src/ts/components/pages/settings/custom-setting/AutoSwitchTheme.tsx new file mode 100644 index 000000000000..cd46bf20c74b --- /dev/null +++ b/frontend/src/ts/components/pages/settings/custom-setting/AutoSwitchTheme.tsx @@ -0,0 +1,78 @@ +import { ThemeName } from "@monkeytype/schemas/configs"; +import { JSXElement, Show } from "solid-js"; + +import { configMetadata } from "../../../../config/metadata"; +import { setConfig } from "../../../../config/setters"; +import { getConfig } from "../../../../config/store"; +import { ThemesList } from "../../../../constants/themes"; +import { cn } from "../../../../utils/cn"; +import { Button } from "../../../common/Button"; +import SlimSelect from "../../../ui/SlimSelect"; +import { Setting } from "../Setting"; + +export function AutoSwitchTheme(): JSXElement { + return ( + +
+ } + fullWidthInputs={ + +
+
+
light
+ ({ + text: theme.name.replace(/_/g, " "), + value: theme.name, + }))} + selected={getConfig.themeLight} + onChange={(value) => + setConfig("themeLight", value as ThemeName) + } + /> +
+
+
dark
+ ({ + text: theme.name.replace(/_/g, " "), + value: theme.name, + }))} + selected={getConfig.themeDark} + onChange={(value) => setConfig("themeDark", value as ThemeName)} + /> +
+
+
+ } + /> + ); +} diff --git a/frontend/src/ts/components/pages/settings/custom-setting/CustomBackground.tsx b/frontend/src/ts/components/pages/settings/custom-setting/CustomBackground.tsx new file mode 100644 index 000000000000..9f2b526696ea --- /dev/null +++ b/frontend/src/ts/components/pages/settings/custom-setting/CustomBackground.tsx @@ -0,0 +1,194 @@ +import { + ConfigSchema, + CustomBackgroundSchema, +} from "@monkeytype/schemas/configs"; +import { createForm } from "@tanstack/solid-form"; +import { createResource, JSXElement, For, Show } from "solid-js"; + +import { configMetadata } from "../../../../config/metadata"; +import { setConfig } from "../../../../config/setters"; +import { getConfig } from "../../../../config/store"; +import { applyCustomBackground } from "../../../../controllers/theme-controller"; +import { useSavedIndicator } from "../../../../hooks/useSavedIndicator"; +import { showNoticeNotification } from "../../../../states/notifications"; +import FileStorage from "../../../../utils/file-storage"; +import { getOptions } from "../../../../utils/zod"; +import { Button } from "../../../common/Button"; +import { Fa } from "../../../common/Fa"; +import { Separator } from "../../../common/Separator"; +import { InputField } from "../../../ui/form/InputField"; +import { fromSchema } from "../../../ui/form/utils"; +import { Setting } from "../Setting"; + +export function CustomBackground(): JSXElement { + const savedIndicator = useSavedIndicator(); + + const form = createForm(() => ({ + defaultValues: { + customBackground: getConfig.customBackground, + }, + onSubmit: ({ value }) => { + const val = value.customBackground; + if (val === getConfig.customBackground) return; + savedIndicator.flash(); + setConfig("customBackground", val); + }, + })); + + const [hasLocalBackground] = createResource( + () => FileStorage.track("LocalBackgroundFile"), + async () => FileStorage.hasFile("LocalBackgroundFile"), + ); + + const readFileAsDataURL = async (file: File): Promise => { + return new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onload = () => resolve(reader.result as string); + reader.onerror = reject; + reader.readAsDataURL(file); + }); + }; + + return ( + + {configMetadata.customBackground.description} +
+
+ Note: The local image is stored in your browser's local storage + and will not be uploaded to the server. This means that if you clear + your browser's local storage or use a different browser, the + local image will be lost. +
+ + } + inputs={ +
+ { + void FileStorage.deleteFile("LocalBackgroundFile").then( + () => { + void applyCustomBackground(); + }, + ); + }} + /> + } + > + <> + { + const fileInput = e.target as HTMLInputElement; + const file = fileInput.files?.[0]; + + if (!file) { + return; + } + + // check type + if (!/image\/(jpeg|jpg|png|gif|webp)/.exec(file.type)) { + showNoticeNotification("Unsupported image format"); + fileInput.value = ""; + return; + } + + const dataUrl = await readFileAsDataURL(file); + await FileStorage.storeFile("LocalBackgroundFile", dataUrl); + + void applyCustomBackground(); + + fileInput.value = ""; + }} + /> + {/* i cant figure out how to trigger the file input with a Button component */} + + + + + +
{ + e.preventDefault(); + e.stopPropagation(); + void form.handleSubmit(); + }} + > + { + const val = value; + return fromSchema(CustomBackgroundSchema)({ + value: val, + }); + }, + onBlur: () => { + void form.handleSubmit(); + }, + }} + children={(field) => ( +
+ + +
+ )} + /> + +
+
+ + {(option) => { + const optionMeta = configMetadata.customBackgroundSize + .optionsMetadata as Record< + string, + { displayString?: string } + >; + const displayString = + optionMeta?.[String(option)]?.displayString ?? String(option); + return ( + + ); + }} + +
+
+ } + /> + ); +} diff --git a/frontend/src/ts/components/pages/settings/custom-setting/CustomBackgroundFilters.tsx b/frontend/src/ts/components/pages/settings/custom-setting/CustomBackgroundFilters.tsx new file mode 100644 index 000000000000..98bd9eb60e38 --- /dev/null +++ b/frontend/src/ts/components/pages/settings/custom-setting/CustomBackgroundFilters.tsx @@ -0,0 +1,166 @@ +import { JSXElement } from "solid-js"; + +import { configMetadata } from "../../../../config/metadata"; +import { setConfig } from "../../../../config/setters"; +import { getConfig } from "../../../../config/store"; +import { qs } from "../../../../utils/dom"; +import { Slider } from "../../../common/Slider"; +import { Setting } from "../Setting"; + +export function applyCustomBackgroundFilters( + values?: [number, number, number, number], +): void { + const valuesToApply = values ?? getConfig.customBackgroundFilter; + + let filterCSS = ""; + //blur + if (valuesToApply[0] !== 0) { + filterCSS += `blur(${valuesToApply[0]}rem) `; + } + //brightness + if (valuesToApply[1] !== 1) { + filterCSS += `brightness(${valuesToApply[1]}) `; + } + //saturate + if (valuesToApply[2] !== 1) { + filterCSS += `saturate(${valuesToApply[2]}) `; + } + //opacity + if (valuesToApply[3] !== 1) { + filterCSS += `opacity(${valuesToApply[3]}) `; + } + + const css = { + filter: filterCSS, + width: `calc(100% + ${valuesToApply[0] * 8}rem)`, + height: `calc(100% + ${valuesToApply[0] * 8}rem)`, + transform: `scale(${1 + valuesToApply[0] / 100})`, + top: `-${valuesToApply[0] * 4}rem`, + position: "absolute", + }; + qs(".customBackground img")?.setStyle(css); +} + +export function CustomBackgroundFilters(): JSXElement { + let refBlur: HTMLInputElement | undefined = undefined; + let refBrightness: HTMLInputElement | undefined = undefined; + let refSaturate: HTMLInputElement | undefined = undefined; + let refOpacity: HTMLInputElement | undefined = undefined; + + const refValues = () => { + if (!refBlur || !refBrightness || !refSaturate || !refOpacity) { + return undefined; + } + return [ + Number(refBlur.value), + Number(refBrightness.value), + Number(refSaturate.value), + Number(refOpacity.value), + ] as [number, number, number, number]; + }; + + return ( + +
+
blur
+ (refBlur = el)} + min={0} + max={5} + step={0.1} + text={(value) => { + return value.toFixed(1); + }} + value={getConfig.customBackgroundFilter[0]} + onEveryChange={() => applyCustomBackgroundFilters(refValues())} + onChange={(value) => { + if (value === getConfig.customBackgroundFilter[0]) return; + setConfig("customBackgroundFilter", [ + value, + getConfig.customBackgroundFilter[1], + getConfig.customBackgroundFilter[2], + getConfig.customBackgroundFilter[3], + ]); + }} + /> +
+
+
brightness
+ (refBrightness = el)} + min={0} + max={2} + step={0.1} + text={(value) => { + return value.toFixed(1); + }} + value={getConfig.customBackgroundFilter[1]} + onEveryChange={() => applyCustomBackgroundFilters(refValues())} + onChange={(value) => { + if (value === getConfig.customBackgroundFilter[1]) return; + setConfig("customBackgroundFilter", [ + getConfig.customBackgroundFilter[0], + value, + getConfig.customBackgroundFilter[2], + getConfig.customBackgroundFilter[3], + ]); + }} + /> +
+
+
saturate
+ (refSaturate = el)} + min={0} + max={2} + step={0.1} + text={(value) => { + return value.toFixed(1); + }} + value={getConfig.customBackgroundFilter[2]} + onEveryChange={() => applyCustomBackgroundFilters(refValues())} + onChange={(value) => { + if (value === getConfig.customBackgroundFilter[2]) return; + setConfig("customBackgroundFilter", [ + getConfig.customBackgroundFilter[0], + getConfig.customBackgroundFilter[1], + value, + getConfig.customBackgroundFilter[3], + ]); + }} + /> +
+
+
opacity
+ (refOpacity = el)} + min={0} + max={1} + step={0.1} + text={(value) => { + return value.toFixed(1); + }} + value={getConfig.customBackgroundFilter[3]} + onEveryChange={() => applyCustomBackgroundFilters(refValues())} + onChange={(value) => { + if (value === getConfig.customBackgroundFilter[3]) return; + setConfig("customBackgroundFilter", [ + getConfig.customBackgroundFilter[0], + getConfig.customBackgroundFilter[1], + getConfig.customBackgroundFilter[2], + value, + ]); + }} + /> +
+ + } + /> + ); +} diff --git a/frontend/src/ts/components/pages/settings/custom-setting/CustomLayoutfluid.tsx b/frontend/src/ts/components/pages/settings/custom-setting/CustomLayoutfluid.tsx new file mode 100644 index 000000000000..662ae6da43d9 --- /dev/null +++ b/frontend/src/ts/components/pages/settings/custom-setting/CustomLayoutfluid.tsx @@ -0,0 +1,46 @@ +import { CustomLayoutFluid } from "@monkeytype/schemas/configs"; +import { JSXElement } from "solid-js"; + +import { configMetadata } from "../../../../config/metadata"; +import { setConfig } from "../../../../config/setters"; +import { getConfig } from "../../../../config/store"; +import { LayoutsList } from "../../../../constants/layouts"; +import { areUnsortedArraysEqual } from "../../../../utils/arrays"; +import SlimSelect from "../../../ui/SlimSelect"; +import { Setting } from "../Setting"; + +export function CustomLayoutfluid(): JSXElement { + return ( + ({ + text: layout.replace(/_/g, " "), + value: layout, + }))} + selected={getConfig.customLayoutfluid} + onChange={(val) => { + if ( + areUnsortedArraysEqual( + getConfig.customLayoutfluid, + val as CustomLayoutFluid, + ) + ) { + return; + } + setConfig("customLayoutfluid", val as CustomLayoutFluid); + }} + /> + } + /> + ); +} diff --git a/frontend/src/ts/components/pages/settings/custom-setting/CustomPolyglot.tsx b/frontend/src/ts/components/pages/settings/custom-setting/CustomPolyglot.tsx new file mode 100644 index 000000000000..8b560764eb8a --- /dev/null +++ b/frontend/src/ts/components/pages/settings/custom-setting/CustomPolyglot.tsx @@ -0,0 +1,57 @@ +import { CustomPolyglot as CustomPolyglotType } from "@monkeytype/schemas/configs"; +import { Optgroup } from "slim-select/store"; +import { JSXElement } from "solid-js"; + +import { configMetadata } from "../../../../config/metadata"; +import { setConfig } from "../../../../config/setters"; +import { getConfig } from "../../../../config/store"; +import { + LanguageGroupNames, + LanguageGroups, +} from "../../../../constants/languages"; +import { areUnsortedArraysEqual } from "../../../../utils/arrays"; +import { getLanguageDisplayString } from "../../../../utils/strings"; +import SlimSelect from "../../../ui/SlimSelect"; +import { Setting } from "../Setting"; + +export function CustomPolyglot(): JSXElement { + return ( + + ({ + label: group, + options: LanguageGroups[group]?.map((language) => ({ + text: getLanguageDisplayString(language), + value: language, + })), + }) as Optgroup, + )} + selected={getConfig.customPolyglot} + onChange={(val) => { + if ( + areUnsortedArraysEqual( + getConfig.customPolyglot, + val as CustomPolyglotType, + ) + ) { + return; + } + setConfig("customPolyglot", val as CustomPolyglotType); + }} + /> + } + /> + ); +} diff --git a/frontend/src/ts/components/pages/settings/custom-setting/FontFamily.tsx b/frontend/src/ts/components/pages/settings/custom-setting/FontFamily.tsx new file mode 100644 index 000000000000..62e13b1b9de5 --- /dev/null +++ b/frontend/src/ts/components/pages/settings/custom-setting/FontFamily.tsx @@ -0,0 +1,191 @@ +import { ConfigSchema } from "@monkeytype/schemas/configs"; +import { createResource, For, JSXElement, Show } from "solid-js"; + +import { configMetadata } from "../../../../config/metadata"; +import { setConfig } from "../../../../config/setters"; +import { getConfig } from "../../../../config/store"; +import { showNoticeNotification } from "../../../../states/notifications"; +import { showSimpleModal } from "../../../../states/simple-modal"; +import { applyFontFamily } from "../../../../ui"; +import FileStorage from "../../../../utils/file-storage"; +import { getOptions } from "../../../../utils/zod"; +import { Button } from "../../../common/Button"; +import { Separator } from "../../../common/Separator"; +import { Setting } from "../Setting"; + +export function FontFamily(): JSXElement { + const [hasLocalFont, { refetch }] = createResource(async () => + FileStorage.hasFile("LocalFontFamilyFile"), + ); + + const fontOptions = getOptions(ConfigSchema.shape.fontFamily); + const isCustomFont = () => + fontOptions !== undefined && !fontOptions.includes(getConfig.fontFamily); + + const readFileAsDataURL = async (file: File): Promise => { + return new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onload = () => resolve(reader.result as string); + reader.onerror = reject; + reader.readAsDataURL(file); + }); + }; + + return ( + + {configMetadata.fontFamily.description} +
+
+ Note: Local fonts are not sent to the server and will not persist + across devices. +
+ + } + inputs={ +
+ { + void FileStorage.deleteFile("LocalFontFamilyFile").then( + () => { + void applyFontFamily(); + void refetch(); + }, + ); + }} + /> + } + > + <> + { + const fileInput = e.target as HTMLInputElement; + const file = fileInput.files?.[0]; + + if (!file) { + return; + } + + // check type + if ( + !/font\/(woff|woff2|ttf|otf)/.exec(file.type) && + !/\.(woff|woff2|ttf|otf)$/i.exec(file.name) + ) { + showNoticeNotification( + "Unsupported font format, must be woff, woff2, ttf or otf.", + ); + fileInput.value = ""; + return; + } + + const dataUrl = await readFileAsDataURL(file); + await FileStorage.storeFile("LocalFontFamilyFile", dataUrl); + + await applyFontFamily(); + void refetch(); + + fileInput.value = ""; + }} + /> + {/* i cant figure out how to trigger the file input with a Button component */} + + + + +
+ } + fullWidthInputs={ + +
+ + {(option) => { + const optionsMeta = configMetadata.fontFamily + .optionsMetadata as + | Record + | undefined; + const match = optionsMeta?.[String(option)]; + const displayString = + match?.displayString ?? String(option).replace(/_/g, " "); + + const fontFamily = () => { + if (option === "Comic_Sans_MS") { + return "Comic Sans MS"; + } + + return `${option.replace(/_/g, " ")} Preview`; + }; + + return ( +
+
+ ); + }} +
+
+
+ } + /> + ); +} diff --git a/frontend/src/ts/components/pages/settings/custom-setting/Funbox.tsx b/frontend/src/ts/components/pages/settings/custom-setting/Funbox.tsx new file mode 100644 index 000000000000..340654f92456 --- /dev/null +++ b/frontend/src/ts/components/pages/settings/custom-setting/Funbox.tsx @@ -0,0 +1,85 @@ +import { checkCompatibility, getAllFunboxes } from "@monkeytype/funbox"; +import { For, JSXElement, type JSX } from "solid-js"; + +// import { canSetFunboxWithConfig } from "../../../../config/funbox-validation"; +import { configMetadata } from "../../../../config/metadata"; +import { toggleFunbox } from "../../../../config/setters"; +import { getConfig } from "../../../../config/store"; +import { getActiveFunboxNames } from "../../../../test/funbox/list"; +import { Button } from "../../../common/Button"; +import { Setting } from "../Setting"; + +export function Funbox(): JSXElement { + return ( + + + {(funbox) => { + const active = () => getConfig.funbox.includes(funbox.name); + + const disabled = () => { + if (active()) return false; + const incompatible = !checkCompatibility( + getActiveFunboxNames(), + funbox.name, + ); + return incompatible; + // const configIncompatible = !canSetFunboxWithConfig( + // funbox.name, + // getConfig, + // ).ok; + // return incompatible || configIncompatible; + }; + + const style = (): JSX.CSSProperties | undefined => { + if (funbox.name === "mirror") { + return { + transform: "scaleX(-1)", + }; + } + if (funbox.name === "upside_down") { + return { + transform: "rotate(180deg)", + "z-index": 2, + }; + } + return undefined; + }; + + const text = () => { + if (funbox.name === "underscore_spaces") { + return "underscore_spaces"; + } + return funbox.name.replace(/_/g, " "); + }; + + return ( +
+ +
+ ); + }} +
+ + } + /> + ); +} diff --git a/frontend/src/ts/components/pages/settings/custom-setting/ImportExport.tsx b/frontend/src/ts/components/pages/settings/custom-setting/ImportExport.tsx new file mode 100644 index 000000000000..6ac4e2e69a42 --- /dev/null +++ b/frontend/src/ts/components/pages/settings/custom-setting/ImportExport.tsx @@ -0,0 +1,98 @@ +import { JSXElement } from "solid-js"; + +import { applyConfigFromJson } from "../../../../config/lifecycle"; +import { getConfig } from "../../../../config/store"; +import { + showNoticeNotification, + showSuccessNotification, +} from "../../../../states/notifications"; +import { showSimpleModal } from "../../../../states/simple-modal"; +import { Button } from "../../../common/Button"; +import { Setting } from "../Setting"; + +export function ImportExport(): JSXElement { + return ( + + + + + } + /> + ); +} diff --git a/frontend/src/ts/components/pages/settings/custom-setting/KeymapLayout.tsx b/frontend/src/ts/components/pages/settings/custom-setting/KeymapLayout.tsx new file mode 100644 index 000000000000..bc62e730bf21 --- /dev/null +++ b/frontend/src/ts/components/pages/settings/custom-setting/KeymapLayout.tsx @@ -0,0 +1,41 @@ +import { KeymapLayout as KeymapLayoutSchema } from "@monkeytype/schemas/configs"; +import { JSXElement } from "solid-js"; + +import { configMetadata } from "../../../../config/metadata"; +import { setConfig } from "../../../../config/setters"; +import { getConfig } from "../../../../config/store"; +import { LayoutsList } from "../../../../constants/layouts"; +import SlimSelect from "../../../ui/SlimSelect"; +import { Setting } from "../Setting"; + +export function KeymapLayout(): JSXElement { + return ( + { + return { + text: layout.replace(/_/g, " "), + value: layout, + }; + }), + ]} + selected={getConfig.keymapLayout} + onChange={(val) => { + if (getConfig.keymapLayout === (val as KeymapLayoutSchema)) return; + setConfig("keymapLayout", val as KeymapLayoutSchema); + }} + /> + } + /> + ); +} diff --git a/frontend/src/ts/components/pages/settings/custom-setting/KeymapSize.tsx b/frontend/src/ts/components/pages/settings/custom-setting/KeymapSize.tsx new file mode 100644 index 000000000000..b1620895b688 --- /dev/null +++ b/frontend/src/ts/components/pages/settings/custom-setting/KeymapSize.tsx @@ -0,0 +1,33 @@ +import { JSXElement } from "solid-js"; + +import { configMetadata } from "../../../../config/metadata"; +import { setConfig } from "../../../../config/setters"; +import { getConfig } from "../../../../config/store"; +import { Slider } from "../../../common/Slider"; +import { Setting } from "../Setting"; + +export function KeymapSize(): JSXElement { + return ( + { + return value.toFixed(1); + }} + value={getConfig.keymapSize} + onChange={(value) => { + if (value === getConfig.keymapSize) return; + setConfig("keymapSize", value); + }} + /> + } + /> + ); +} diff --git a/frontend/src/ts/components/pages/settings/custom-setting/Language.tsx b/frontend/src/ts/components/pages/settings/custom-setting/Language.tsx new file mode 100644 index 000000000000..f3acf14a7cf9 --- /dev/null +++ b/frontend/src/ts/components/pages/settings/custom-setting/Language.tsx @@ -0,0 +1,44 @@ +import { Language as LanguageSchema } from "@monkeytype/schemas/languages"; +import { Optgroup } from "slim-select/store"; +import { JSXElement } from "solid-js"; + +import { configMetadata } from "../../../../config/metadata"; +import { setConfig } from "../../../../config/setters"; +import { getConfig } from "../../../../config/store"; +import { + LanguageGroupNames, + LanguageGroups, +} from "../../../../constants/languages"; +import { getLanguageDisplayString } from "../../../../utils/strings"; +import SlimSelect from "../../../ui/SlimSelect"; +import { Setting } from "../Setting"; + +export function Language(): JSXElement { + return ( + + ({ + label: group, + options: LanguageGroups[group]?.map((language) => ({ + text: getLanguageDisplayString(language), + value: language, + })), + }) as Optgroup, + )} + selected={getConfig.language} + onChange={(val) => { + if (getConfig.language === (val as LanguageSchema)) return; + setConfig("language", val as LanguageSchema); + }} + /> + } + /> + ); +} diff --git a/frontend/src/ts/components/pages/settings/custom-setting/Layout.tsx b/frontend/src/ts/components/pages/settings/custom-setting/Layout.tsx new file mode 100644 index 000000000000..1f6271834d80 --- /dev/null +++ b/frontend/src/ts/components/pages/settings/custom-setting/Layout.tsx @@ -0,0 +1,33 @@ +import { Layout as LayoutSchema } from "@monkeytype/schemas/configs"; +import { JSXElement } from "solid-js"; + +import { configMetadata } from "../../../../config/metadata"; +import { setConfig } from "../../../../config/setters"; +import { getConfig } from "../../../../config/store"; +import { LayoutsList } from "../../../../constants/layouts"; +import SlimSelect from "../../../ui/SlimSelect"; +import { Setting } from "../Setting"; + +export function Layout(): JSXElement { + return ( + ({ + text: layout.replace(/_/g, " "), + value: layout, + }))} + selected={getConfig.layout} + onChange={(val) => { + if (getConfig.layout === (val as LayoutSchema)) return; + setConfig("layout", val as LayoutSchema); + }} + /> + } + /> + ); +} diff --git a/frontend/src/ts/components/pages/settings/custom-setting/MaxLineWidth.tsx b/frontend/src/ts/components/pages/settings/custom-setting/MaxLineWidth.tsx new file mode 100644 index 000000000000..f69258ea38cc --- /dev/null +++ b/frontend/src/ts/components/pages/settings/custom-setting/MaxLineWidth.tsx @@ -0,0 +1,80 @@ +import { MaxLineWidthSchema } from "@monkeytype/schemas/configs"; +import { createForm } from "@tanstack/solid-form"; +import { JSXElement } from "solid-js"; + +import { configMetadata } from "../../../../config/metadata"; +import { setConfig } from "../../../../config/setters"; +import { getConfig } from "../../../../config/store"; +import { useSavedIndicator } from "../../../../hooks/useSavedIndicator"; +// import { showSuccessNotification } from "../../../../states/notifications"; +import { InputField } from "../../../ui/form/InputField"; +import { fromSchema } from "../../../ui/form/utils"; +import { Setting } from "../Setting"; + +export function MaxLineWidth(): JSXElement { + const { component: SavedIndicator, flash } = useSavedIndicator(); + + const form = createForm(() => ({ + defaultValues: { + maxLineWidth: getConfig.maxLineWidth, + }, + onSubmit: ({ value }) => { + const val = parseInt(String(value.maxLineWidth)); + if (val === getConfig.maxLineWidth) return; + flash(); + setConfig("maxLineWidth", val); + }, + })); + + return ( + +
{ + e.preventDefault(); + e.stopPropagation(); + void form.handleSubmit(); + }} + > + { + const val = parseInt(String(value)); + if (isNaN(val)) { + return "Must be a number"; + } + return fromSchema(MaxLineWidthSchema)({ + value: val, + }); + }, + onBlur: () => { + void form.handleSubmit(); + }, + }} + children={(field) => ( +
+ + +
+ )} + /> + + + } + /> + ); +} diff --git a/frontend/src/ts/components/pages/settings/custom-setting/MinAcc.tsx b/frontend/src/ts/components/pages/settings/custom-setting/MinAcc.tsx new file mode 100644 index 000000000000..7f703ed5b433 --- /dev/null +++ b/frontend/src/ts/components/pages/settings/custom-setting/MinAcc.tsx @@ -0,0 +1,106 @@ +import { MinimumAccuracyCustomSchema } from "@monkeytype/schemas/configs"; +import { createForm } from "@tanstack/solid-form"; +import { JSXElement } from "solid-js"; + +import { configMetadata } from "../../../../config/metadata"; +import { setConfig } from "../../../../config/setters"; +import { getConfig } from "../../../../config/store"; +import { useSavedIndicator } from "../../../../hooks/useSavedIndicator"; +// import { showSuccessNotification } from "../../../../states/notifications"; +import { Button } from "../../../common/Button"; +import { InputField } from "../../../ui/form/InputField"; +import { fromSchema } from "../../../ui/form/utils"; +import { Setting } from "../Setting"; + +export function MinAcc(): JSXElement { + const savedIndicator = useSavedIndicator(); + + const form = createForm(() => ({ + defaultValues: { + minAccCustom: getConfig.minAccCustom, + }, + onSubmit: ({ value }) => { + const val = parseInt(String(value.minAccCustom)); + if (val === getConfig.minAccCustom) return; + if (getConfig.minAcc === "custom") { + // + } else { + setConfig("minAcc", "custom"); + } + savedIndicator.flash(); + setConfig("minAccCustom", val); + }, + })); + + return ( + +
{ + e.preventDefault(); + e.stopPropagation(); + void form.handleSubmit(); + }} + > + { + const val = parseInt(String(value)); + if (isNaN(val)) { + return "Must be a number"; + } + return fromSchema(MinimumAccuracyCustomSchema)({ + value: val, + }); + }, + onBlur: () => { + void form.handleSubmit(); + }, + }} + children={(field) => ( +
+ + +
+ )} + /> + + {/* */} +
+ + +
+ + } + /> + ); +} diff --git a/frontend/src/ts/components/pages/settings/custom-setting/MinBurst.tsx b/frontend/src/ts/components/pages/settings/custom-setting/MinBurst.tsx new file mode 100644 index 000000000000..577dfb8d60f4 --- /dev/null +++ b/frontend/src/ts/components/pages/settings/custom-setting/MinBurst.tsx @@ -0,0 +1,115 @@ +import { MinimumBurstCustomSpeedSchema } from "@monkeytype/schemas/configs"; +import { createForm } from "@tanstack/solid-form"; +import { JSXElement } from "solid-js"; + +import { configMetadata } from "../../../../config/metadata"; +import { setConfig } from "../../../../config/setters"; +import { getConfig } from "../../../../config/store"; +import { useSavedIndicator } from "../../../../hooks/useSavedIndicator"; +// import { showSuccessNotification } from "../../../../states/notifications"; +import { Button } from "../../../common/Button"; +import { InputField } from "../../../ui/form/InputField"; +import { fromSchema } from "../../../ui/form/utils"; +import { Setting } from "../Setting"; + +export function MinBurst(): JSXElement { + const savedIndicator = useSavedIndicator(); + + const form = createForm(() => ({ + defaultValues: { + minBurstCustomSpeed: getConfig.minBurstCustomSpeed, + }, + onSubmit: ({ value }) => { + const val = parseInt(String(value.minBurstCustomSpeed)); + if (val === getConfig.minBurstCustomSpeed) return; + if (getConfig.minBurst !== "off") { + // + } else { + setConfig("minBurst", "fixed"); + } + savedIndicator.flash(); + setConfig("minBurstCustomSpeed", val); + }, + })); + + return ( + +
{ + e.preventDefault(); + e.stopPropagation(); + void form.handleSubmit(); + }} + > + { + const val = parseInt(String(value)); + if (isNaN(val)) { + return "Must be a number"; + } + return fromSchema(MinimumBurstCustomSpeedSchema)({ + value: val, + }); + }, + onBlur: () => { + void form.handleSubmit(); + }, + }} + children={(field) => ( +
+ + +
+ )} + /> + + {/* */} +
+ + + +
+ + } + /> + ); +} diff --git a/frontend/src/ts/components/pages/settings/custom-setting/MinSpeed.tsx b/frontend/src/ts/components/pages/settings/custom-setting/MinSpeed.tsx new file mode 100644 index 000000000000..a799f5a1a499 --- /dev/null +++ b/frontend/src/ts/components/pages/settings/custom-setting/MinSpeed.tsx @@ -0,0 +1,104 @@ +import { MinWpmCustomSpeedSchema } from "@monkeytype/schemas/configs"; +import { createForm } from "@tanstack/solid-form"; +import { JSXElement } from "solid-js"; + +import { configMetadata } from "../../../../config/metadata"; +import { setConfig } from "../../../../config/setters"; +import { getConfig } from "../../../../config/store"; +import { useSavedIndicator } from "../../../../hooks/useSavedIndicator"; +// import { showSuccessNotification } from "../../../../states/notifications"; +import { Button } from "../../../common/Button"; +import { InputField } from "../../../ui/form/InputField"; +import { fromSchema } from "../../../ui/form/utils"; +import { Setting } from "../Setting"; + +export function MinSpeed(): JSXElement { + const savedIndicator = useSavedIndicator(); + + const form = createForm(() => ({ + defaultValues: { + minWpmCustomSpeed: getConfig.minWpmCustomSpeed, + }, + onSubmit: ({ value }) => { + const val = parseInt(String(value.minWpmCustomSpeed)); + if (val === getConfig.minWpmCustomSpeed) return; + if (getConfig.minWpm === "custom") { + // + } else { + setConfig("minWpm", "custom"); + } + savedIndicator.flash(); + setConfig("minWpmCustomSpeed", val); + }, + })); + + return ( + +
{ + e.preventDefault(); + e.stopPropagation(); + void form.handleSubmit(); + }} + > + { + const val = parseInt(String(value)); + if (isNaN(val)) { + return "Must be a number"; + } + return fromSchema(MinWpmCustomSpeedSchema)({ value: val }); + }, + onBlur: () => { + void form.handleSubmit(); + }, + }} + children={(field) => ( +
+ + +
+ )} + /> + + {/* */} +
+ + +
+ + } + /> + ); +} diff --git a/frontend/src/ts/components/pages/settings/custom-setting/PaceCaret.tsx b/frontend/src/ts/components/pages/settings/custom-setting/PaceCaret.tsx new file mode 100644 index 000000000000..e8c06ecbc626 --- /dev/null +++ b/frontend/src/ts/components/pages/settings/custom-setting/PaceCaret.tsx @@ -0,0 +1,110 @@ +import { + ConfigSchema, + PaceCaretCustomSpeedSchema, +} from "@monkeytype/schemas/configs"; +import { createForm } from "@tanstack/solid-form"; +import { For, JSXElement } from "solid-js"; + +import { configMetadata } from "../../../../config/metadata"; +import { setConfig } from "../../../../config/setters"; +import { getConfig } from "../../../../config/store"; +import { useSavedIndicator } from "../../../../hooks/useSavedIndicator"; +import { getOptions } from "../../../../utils/zod"; +import { Button } from "../../../common/Button"; +import { InputField } from "../../../ui/form/InputField"; +import { fromSchema } from "../../../ui/form/utils"; +import { Setting } from "../Setting"; + +export function PaceCaret(): JSXElement { + const savedIndicator = useSavedIndicator(); + + const form = createForm(() => ({ + defaultValues: { + paceCaretCustomSpeed: getConfig.paceCaretCustomSpeed, + }, + onSubmit: ({ value }) => { + const val = parseInt(String(value.paceCaretCustomSpeed)); + if (val === getConfig.paceCaretCustomSpeed) return; + if (getConfig.paceCaret !== "off") { + // + } else { + setConfig("paceCaret", "custom"); + } + savedIndicator.flash(); + setConfig("paceCaretCustomSpeed", val); + }, + })); + + return ( + +
{ + e.preventDefault(); + e.stopPropagation(); + void form.handleSubmit(); + }} + > + { + const val = parseInt(String(value)); + if (isNaN(val)) { + return "Must be a number"; + } + return fromSchema(PaceCaretCustomSpeedSchema)({ + value: val, + }); + }, + onBlur: () => { + void form.handleSubmit(); + }, + }} + children={(field) => ( +
+ + +
+ )} + /> + +
+ + {(option) => { + const optionMeta = configMetadata.paceCaret + .optionsMetadata as Record< + string, + { displayString?: string } + >; + const displayString = + optionMeta?.[String(option)]?.displayString ?? String(option); + return ( + + ); + }} + +
+ + } + /> + ); +} diff --git a/frontend/src/ts/components/pages/settings/custom-setting/Presets.tsx b/frontend/src/ts/components/pages/settings/custom-setting/Presets.tsx new file mode 100644 index 000000000000..0a1ecff476ef --- /dev/null +++ b/frontend/src/ts/components/pages/settings/custom-setting/Presets.tsx @@ -0,0 +1,82 @@ +import { JSXElement, For } from "solid-js"; + +import { + deletePreset, + usePresetsLiveQuery, +} from "../../../../collections/presets"; +import { apply } from "../../../../controllers/preset-controller"; +import { showEditPresetModal } from "../../../../states/edit-preset-modal"; +import { hideLoaderBar, showLoaderBar } from "../../../../states/loader-bar"; +import { showModal } from "../../../../states/modals"; +import { showSimpleModal } from "../../../../states/simple-modal"; +import { Button } from "../../../common/Button"; +import { Setting } from "../Setting"; + +export function Presets(): JSXElement { + const presets = usePresetsLiveQuery(); + + return ( + + + {(preset) => ( +
+
+ )} +
+ + ); +} + +function ThemeButton(props: { theme: ThemeWithName }): JSXElement { + const isActive = () => getConfig.theme === props.theme.name; + const isFav = () => getConfig.favThemes.includes(props.theme.name); + + return ( + + ); +} + +function Picker(props: { color: ColorName }): JSXElement { + let colorInputRef: HTMLInputElement | undefined = undefined; + + const text = () => { + if (props.color === "bg") return "background"; + if (props.color === "main") return "main"; + if (props.color === "sub") return "sub"; + if (props.color === "subAlt") return "sub alt"; + if (props.color === "caret") return "caret"; + if (props.color === "text") return "text"; + if (props.color === "error") return "error"; + if (props.color === "errorExtra") return "extra error"; + if (props.color === "colorfulError") return "error"; + if (props.color === "colorfulErrorExtra") return "extra error"; + return "unknown"; + }; + + const _classes = [ + "bg-(--picker-bg)", + "bg-(--picker-main)", + "bg-(--picker-caret)", + "bg-(--picker-sub)", + "bg-(--picker-subAlt)", + "bg-(--picker-text)", + "bg-(--picker-error)", + "bg-(--picker-errorExtra)", + "bg-(--picker-colorfulError)", + "bg-(--picker-colorfulErrorExtra)", + ]; + + // oxlint-disable-next-line solid/reactivity + const debouncedInput = debounce(125, (e: InputEvent) => { + const target = e.target as HTMLInputElement; + const color = target.value; + const key = props.color; + + updateThemeColor(key, color); + }); + + return ( +
+
{text()}
+ { + const value = e.currentTarget.value; + if (!/^#([0-9A-Fa-f]{3}){1,2}$/.test(value)) { + // invalid hex color + e.currentTarget.value = getTheme()[props.color]; + return; + } + updateThemeColor(props.color, value); + }} + /> +
+ (colorInputRef = el)} + type="color" + value={getTheme()[props.color]} + onInput={debouncedInput} + class="pointer-events-none col-[1/1] row-[1/1] m-0 h-full w-0 p-0 opacity-0" + // onChange={(e) => { + // const current = [...getConfig.customThemeColors]; + // current[colorIndex()] = e.currentTarget.value; + // setConfig( + // "customThemeColors", + // current as typeof getConfig.customThemeColors, + // ); + // }} + /> +
+
+ ); +} diff --git a/frontend/src/ts/components/popups/alerts/AlertsPopup.tsx b/frontend/src/ts/components/popups/alerts/AlertsPopup.tsx index cac8561a3a44..d5f9f199efb6 100644 --- a/frontend/src/ts/components/popups/alerts/AlertsPopup.tsx +++ b/frontend/src/ts/components/popups/alerts/AlertsPopup.tsx @@ -12,7 +12,6 @@ export function AlertsPopup(): JSXElement { return ( []; + optionGroups?: Optgroup[]; values?: string[]; // Simple string array where value === text settings?: Config["settings"] & { scrollToTop?: boolean; @@ -83,6 +84,11 @@ export default function SlimSelect(props: SlimSelectProps): JSXElement { return []; }; + const getInitialData = (): (Partial)[] => { + if (props.optionGroups) return props.optionGroups; + return getDataWithAll(buildData(getOptions(), getSelected())); + }; + // Build option data with selection state const buildData = ( options: Pick[] = [], @@ -245,7 +251,7 @@ export default function SlimSelect(props: SlimSelectProps): JSXElement { const config: Config = { select: selectRef, - data: getDataWithAll(buildData(getOptions(), getSelected())) as Option[], + data: getInitialData() as Option[], settings: { ...props.settings, ...(props.appendTo === "container" && { @@ -357,9 +363,12 @@ export default function SlimSelect(props: SlimSelectProps): JSXElement { setIsInitialMount(false); - // Initialize with selected values + // Initialize currentSelected from data without firing onChange requestAnimationFrame(() => { - if (!props.onChange || (!props.options && !props.values) || !slimSelect) { + if ( + (!props.options && !props.values && !props.optionGroups) || + !slimSelect + ) { setIsInitializing(false); return; } @@ -380,13 +389,6 @@ export default function SlimSelect(props: SlimSelectProps): JSXElement { } } - if (initialValue.length > 0 && props.onChange !== undefined) { - if (props.multiple) { - props.onChange(initialValue); - } else { - props.onChange(initialValue[0] ?? ""); - } - } currentSelected = initialValue; } @@ -409,6 +411,9 @@ export default function SlimSelect(props: SlimSelectProps): JSXElement { return; } + // When using optionGroups without selected prop, selection is embedded in the data + if (props.selected === undefined) return; + if (slimSelect && selected !== undefined) { currentSelected = selected; diff --git a/frontend/src/ts/components/ui/form/InputField.tsx b/frontend/src/ts/components/ui/form/InputField.tsx index 1a3cc90dd192..e1868a88d745 100644 --- a/frontend/src/ts/components/ui/form/InputField.tsx +++ b/frontend/src/ts/components/ui/form/InputField.tsx @@ -1,5 +1,5 @@ import { AnyFieldApi } from "@tanstack/solid-form"; -import { Accessor, JSXElement, Show } from "solid-js"; +import { Accessor, createSignal, JSXElement, Show } from "solid-js"; import { cn } from "../../../utils/cn"; import { FieldIndicator } from "./FieldIndicator"; @@ -10,13 +10,36 @@ export function InputField(props: { autocomplete?: string; type?: string; disabled?: boolean; + readOnly?: boolean; + clickToSelect?: boolean; class?: string; dir?: "ltr" | "rtl" | "auto"; maxLength?: number; onFocus?: () => void; + /** + * If user inputs empty string the field is resetted to the default value + */ + resetToDefaultIfEmptyOnBlur?: boolean; }): JSXElement { + const [shake, setShake] = createSignal(false); + + const shakeItIfYouWantIt = () => { + if ( + props.field().state.meta.isTouched && + !props.field().state.meta.isValid + ) { + setShake(true); + setTimeout(() => setShake(false), 300); + } + }; + return ( -
+
props.field().handleBlur()} + onBlur={() => { + if ( + props.resetToDefaultIfEmptyOnBlur && + props.field().state.value === "" + ) { + props.field().setValue( + // oxlint-disable-next-line typescript/no-unsafe-member-access + props.field().form.options.defaultValues?.[props.field().name], + ); + } + shakeItIfYouWantIt(); + props.field().handleBlur(); + }} onInput={(e) => props.field().handleChange(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter") { + shakeItIfYouWantIt(); + } + }} disabled={props.disabled} + readOnly={props.readOnly} + onClick={(e) => { + if (props.clickToSelect) e.currentTarget.select(); + }} onFocus={() => props.onFocus?.()} dir={props.dir} maxLength={props.maxLength} diff --git a/frontend/src/ts/components/ui/form/utils.ts b/frontend/src/ts/components/ui/form/utils.ts index 5f7cb828f2ae..2cacacb5617f 100644 --- a/frontend/src/ts/components/ui/form/utils.ts +++ b/frontend/src/ts/components/ui/form/utils.ts @@ -4,9 +4,13 @@ import { ZodSchema } from "zod"; export type ValidationResult = { type: "error" | "warning"; message: string }; export function fromSchema( schema: ZodSchema, + options?: { + convert?: (value: T) => T; + }, ): (args: { value: T }) => undefined | string[] { return ({ value }) => { - const result = schema.safeParse(value); + const convertedValue = options?.convert?.(value) ?? value; + const result = schema.safeParse(convertedValue); return result.success ? undefined : result.error.issues.map((it) => it.message); diff --git a/frontend/src/ts/config/lifecycle.ts b/frontend/src/ts/config/lifecycle.ts index 9402afa95698..4f88247173e4 100644 --- a/frontend/src/ts/config/lifecycle.ts +++ b/frontend/src/ts/config/lifecycle.ts @@ -9,14 +9,13 @@ import { saveToLocalStorage, saveFullConfigToLocalStorage, } from "./persistence"; -import { Config, setConfigStore } from "./store"; +import { Config, setFullConfigStore } from "./store"; import { getDefaultConfig } from "../constants/default-config"; import { configEvent } from "../events/config"; import { migrateConfig } from "./utils"; import { promiseWithResolvers, typedKeys } from "../utils/misc"; import { setConfig } from "./setters"; import { deleteConfig } from "../ape/config"; -import { reconcile } from "solid-js/store"; export async function applyConfigFromJson(json: string): Promise { try { @@ -108,7 +107,7 @@ export async function applyConfig( } configEvent.dispatch({ key: "fullConfigChangeFinished" }); - setConfigStore(reconcile(Config)); + setFullConfigStore(fullConfig); } export async function resetConfig(): Promise { diff --git a/frontend/src/ts/config/metadata.ts b/frontend/src/ts/config/metadata.tsx similarity index 66% rename from frontend/src/ts/config/metadata.ts rename to frontend/src/ts/config/metadata.tsx index dc4815ae5b3f..c7b65dffd24d 100644 --- a/frontend/src/ts/config/metadata.ts +++ b/frontend/src/ts/config/metadata.tsx @@ -1,15 +1,17 @@ import { checkCompatibility } from "@monkeytype/funbox"; -import * as DB from "../db"; -import { showNoticeNotification } from "../states/notifications"; -import { isAuthenticated } from "../states/core"; -import { canSetFunboxWithConfig } from "./funbox-validation"; -import { reloadAfter } from "../utils/misc"; -import { isDevEnvironment } from "../utils/env"; import * as ConfigSchemas from "@monkeytype/schemas/configs"; import { roundTo1 } from "@monkeytype/util/numbers"; -import { capitalizeFirstLetter } from "../utils/strings"; +import { JSXElement } from "solid-js"; + +import * as CustomThemes from "../collections/custom-themes"; import { getDefaultConfig } from "../constants/default-config"; +import { isAuthenticated } from "../states/core"; +import { showNoticeNotification } from "../states/notifications"; import { FaObject } from "../types/font-awesome"; +import { isDevEnvironment } from "../utils/env"; +import { reloadAfter } from "../utils/misc"; +import { capitalizeFirstLetter } from "../utils/strings"; +import { canSetFunboxWithConfig } from "./funbox-validation"; // type SetBlock = { // [K in keyof ConfigSchemas.Config]?: ConfigSchemas.Config[K][]; // }; @@ -18,6 +20,12 @@ import { FaObject } from "../types/font-awesome"; // [K in keyof ConfigSchemas.Config]?: ConfigSchemas.Config[K]; // }; +export type OptionMetadata = { + displayString?: string; + fa?: FaObject; + visible?: boolean; +}; + export type ConfigMetadata = { /** * The config key that this metadata is for @@ -33,22 +41,21 @@ export type ConfigMetadata = { */ triggerResize?: true; + description?: string | JSXElement; + /** * Fa object (icon) */ fa: FaObject; optionsMetadata?: ConfigSchemas.Config[K] extends string | number | symbol - ? Partial< - Record< - ConfigSchemas.Config[K], - { - displayString?: string; - fa?: FaObject; - } - > - > - : never; + ? Record + : ConfigSchemas.Config[K] extends boolean + ? Partial<{ + true: OptionMetadata; + false: OptionMetadata; + }> + : never; // commandline?: { // displayValues?: ConfigSchemas.Config[K] extends string | number | symbol @@ -112,6 +119,22 @@ export type ConfigMetadataObject = { //todo: // maybe have generic set somehow handle test restarting +const caretOptionsMetadata = { + banana: { + visible: false, + }, + carrot: { + visible: false, + }, + monkey: { + visible: false, + }, + block: {}, + off: {}, + default: {}, + outline: {}, + underline: {}, +}; export const configMetadata: ConfigMetadataObject = { // test punctuation: { @@ -230,6 +253,7 @@ export const configMetadata: ConfigMetadataObject = { displayString: "language", changeRequiresRestart: true, group: "test", + description: "Change in which language you want to type.", }, burstHeatmap: { key: "burstHeatmap", @@ -245,6 +269,8 @@ export const configMetadata: ConfigMetadataObject = { fa: { icon: "fa-star" }, changeRequiresRestart: true, group: "behavior", + description: + "Normal is the classic typing test experience. Expert fails the test if you submit (press space) an incorrect word. Master fails if you press a single incorrect key (meaning you have to achieve 100% accuracy).", }, quickRestart: { key: "quickRestart", @@ -252,6 +278,8 @@ export const configMetadata: ConfigMetadataObject = { displayString: "quick restart", changeRequiresRestart: false, group: "behavior", + description: + 'Press tab, esc or enter to quickly restart the test, or to quickly jump to the test page. These options disable tab navigation on most parts of the website. Using the "esc" option will move opening the commandline to the tab key.', }, repeatQuotes: { key: "repeatQuotes", @@ -259,6 +287,8 @@ export const configMetadata: ConfigMetadataObject = { displayString: "repeat quotes", changeRequiresRestart: false, group: "behavior", + description: + "This setting changes the restarting behavior when typing in quote mode. Changing it to 'typing' will repeat the quote if you restart while typing.", }, resultSaving: { key: "resultSaving", @@ -266,13 +296,22 @@ export const configMetadata: ConfigMetadataObject = { displayString: "result saving", changeRequiresRestart: false, group: "behavior", + description: + "Disable result saving, in case you want to practice without affecting your account stats.", }, blindMode: { key: "blindMode", fa: { icon: "fa-eye-slash" }, + optionsMetadata: { + true: { + displayString: "‎", + }, + }, displayString: "blind mode", changeRequiresRestart: false, group: "behavior", + description: + "No errors or incorrect words are highlighted. Helps you to focus on raw speed. If enabled, quick end is recommended.", }, alwaysShowWordsHistory: { key: "alwaysShowWordsHistory", @@ -280,6 +319,8 @@ export const configMetadata: ConfigMetadataObject = { displayString: "always show words history", changeRequiresRestart: false, group: "behavior", + description: + "This option will automatically show the words history at the end of the test. Can cause slight lag with a lot of words.", }, singleListCommandLine: { key: "singleListCommandLine", @@ -287,6 +328,8 @@ export const configMetadata: ConfigMetadataObject = { displayString: "single list command line", changeRequiresRestart: false, group: "behavior", + description: + "When enabled, it will show the command line with all commands in a single list instead of submenu arrangements. Selecting 'manual' will expose all commands only after typing >.", }, minWpm: { key: "minWpm", @@ -294,6 +337,8 @@ export const configMetadata: ConfigMetadataObject = { displayString: "min speed", changeRequiresRestart: true, group: "behavior", + description: + "Automatically fails a test if your speed falls below a threshold.", }, minWpmCustomSpeed: { key: "minWpmCustomSpeed", @@ -316,6 +361,8 @@ export const configMetadata: ConfigMetadataObject = { displayString: "min accuracy", changeRequiresRestart: true, group: "behavior", + description: + "Automatically fails a test if your accuracy falls below a threshold.", }, minAccCustom: { key: "minAccCustom", @@ -338,6 +385,8 @@ export const configMetadata: ConfigMetadataObject = { displayString: "min word burst", changeRequiresRestart: true, group: "behavior", + description: + "Automatically fails a test if your raw for a single word falls below this threshold. Selecting 'flex' allows for this threshold to automatically decrease for longer words.", }, minBurstCustomSpeed: { key: "minBurstCustomSpeed", @@ -352,12 +401,16 @@ export const configMetadata: ConfigMetadataObject = { displayString: "british english", changeRequiresRestart: true, group: "behavior", + description: + "When enabled, the website will use the British spelling instead of American. Note that this might not replace all words correctly. If you find any issues, please let us know.", }, funbox: { key: "funbox", fa: { icon: "fa-gamepad" }, changeRequiresRestart: true, group: "behavior", + description: + "These are special modes that change the website in some special way (by altering the word generation, behavior of the website or the looks). Give each one of them a try!", isBlocked: ({ value, currentConfig }) => { if (!checkCompatibility(value)) { showNoticeNotification( @@ -387,6 +440,8 @@ export const configMetadata: ConfigMetadataObject = { displayString: "custom layoutfluid", changeRequiresRestart: true, group: "behavior", + description: + "Select which layouts you want the layoutfluid funbox to cycle through.", overrideValue: ({ value }) => { return Array.from(new Set(value)); }, @@ -394,9 +449,10 @@ export const configMetadata: ConfigMetadataObject = { customPolyglot: { key: "customPolyglot", fa: { icon: "fa-language" }, - displayString: "custom polyglot", + displayString: "polyglot languages", changeRequiresRestart: false, group: "behavior", + description: "Select which languages you want the polyglot funbox to use.", overrideValue: ({ value }) => { return Array.from(new Set(value)); }, @@ -409,6 +465,8 @@ export const configMetadata: ConfigMetadataObject = { changeRequiresRestart: false, displayString: "freedom mode", group: "input", + description: + "Allows you to delete any word, even if it was typed correctly.", overrideConfig: ({ value }) => { if (value) { return { @@ -424,6 +482,8 @@ export const configMetadata: ConfigMetadataObject = { displayString: "strict space", changeRequiresRestart: true, group: "input", + description: + "Pressing space at the beginning of a word will insert a space character when this mode is enabled.", }, oppositeShiftMode: { key: "oppositeShiftMode", @@ -431,6 +491,8 @@ export const configMetadata: ConfigMetadataObject = { displayString: "opposite shift mode", changeRequiresRestart: false, group: "input", + description: + 'This mode will force you to use opposite shift keys for shifting. Using an incorrect one will count as an error. This feature ignores keys in locations B, Y, and ^ because many people use the other hand for those keys. If you\'re using external software to emulate your layout (including QMK), you should use the "keymap" mode - the standard "on" will not work. This will enforce opposite shift based on the "keymap layout" setting.', }, stopOnError: { key: "stopOnError", @@ -438,6 +500,8 @@ export const configMetadata: ConfigMetadataObject = { displayString: "stop on error", changeRequiresRestart: true, group: "input", + description: + "Letter mode will stop input when pressing any incorrect letters. Word mode will not allow you to continue to the next word until you correct all mistakes.", overrideConfig: ({ value }) => { if (value !== "off") { return { @@ -453,6 +517,8 @@ export const configMetadata: ConfigMetadataObject = { displayString: "confidence mode", changeRequiresRestart: false, group: "input", + description: + "When enabled, you will not be able to go back to previous words to fix mistakes. When turned up to the max, you won't be able to backspace at all.", overrideConfig: ({ value }) => { if (value !== "off") { return { @@ -469,6 +535,8 @@ export const configMetadata: ConfigMetadataObject = { displayString: "quick end", changeRequiresRestart: false, group: "input", + description: + "This only applies to the words mode - when enabled, the test will end as soon as the last word has been typed, even if it's incorrect. When disabled, you need to manually confirm the last incorrect entry with a space.", }, indicateTypos: { key: "indicateTypos", @@ -476,6 +544,8 @@ export const configMetadata: ConfigMetadataObject = { displayString: "indicate typos", changeRequiresRestart: false, group: "input", + description: + 'Shows typos that you\'ve made. "Below" shows what you typed below the letters, "replace" will replace the letters with the ones you typed and "both" will do the same as replace and below, but it will show the correct letters below your mistakes.', }, compositionDisplay: { key: "compositionDisplay", @@ -483,6 +553,8 @@ export const configMetadata: ConfigMetadataObject = { displayString: "composition display", changeRequiresRestart: false, group: "input", + description: + 'Change how composition is displayed. "off" will just underline the letter if composition is active. "below" will show the composed character below the test. "replace" will replace the letter in the test with the composed character.', }, hideExtraLetters: { key: "hideExtraLetters", @@ -490,6 +562,8 @@ export const configMetadata: ConfigMetadataObject = { displayString: "hide extra letters", changeRequiresRestart: false, group: "input", + description: + "Hides extra letters. This will completely avoid words jumping lines (due to changing width), but might feel a bit confusing when you press a key and nothing happens.", }, lazyMode: { key: "lazyMode", @@ -497,6 +571,8 @@ export const configMetadata: ConfigMetadataObject = { displayString: "lazy mode", changeRequiresRestart: true, group: "input", + description: + "Replaces accents / diacritics / special characters with their normal letter equivalents.", }, layout: { key: "layout", @@ -504,6 +580,8 @@ export const configMetadata: ConfigMetadataObject = { displayString: "layout", changeRequiresRestart: true, group: "input", + description: + "With this setting you can emulate other layouts. This setting is best kept off, as it can break things like dead keys and alt layers.", }, codeUnindentOnBackspace: { key: "codeUnindentOnBackspace", @@ -511,6 +589,8 @@ export const configMetadata: ConfigMetadataObject = { displayString: "code unindent on backspace", changeRequiresRestart: true, group: "input", + description: + "Automatically go back to the previous line when deleting line leading tab characters. Only works in code languages.", }, // sound @@ -520,27 +600,76 @@ export const configMetadata: ConfigMetadataObject = { displayString: "sound volume", changeRequiresRestart: false, group: "sound", + description: "Change the volume of the sound effects.", }, playSoundOnClick: { key: "playSoundOnClick", + optionsMetadata: { + off: {}, + "1": { displayString: "click" }, + "2": { displayString: "beep" }, + "3": { displayString: "pop" }, + "4": { displayString: "nk creams" }, + "5": { displayString: "typewriter" }, + "6": { displayString: "osu" }, + "7": { displayString: "hitmarker" }, + "8": { displayString: "sine" }, + "9": { displayString: "sawtooth" }, + "10": { displayString: "square" }, + "11": { displayString: "triangle" }, + "12": { displayString: "pentatonic" }, + "13": { displayString: "wholetone" }, + "14": { displayString: "fist fight" }, + "15": { displayString: "rubber keys" }, + "16": { displayString: "fart" }, + "17": { displayString: "akko lavenders" }, + "18": { displayString: "cherrymx black abs" }, + "19": { displayString: "cherrymx black pbt" }, + "20": { displayString: "cherrymx blue abs" }, + "21": { displayString: "cherrymx blue pbt" }, + "22": { displayString: "cherrymx brown pbt" }, + "23": { displayString: "kalih box white" }, + "24": { displayString: "razer green" }, + "25": { displayString: "tealios v2" }, + "26": { displayString: "trust gxt" }, + }, fa: { icon: "fa-volume-up" }, displayString: "play sound on click", changeRequiresRestart: false, group: "sound", + description: "Plays a short sound when you press a key.", }, playSoundOnError: { key: "playSoundOnError", + optionsMetadata: { + off: {}, + "1": { displayString: "damage" }, + "2": { displayString: "triangle" }, + "3": { displayString: "square" }, + "4": { displayString: "missed punch" }, + }, fa: { icon: "fa-volume-mute" }, displayString: "play sound on error", changeRequiresRestart: false, group: "sound", + description: + "Plays a short sound if you press an incorrect key or press space too early.", }, playTimeWarning: { key: "playTimeWarning", + optionsMetadata: { + off: {}, + "1": { displayString: "1 second" }, + "3": { displayString: "3 seconds" }, + "5": { displayString: "5 seconds" }, + "10": { displayString: "10 seconds" }, + }, fa: { icon: "fa-exclamation-triangle" }, displayString: "play time warning", changeRequiresRestart: false, group: "sound", + description: + "Play a short warning sound if you are close to the end of a timed test.", }, // caret @@ -550,6 +679,7 @@ export const configMetadata: ConfigMetadataObject = { displayString: "smooth caret", changeRequiresRestart: false, group: "caret", + description: "The caret will move smoothly between letters and words.", }, caretStyle: { key: "caretStyle", @@ -557,6 +687,8 @@ export const configMetadata: ConfigMetadataObject = { displayString: "caret style", changeRequiresRestart: false, group: "caret", + description: "Change the style of the caret during the test.", + optionsMetadata: caretOptionsMetadata, }, paceCaret: { key: "paceCaret", @@ -564,6 +696,19 @@ export const configMetadata: ConfigMetadataObject = { displayString: "pace caret", changeRequiresRestart: false, group: "caret", + description: + "Displays a second caret that moves at constant speed. The 'average' option averages the speed of last 10 results. The 'tag pb' option takes the highest PB of any active tag. The 'daily' option takes the highest speed of the last 24 hours.", + optionsMetadata: { + tagPb: { + displayString: "tag pb", + }, + average: {}, + custom: {}, + daily: {}, + last: {}, + off: {}, + pb: {}, + }, isBlocked: ({ value }) => { if (document.readyState === "complete") { if ((value === "pb" || value === "tagPb") && !isAuthenticated()) { @@ -597,6 +742,8 @@ export const configMetadata: ConfigMetadataObject = { displayString: "pace caret style", changeRequiresRestart: false, group: "caret", + description: "Change the style of the pace caret during the test.", + optionsMetadata: caretOptionsMetadata, }, repeatedPace: { key: "repeatedPace", @@ -604,6 +751,8 @@ export const configMetadata: ConfigMetadataObject = { displayString: "repeated pace", changeRequiresRestart: false, group: "caret", + description: + "When repeating a test, a pace caret will automatically be enabled for one test with the speed of your previous test. It does not override the pace caret if it's already enabled.", }, // appearance @@ -613,6 +762,8 @@ export const configMetadata: ConfigMetadataObject = { displayString: "live progress style", changeRequiresRestart: false, group: "appearance", + description: + 'Change the style of the timer/word count during a test. "Flash" styles will briefly show the timer in timed modes every 15 seconds.', }, liveSpeedStyle: { key: "liveSpeedStyle", @@ -620,6 +771,8 @@ export const configMetadata: ConfigMetadataObject = { displayString: "live speed style", changeRequiresRestart: false, group: "appearance", + description: + "Change the style of the live speed displayed during the test.", overrideConfig: ({ value }) => { if (value === "text") { return { @@ -635,6 +788,8 @@ export const configMetadata: ConfigMetadataObject = { displayString: "live accuracy style", changeRequiresRestart: false, group: "appearance", + description: + "Change the style of the live accuracy displayed during the test.", overrideConfig: ({ value }) => { if (value === "text") { return { @@ -650,6 +805,8 @@ export const configMetadata: ConfigMetadataObject = { displayString: "live word burst style", changeRequiresRestart: false, group: "appearance", + description: + "Change the style of the live burst speed displayed during the test.", }, timerColor: { key: "timerColor", @@ -657,6 +814,8 @@ export const configMetadata: ConfigMetadataObject = { displayString: "timer color", changeRequiresRestart: false, group: "appearance", + description: + "Change the color of the progress, live speed, accuracy and burst text.", }, timerOpacity: { key: "timerOpacity", @@ -664,6 +823,8 @@ export const configMetadata: ConfigMetadataObject = { displayString: "timer opacity", changeRequiresRestart: false, group: "appearance", + description: + "Change the opacity of the progress, live speed, burst and accuracy text.", }, highlightMode: { key: "highlightMode", @@ -671,6 +832,7 @@ export const configMetadata: ConfigMetadataObject = { displayString: "highlight mode", changeRequiresRestart: false, group: "appearance", + description: "Change what is highlighted during the test.", }, typedEffect: { key: "typedEffect", @@ -678,6 +840,7 @@ export const configMetadata: ConfigMetadataObject = { displayString: "typed effect", changeRequiresRestart: false, group: "appearance", + description: "Change how typed words are shown.", }, tapeMode: { key: "tapeMode", @@ -686,6 +849,8 @@ export const configMetadata: ConfigMetadataObject = { changeRequiresRestart: false, displayString: "tape mode", group: "appearance", + description: + "Only shows one line which scrolls horizontally. Setting this to 'word' will make it scroll after every word and 'letter' will scroll after every keypress. Works best with smooth line scroll enabled and a monospace font.", overrideConfig: ({ value }) => { if (value !== "off") { return { @@ -702,6 +867,8 @@ export const configMetadata: ConfigMetadataObject = { triggerResize: true, changeRequiresRestart: false, group: "appearance", + description: + "When in tape mode, set the carets position from the left edge of the typing test as a percentage (for example, 50% centers it).", }, smoothLineScroll: { key: "smoothLineScroll", @@ -709,6 +876,7 @@ export const configMetadata: ConfigMetadataObject = { displayString: "smooth line scroll", changeRequiresRestart: false, group: "appearance", + description: "When enabled, the line transition will be animated.", }, showAllLines: { key: "showAllLines", @@ -716,6 +884,8 @@ export const configMetadata: ConfigMetadataObject = { changeRequiresRestart: false, displayString: "show all lines", group: "appearance", + description: + "When enabled, the website will show all lines for word, custom and quote mode tests - otherwise the lines will be limited to 3, and will automatically scroll. Using this could cause the timer text and live speed to not be visible.", isBlocked: ({ value, currentConfig }) => { if (value && currentConfig.tapeMode !== "off") { showNoticeNotification("Show all lines doesn't support tape mode."); @@ -732,6 +902,8 @@ export const configMetadata: ConfigMetadataObject = { displayString: "always show decimal places", changeRequiresRestart: false, group: "appearance", + description: + "Always shows decimal places for values on the result page, without the need to hover over the stats.", }, typingSpeedUnit: { key: "typingSpeedUnit", @@ -739,6 +911,7 @@ export const configMetadata: ConfigMetadataObject = { displayString: "typing speed unit", changeRequiresRestart: false, group: "appearance", + description: "Display typing speed in the specified unit.", }, startGraphsAtZero: { key: "startGraphsAtZero", @@ -746,6 +919,8 @@ export const configMetadata: ConfigMetadataObject = { displayString: "start graphs at zero", changeRequiresRestart: false, group: "appearance", + description: + "Force graph axis to always start at zero, no matter what the data is. Turning this off may exaggerate the value changes.", }, maxLineWidth: { key: "maxLineWidth", @@ -754,6 +929,8 @@ export const configMetadata: ConfigMetadataObject = { triggerResize: true, displayString: "max line width", group: "appearance", + description: + "Change the maximum width of the typing test, measured in characters. Setting this to 0 will align the words to the edges of the content area.", }, fontSize: { key: "fontSize", @@ -762,6 +939,7 @@ export const configMetadata: ConfigMetadataObject = { triggerResize: true, displayString: "font size", group: "appearance", + description: "Change the font size of the test words.", }, fontFamily: { key: "fontFamily", @@ -769,6 +947,13 @@ export const configMetadata: ConfigMetadataObject = { displayString: "font family", changeRequiresRestart: false, group: "appearance", + description: + "Change the font family used by the website. Using a local font will override your choice. ", + optionsMetadata: { + Comic_Sans_MS: { + displayString: "Helvetica", + }, + }, }, keymapMode: { key: "keymapMode", @@ -776,6 +961,8 @@ export const configMetadata: ConfigMetadataObject = { displayString: "keymap mode", changeRequiresRestart: false, group: "appearance", + description: + "Displays your current layout while taking a test. React shows what you pressed and Next shows what you need to press next.", }, keymapLayout: { key: "keymapLayout", @@ -783,6 +970,7 @@ export const configMetadata: ConfigMetadataObject = { displayString: "keymap layout", changeRequiresRestart: false, group: "appearance", + description: "Controls which layout is displayed on the keymap.", overrideConfig: ({ currentConfig }) => currentConfig.keymapMode === "off" ? { keymapMode: "static" } : {}, }, @@ -820,6 +1008,7 @@ export const configMetadata: ConfigMetadataObject = { changeRequiresRestart: false, displayString: "keymap size", group: "appearance", + description: "Change the size of the keymap.", overrideValue: ({ value }) => { if (value < 0.5) value = 0.5; if (value > 3.5) value = 3.5; @@ -836,6 +1025,8 @@ export const configMetadata: ConfigMetadataObject = { displayString: "flip test colors", changeRequiresRestart: false, group: "theme", + description: + "By default, typed text is brighter than the future text. When enabled, the colors will be flipped and the future text will be brighter than the already typed text.", }, colorfulMode: { key: "colorfulMode", @@ -843,16 +1034,20 @@ export const configMetadata: ConfigMetadataObject = { displayString: "colorful mode", changeRequiresRestart: false, group: "theme", + description: + "When enabled, the test words will use the main color, instead of the text color, making the website more colorful.", }, customBackground: { key: "customBackground", fa: { icon: "fa-link" }, - displayString: "URL background", + displayString: "custom background", changeRequiresRestart: false, group: "theme", overrideValue: ({ value }) => { return value.trim(); }, + description: + "Set an image url or local image to be a custom background image. Local image always take priority over the image url. Cover fits the image to cover the screen. Contain fits the image to be fully visible. Max fits the image corner to corner.", }, customBackgroundSize: { key: "customBackgroundSize", @@ -860,6 +1055,8 @@ export const configMetadata: ConfigMetadataObject = { displayString: "custom background size", changeRequiresRestart: false, group: "theme", + description: + "Set an image url or local image to be a custom background image. Cover fits the image to cover the screen. Contain fits the image to be fully visible. Max fits the image corner to corner.", }, customBackgroundFilter: { key: "customBackgroundFilter", @@ -867,6 +1064,7 @@ export const configMetadata: ConfigMetadataObject = { displayString: "custom background filter", changeRequiresRestart: false, group: "theme", + description: "Apply various effects to the custom background.", }, autoSwitchTheme: { key: "autoSwitchTheme", @@ -874,6 +1072,8 @@ export const configMetadata: ConfigMetadataObject = { displayString: "auto switch theme", changeRequiresRestart: false, group: "theme", + description: + "Enabling this will automatically switch the theme between light and dark depending on the system theme.", }, themeLight: { key: "themeLight", @@ -895,22 +1095,28 @@ export const configMetadata: ConfigMetadataObject = { changeRequiresRestart: false, displayString: "random theme", group: "theme", + description: + "After completing a test, the theme will be set to a random one. The random themes are not saved to your config. If set to 'favorite' only favorite themes will be randomized. If set to 'light' or 'dark', only presets with light or dark background colors will be randomized, respectively. If set to 'auto' dark or light themes are used, depending on your system theme. If set to 'custom', custom themes will be randomized.", + optionsMetadata: { + fav: { + displayString: "favorite", + }, + auto: {}, + custom: {}, + dark: {}, + light: {}, + off: {}, + on: {}, + }, isBlocked: ({ value }) => { if (value === "custom") { - const snapshot = DB.getSnapshot(); if (!isAuthenticated()) { showNoticeNotification( "Random theme 'custom' is unavailable without an account", ); return true; } - if (!snapshot) { - showNoticeNotification( - "Random theme 'custom' requires a snapshot to be set", - ); - return true; - } - if (snapshot?.customThemes?.length === 0) { + if (CustomThemes.__nonReactive.getCustomThemes().length === 0) { showNoticeNotification( "Random theme 'custom' requires at least one custom theme to be saved", ); @@ -932,6 +1138,8 @@ export const configMetadata: ConfigMetadataObject = { fa: { icon: "fa-palette" }, changeRequiresRestart: false, group: "theme", + description: + "Completely change the look and feel of the website by picking one of the presets, or by creating your own completely custom theme.", overrideConfig: () => { return { customTheme: false, @@ -968,6 +1176,11 @@ export const configMetadata: ConfigMetadataObject = { displayString: "show key tips", changeRequiresRestart: false, group: "hideElements", + description: "Shows the keybind tips at the bottom of the page.", + optionsMetadata: { + true: { displayString: "show" }, + false: { displayString: "hide" }, + }, }, showOutOfFocusWarning: { key: "showOutOfFocusWarning", @@ -975,6 +1188,12 @@ export const configMetadata: ConfigMetadataObject = { displayString: "show out of focus warning", changeRequiresRestart: false, group: "hideElements", + description: + "Shows an out of focus reminder after 1 second of being 'out of focus' (not being able to type).", + optionsMetadata: { + true: { displayString: "show" }, + false: { displayString: "hide" }, + }, }, capsLockWarning: { key: "capsLockWarning", @@ -982,6 +1201,11 @@ export const configMetadata: ConfigMetadataObject = { displayString: "caps lock warning", changeRequiresRestart: false, group: "hideElements", + description: "Displays a warning when caps lock is on.", + optionsMetadata: { + true: { displayString: "show" }, + false: { displayString: "hide" }, + }, }, showAverage: { key: "showAverage", @@ -989,6 +1213,8 @@ export const configMetadata: ConfigMetadataObject = { displayString: "show average", changeRequiresRestart: false, group: "hideElements", + description: + "Displays your average speed and/or accuracy over the last 10 tests.", }, showPb: { key: "showPb", @@ -1050,6 +1276,7 @@ export const configMetadata: ConfigMetadataObject = { key: "ads", fa: { icon: "fa-ad" }, changeRequiresRestart: false, + description: `You can disable or enable ads at any time. "Result" will show one ad on the result page, "on" will add floating vertical banners, and "sellout" will add multiple ads on every page.`, group: "ads", overrideValue: ({ value }) => { if (isDevEnvironment()) { diff --git a/frontend/src/ts/config/setters.ts b/frontend/src/ts/config/setters.ts index 385b948fc428..de181296161f 100644 --- a/frontend/src/ts/config/setters.ts +++ b/frontend/src/ts/config/setters.ts @@ -185,8 +185,7 @@ export function toggleFunbox(funbox: FunboxName, nosave?: boolean): boolean { if (newConfig.includes(funbox)) { newConfig = newConfig.filter((it) => it !== funbox); } else { - newConfig.push(funbox); - newConfig.sort(); + newConfig = [...newConfig, funbox].sort(); } if (!isConfigValueValid("funbox", newConfig, ConfigSchemas.FunboxSchema)) { @@ -202,5 +201,7 @@ export function toggleFunbox(funbox: FunboxName, nosave?: boolean): boolean { previousValue, }); + setConfigStore("funbox", newConfig); + return true; } diff --git a/frontend/src/ts/config/store.ts b/frontend/src/ts/config/store.ts index 1e73844649c3..5d35f3bd94b1 100644 --- a/frontend/src/ts/config/store.ts +++ b/frontend/src/ts/config/store.ts @@ -1,10 +1,29 @@ import type { Config as ConfigSchema } from "@monkeytype/schemas/configs"; import { getDefaultConfig } from "../constants/default-config"; -import { createStore } from "solid-js/store"; +import { createStore, reconcile } from "solid-js/store"; export const Config: ConfigSchema = { ...getDefaultConfig(), }; -export const [getConfig, setConfigStore] = +const [getConfigStore, setConfigStoreRaw] = createStore(getDefaultConfig()); + +export const getConfig = getConfigStore; + +export function setFullConfigStore(newConfig: ConfigSchema): void { + setConfigStoreRaw(reconcile(newConfig)); +} + +export function setConfigStore( + key: K, + value: ConfigSchema[K], +): void { + if (Array.isArray(value)) { + setConfigStoreRaw(key, reconcile(value)); + } else { + setConfigStoreRaw(key, value); + } +} + +// window.getConfigStore = getConfigStore; diff --git a/frontend/src/ts/constants/default-snapshot.ts b/frontend/src/ts/constants/default-snapshot.ts index 17ca8074d841..74f192019a34 100644 --- a/frontend/src/ts/constants/default-snapshot.ts +++ b/frontend/src/ts/constants/default-snapshot.ts @@ -54,6 +54,7 @@ export type Snapshot = Omit< | "streak" | "resultFilterPresets" | "tags" + | "customThemes" | "xp" | "testActivity" > & { @@ -87,7 +88,6 @@ const defaultSnap = { uid: "", isPremium: false, config: getDefaultConfig(), - customThemes: [], banned: undefined, verified: undefined, lbMemory: { time: { 15: { english: 0 }, 60: { english: 0 } } }, diff --git a/frontend/src/ts/controllers/page-controller.ts b/frontend/src/ts/controllers/page-controller.ts index 1a30cb96e34c..542efcea1ba7 100644 --- a/frontend/src/ts/controllers/page-controller.ts +++ b/frontend/src/ts/controllers/page-controller.ts @@ -5,7 +5,6 @@ import { setActivePage, setSelectedProfileName, } from "../states/core"; -import * as Settings from "../pages/settings"; import * as PageTest from "../pages/test"; import * as PageLoading from "../pages/loading"; import * as Friends from "../pages/friends"; @@ -43,7 +42,32 @@ type ChangeOptions = { const pages = { loading: PageLoading.page, test: PageTest.page, - settings: Settings.page, + settings: solidPage("settings", { + beforeShow: async () => { + const highlight = new URLSearchParams(window.location.search).get( + "highlight", + ); + if (highlight === null) return; + + // clear any previous highlight + const prev = document.querySelector( + '[data-component="settingspage"] .settings-highlight', + ); + if (prev !== null) { + prev.classList.remove("settings-highlight"); + } + + const element = document.querySelector( + `[data-component="settingspage"] [data-setting-key="${CSS.escape(highlight)}"]`, + ); + if (element === null) return; + + setTimeout(() => { + element.scrollIntoView({ block: "center", behavior: "auto" }); + element.classList.add("settings-highlight"); + }, 250); + }, + }), about: solidPage("about"), account: solidPage("account", { loadingOptions: { diff --git a/frontend/src/ts/controllers/theme-controller.ts b/frontend/src/ts/controllers/theme-controller.ts index 7fca36a702dd..5f5c3c979c7d 100644 --- a/frontend/src/ts/controllers/theme-controller.ts +++ b/frontend/src/ts/controllers/theme-controller.ts @@ -3,9 +3,8 @@ import { isColorDark, isColorLight } from "../utils/colors"; import { Config } from "../config/store"; import { setConfig } from "../config/setters"; -import * as BackgroundFilter from "../elements/custom-background-filter"; import { configEvent } from "../events/config"; -import * as DB from "../db"; +import * as CustomThemes from "../collections/custom-themes"; import { showNoticeNotification } from "../states/notifications"; import { debounce } from "throttle-debounce"; import { CustomThemeColors, ThemeName } from "@monkeytype/schemas/configs"; @@ -88,10 +87,11 @@ function updateThemeIndicator(nameOverride?: string): void { if (Config.customTheme && nameOverride === undefined) { // Match current custom theme by colors since Config does not store custom theme IDs - const snapshot = DB.getSnapshot(); - const matchedTheme = snapshot?.customThemes?.find((ct) => - Arrays.areSortedArraysEqual(ct.colors, Config.customThemeColors), - ); + const matchedTheme = CustomThemes.__nonReactive + .getCustomThemes() + .find((ct) => + Arrays.areSortedArraysEqual(ct.colors, Config.customThemeColors), + ); if (matchedTheme) { str = `${matchedTheme.name} (custom)`; @@ -181,8 +181,10 @@ async function changeThemeList(): Promise { themesList = themes.map((t) => { return t.name; }); - } else if (Config.randomTheme === "custom" && DB.getSnapshot()) { - themesList = DB.getSnapshot()?.customThemes?.map((ct) => ct._id) ?? []; + } else if (Config.randomTheme === "custom") { + themesList = CustomThemes.__nonReactive + .getCustomThemes() + .map((ct) => ct._id); } Arrays.shuffle(themesList); randomThemeIndex = 0; @@ -213,8 +215,8 @@ export async function randomizeTheme(): Promise { let colorsOverride: CustomThemeColors | undefined; if (Config.randomTheme === "custom") { - const theme = DB.getSnapshot()?.customThemes?.find( - (ct) => ct._id === randomTheme, + const theme = CustomThemes.__nonReactive.getCustomTheme( + randomTheme as string, ); colorsOverride = theme?.colors; randomTheme = "custom"; @@ -229,7 +231,7 @@ export async function randomizeTheme(): Promise { let name = randomTheme.replace(/_/g, " "); if (Config.randomTheme === "custom") { name = ( - DB.getSnapshot()?.customThemes?.find((ct) => ct._id === randomTheme) + CustomThemes.__nonReactive.getCustomTheme(randomTheme as string) ?.name ?? "custom" ).replace(/_/g, " "); } @@ -261,9 +263,6 @@ function applyCustomBackgroundSize(): void { export async function applyCustomBackground(): Promise { let backgroundUrl = Config.customBackground; - qs( - ".pageSettings .section[data-config-name='customBackgroundSize'] input[type='text']", - )?.setValue(backgroundUrl); //if there is a localBackgroundFile available, use it. const localBackgroundFile = await fileStorage.getFile("LocalBackgroundFile"); @@ -303,7 +302,6 @@ export async function applyCustomBackground(): Promise { container?.replaceChildren(img); - BackgroundFilter.apply(); applyCustomBackgroundSize(); } } diff --git a/frontend/src/ts/cookies.ts b/frontend/src/ts/cookies.ts index 168342095b54..b5a0bc34ef36 100644 --- a/frontend/src/ts/cookies.ts +++ b/frontend/src/ts/cookies.ts @@ -1,4 +1,5 @@ import { z } from "zod"; +import { createSignal } from "solid-js"; import { LocalStorageWithSchema } from "./utils/local-storage-with-schema"; import { activateAnalytics } from "./controllers/analytics-controller"; import { activateSentry } from "./sentry"; @@ -14,23 +15,25 @@ const AcceptedCookiesSchema = z export type AcceptedCookies = z.infer; +const cookies = new LocalStorageWithSchema({ + key: "acceptedCookies", + schema: AcceptedCookiesSchema, + fallback: null, + // no migration here, if cookies changed, we need to ask the user again +}); + +const [acceptedCookies, _setAcceptedCookies] = createSignal(cookies.get()); + export function getAcceptedCookies(): AcceptedCookies | null { - return cookies.get(); + return acceptedCookies(); } export function setAcceptedCookies(accepted: AcceptedCookies): void { cookies.set(accepted); - + _setAcceptedCookies(accepted); activateWhatsAccepted(); } -const cookies = new LocalStorageWithSchema({ - key: "acceptedCookies", - schema: AcceptedCookiesSchema, - fallback: null, - // no migration here, if cookies changed, we need to ask the user again -}); - export function activateWhatsAccepted(): void { const accepted = getAcceptedCookies(); if (accepted?.analytics) { diff --git a/frontend/src/ts/db.ts b/frontend/src/ts/db.ts index 8a822e1f7cb1..c9f3cf53220b 100644 --- a/frontend/src/ts/db.ts +++ b/frontend/src/ts/db.ts @@ -1,8 +1,5 @@ import Ape from "./ape"; -import { - showNoticeNotification, - showErrorNotification, -} from "./states/notifications"; +import { showErrorNotification } from "./states/notifications"; import { getAuthenticatedUser } from "./firebase"; import { isAuthenticated } from "./states/core"; import * as Dates from "date-fns"; @@ -11,7 +8,7 @@ import { ModifiableTestActivityCalendar, } from "./elements/test-activity-calendar"; import { showLoaderBar, hideLoaderBar } from "./states/loader-bar"; -import { Badge, CustomTheme } from "@monkeytype/schemas/users"; +import { Badge } from "@monkeytype/schemas/users"; import { Difficulty } from "@monkeytype/schemas/configs"; import { Mode, @@ -171,8 +168,6 @@ export async function initSnapshot(): Promise { snap.lbMemory = userData.lbMemory; } - snap.customThemes = userData.customThemes ?? []; - updateTagsInFilterStorage(userData.tags?.map((it) => it._id) ?? []); snap.connections = convertConnections(connectionsData); @@ -186,95 +181,6 @@ export async function initSnapshot(): Promise { setSolidSnapshot(dbSnapshot); } } - -export async function addCustomTheme( - theme: Omit, -): Promise { - if (!dbSnapshot) return false; - - dbSnapshot.customThemes ??= []; - - if (dbSnapshot.customThemes.length >= 20) { - showNoticeNotification("Too many custom themes!"); - return false; - } - - const response = await Ape.users.addCustomTheme({ body: { ...theme } }); - if (response.status !== 200) { - showErrorNotification("Error adding custom theme", { response }); - return false; - } - - if (response.body.data === null) { - showErrorNotification("Error adding custom theme: No data returned"); - return false; - } - - const newCustomTheme: CustomTheme = { - ...theme, - _id: response.body.data._id, - }; - - dbSnapshot.customThemes.push(newCustomTheme); - return true; -} - -export async function editCustomTheme( - themeId: string, - newTheme: Omit, -): Promise { - if (!isAuthenticated()) return false; - if (!dbSnapshot) return false; - - dbSnapshot.customThemes ??= []; - - const customTheme = dbSnapshot.customThemes?.find((t) => t._id === themeId); - if (!customTheme) { - showErrorNotification( - `Editing failed: Custom theme with id: ${themeId} does not exist`, - ); - return false; - } - - const response = await Ape.users.editCustomTheme({ - body: { themeId, theme: newTheme }, - }); - if (response.status !== 200) { - showErrorNotification("Error editing custom theme", { response }); - return false; - } - - const newCustomTheme: CustomTheme = { - ...newTheme, - _id: themeId, - }; - - dbSnapshot.customThemes[dbSnapshot.customThemes.indexOf(customTheme)] = - newCustomTheme; - - return true; -} - -export async function deleteCustomTheme(themeId: string): Promise { - if (!isAuthenticated()) return false; - if (!dbSnapshot) return false; - - const customTheme = dbSnapshot.customThemes?.find((t) => t._id === themeId); - if (!customTheme) return false; - - const response = await Ape.users.deleteCustomTheme({ body: { themeId } }); - if (response.status !== 200) { - showErrorNotification("Error deleting custom theme", { response }); - return false; - } - - dbSnapshot.customThemes = dbSnapshot.customThemes?.filter( - (t) => t._id !== themeId, - ); - - return true; -} - export async function getLocalPB( mode: M, mode2: Mode2, diff --git a/frontend/src/ts/elements/custom-background-filter.ts b/frontend/src/ts/elements/custom-background-filter.ts deleted file mode 100644 index b335933e7893..000000000000 --- a/frontend/src/ts/elements/custom-background-filter.ts +++ /dev/null @@ -1,141 +0,0 @@ -import { CustomBackgroundFilter } from "@monkeytype/schemas/configs"; -import { setConfig } from "../config/setters"; -import { configEvent } from "../events/config"; -import { debounce } from "throttle-debounce"; -import { qs, qsr } from "../utils/dom"; - -const section = qsr( - ".pageSettings .section[data-config-name='customBackgroundFilter']", -); - -const filters = { - blur: { - value: 0, - default: 0, - }, - brightness: { - value: 1, - default: 1, - }, - saturate: { - value: 1, - default: 1, - }, - opacity: { - value: 1, - default: 1, - }, -}; - -function getCSS(): string { - let ret = ""; - Object.keys(filters).forEach((filterKey) => { - const key = filterKey as keyof typeof filters; - if (filters[key].value !== filters[key].default) { - ret += `${filterKey}(${filters[key].value}${ - filterKey === "blur" ? "rem" : "" - }) `; - } - }); - return ret; -} - -export function apply(): void { - const filterCSS = getCSS(); - const css = { - filter: filterCSS, - width: `calc(100% + ${filters.blur.value * 4}rem)`, - height: `calc(100% + ${filters.blur.value * 4}rem)`, - // left: `-${filters.blur.value * 2}rem`, - // top: `-${filters.blur.value * 2}rem`, - position: "absolute", - }; - qs(".customBackground img")?.setStyle(css); -} - -function syncSliders(): void { - section - .qs(".blur-filter input") - ?.setValue(filters.blur.value.toString()); - section - .qs(".brightness input") - ?.setValue(filters.brightness.value.toString()); - section - .qs(".saturate input") - ?.setValue(filters.saturate.value.toString()); - section - .qs(".opacity input") - ?.setValue(filters.opacity.value.toString()); -} - -function updateNumbers(): void { - section.qs(".blur-filter .value")?.setHtml(filters.blur.value.toFixed(1)); - section - .qs(".brightness .value") - ?.setHtml(filters.brightness.value.toFixed(1)); - section.qs(".saturate .value")?.setHtml(filters.saturate.value.toFixed(1)); - section.qs(".opacity .value")?.setHtml(filters.opacity.value.toFixed(1)); -} - -export function updateUI(): void { - syncSliders(); - updateNumbers(); -} - -function loadConfig(config: CustomBackgroundFilter): void { - filters.blur.value = config[0]; - filters.brightness.value = config[1]; - filters.saturate.value = config[2]; - filters.opacity.value = config[3]; - updateUI(); -} - -section.qs(".blur-filter input")?.on("input", () => { - filters.blur.value = parseFloat( - section.qs(".blur-filter input")?.getValue() ?? "0", - ); - updateNumbers(); - apply(); -}); - -section.qs(".brightness input")?.on("input", () => { - filters.brightness.value = parseFloat( - section.qs(".brightness input")?.getValue() ?? "1", - ); - updateNumbers(); - apply(); -}); - -section.qs(".saturate input")?.on("input", () => { - filters.saturate.value = parseFloat( - section.qs(".saturate input")?.getValue() ?? "1", - ); - updateNumbers(); - apply(); -}); - -section.qs(".opacity input")?.on("input", () => { - filters.opacity.value = parseFloat( - section.qs(".opacity input")?.getValue() ?? "1", - ); - updateNumbers(); - apply(); -}); - -section.qsa("input")?.on("input", () => { - debouncedSave(); -}); - -const debouncedSave = debounce(2000, async () => { - const arr = Object.keys(filters).map( - (filterKey) => filters[filterKey as keyof typeof filters].value, - ) as CustomBackgroundFilter; - setConfig("customBackgroundFilter", arr); -}); - -configEvent.subscribe(({ key, newValue }) => { - if (key === "customBackgroundFilter") { - loadConfig(newValue); - apply(); - } -}); diff --git a/frontend/src/ts/elements/settings/account-settings-notice.ts b/frontend/src/ts/elements/settings/account-settings-notice.ts deleted file mode 100644 index 52bc92a9743a..000000000000 --- a/frontend/src/ts/elements/settings/account-settings-notice.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { z } from "zod"; -import { LocalStorageWithSchema } from "../../utils/local-storage-with-schema"; -import { navigate } from "../../controllers/route-controller"; -import { qsa } from "../../utils/dom"; - -const ls = new LocalStorageWithSchema({ - key: "accountSettingsMessageDismissed", - schema: z.boolean(), - fallback: false, -}); - -if (ls.get()) { - qsa(".pageSettings .accountSettingsNotice")?.remove(); -} - -qsa(".pageSettings .accountSettingsNotice .dismissAndGo").on("click", () => { - ls.set(true); - void navigate("/account-settings"); - qsa(".pageSettings .accountSettingsNotice")?.remove(); -}); diff --git a/frontend/src/ts/elements/settings/custom-background-picker.ts b/frontend/src/ts/elements/settings/custom-background-picker.ts deleted file mode 100644 index 84a2df5375ff..000000000000 --- a/frontend/src/ts/elements/settings/custom-background-picker.ts +++ /dev/null @@ -1,68 +0,0 @@ -import FileStorage from "../../utils/file-storage"; -import { showNoticeNotification } from "../../states/notifications"; -import { applyCustomBackground } from "../../controllers/theme-controller"; - -const parentEl = document.querySelector( - ".pageSettings .section[data-config-name='customBackgroundSize']", -); -const usingLocalImageEl = parentEl?.querySelector(".usingLocalImage"); -const separatorEl = parentEl?.querySelector(".separator"); -const uploadContainerEl = parentEl?.querySelector(".uploadContainer"); -const inputAndButtonEl = parentEl?.querySelector(".inputAndButton"); - -async function readFileAsDataURL(file: File): Promise { - return new Promise((resolve, reject) => { - const reader = new FileReader(); - reader.onload = () => resolve(reader.result as string); - reader.onerror = reject; - reader.readAsDataURL(file); - }); -} - -export async function updateUI(): Promise { - if (await FileStorage.hasFile("LocalBackgroundFile")) { - usingLocalImageEl?.classList.remove("hidden"); - separatorEl?.classList.add("hidden"); - uploadContainerEl?.classList.add("hidden"); - inputAndButtonEl?.classList.add("hidden"); - } else { - usingLocalImageEl?.classList.add("hidden"); - separatorEl?.classList.remove("hidden"); - uploadContainerEl?.classList.remove("hidden"); - inputAndButtonEl?.classList.remove("hidden"); - } -} - -usingLocalImageEl - ?.querySelector("button") - ?.addEventListener("click", async () => { - await FileStorage.deleteFile("LocalBackgroundFile"); - await updateUI(); - await applyCustomBackground(); - }); - -uploadContainerEl - ?.querySelector("input[type='file']") - ?.addEventListener("change", async (e) => { - const fileInput = e.target as HTMLInputElement; - const file = fileInput.files?.[0]; - - if (!file) { - return; - } - - // check type - if (!/image\/(jpeg|jpg|png|gif|webp)/.exec(file.type)) { - showNoticeNotification("Unsupported image format"); - fileInput.value = ""; - return; - } - - const dataUrl = await readFileAsDataURL(file); - await FileStorage.storeFile("LocalBackgroundFile", dataUrl); - - await updateUI(); - await applyCustomBackground(); - - fileInput.value = ""; - }); diff --git a/frontend/src/ts/elements/settings/custom-font-picker.ts b/frontend/src/ts/elements/settings/custom-font-picker.ts deleted file mode 100644 index 65129755135a..000000000000 --- a/frontend/src/ts/elements/settings/custom-font-picker.ts +++ /dev/null @@ -1,73 +0,0 @@ -import FileStorage from "../../utils/file-storage"; -import { showNoticeNotification } from "../../states/notifications"; -import { applyFontFamily } from "../../ui"; - -const parentEl = document.querySelector( - ".pageSettings .section[data-config-name='fontFamily']", -); -const usingLocalFontEl = parentEl?.querySelector(".usingLocalFont"); -const separatorEl = parentEl?.querySelector(".separator"); -const uploadContainerEl = parentEl?.querySelector(".uploadContainer"); -const inputAndButtonEl = parentEl?.querySelector(".buttons"); - -async function readFileAsDataURL(file: File): Promise { - return new Promise((resolve, reject) => { - const reader = new FileReader(); - reader.onload = () => resolve(reader.result as string); - reader.onerror = reject; - reader.readAsDataURL(file); - }); -} - -export async function updateUI(): Promise { - if (await FileStorage.hasFile("LocalFontFamilyFile")) { - usingLocalFontEl?.classList.remove("hidden"); - separatorEl?.classList.add("hidden"); - uploadContainerEl?.classList.add("hidden"); - inputAndButtonEl?.classList.add("hidden"); - } else { - usingLocalFontEl?.classList.add("hidden"); - separatorEl?.classList.remove("hidden"); - uploadContainerEl?.classList.remove("hidden"); - inputAndButtonEl?.classList.remove("hidden"); - } -} - -usingLocalFontEl - ?.querySelector("button") - ?.addEventListener("click", async () => { - await FileStorage.deleteFile("LocalFontFamilyFile"); - await updateUI(); - await applyFontFamily(); - }); - -uploadContainerEl - ?.querySelector("input[type='file']") - ?.addEventListener("change", async (e) => { - const fileInput = e.target as HTMLInputElement; - const file = fileInput.files?.[0]; - - if (!file) { - return; - } - - // check type - if ( - !/font\/(woff|woff2|ttf|otf)/.exec(file.type) && - !/\.(woff|woff2|ttf|otf)$/i.exec(file.name) - ) { - showNoticeNotification( - "Unsupported font format, must be woff, woff2, ttf or otf.", - ); - fileInput.value = ""; - return; - } - - const dataUrl = await readFileAsDataURL(file); - await FileStorage.storeFile("LocalFontFamilyFile", dataUrl); - - await updateUI(); - await applyFontFamily(); - - fileInput.value = ""; - }); diff --git a/frontend/src/ts/elements/settings/fps-limit-section.ts b/frontend/src/ts/elements/settings/fps-limit-section.ts deleted file mode 100644 index 5c45cae0aca8..000000000000 --- a/frontend/src/ts/elements/settings/fps-limit-section.ts +++ /dev/null @@ -1,53 +0,0 @@ -import { getfpsLimit, fpsLimitSchema, setfpsLimit } from "../../anim"; -import { qsr } from "../../utils/dom"; -import { ValidatedHtmlInputElement } from "../input-validation"; -import { showNoticeNotification } from "../../states/notifications"; - -const section = qsr("#pageSettings .section.fpsLimit"); - -const button = section?.qs("button[data-fpsLimit='native']"); - -const input = new ValidatedHtmlInputElement( - section.qsr('input[type="number"]'), - { - schema: fpsLimitSchema, - inputValueConvert: (val: string) => parseInt(val, 10), - }, -); - -export function update(): void { - const fpsLimit = getfpsLimit(); - if (fpsLimit >= 1000) { - input.setValue(null); - button?.addClass("active"); - } else { - input.setValue(fpsLimit.toString()); - button?.removeClass("active"); - } -} - -function save(value: number): void { - if (getfpsLimit() !== value && setfpsLimit(value)) { - showNoticeNotification("FPS limit updated"); - } - update(); -} - -function saveFromInput(): void { - if (input.getValidationResult().status !== "success") return; - const val = parseInt(input.getValue() ?? "", 10); - save(val); -} - -button?.on("click", () => { - save(1000); - update(); -}); - -input.on("keypress", (e) => { - if (e.key === "Enter") { - saveFromInput(); - } -}); - -input.on("focusout", (e) => saveFromInput()); diff --git a/frontend/src/ts/elements/settings/settings-group.ts b/frontend/src/ts/elements/settings/settings-group.ts deleted file mode 100644 index a59d20120f73..000000000000 --- a/frontend/src/ts/elements/settings/settings-group.ts +++ /dev/null @@ -1,254 +0,0 @@ -import { Config as ConfigType, ConfigKey } from "@monkeytype/schemas/configs"; -import { Config } from "../../config/store"; -import { setConfig } from "../../config/setters"; -import { showErrorNotification } from "../../states/notifications"; -import SlimSelect from "slim-select"; -import { debounce } from "throttle-debounce"; -import { handleConfigInput, ConfigInputOptions } from "../input-validation"; -import { ElementWithUtils, qs, qsa } from "../../utils/dom"; -import { Validation } from "../../types/validation"; - -type Mode = "select" | "button" | "range" | "input"; - -export type SimpleValidation = Omit, "schema"> & { - schema?: true; -}; - -export default class SettingsGroup { - public configName: K; - public configFunction: (param: T, nosave?: boolean) => boolean; - public mode: Mode; - public setCallback?: () => void; - public updateCallback?: () => void; - private elements: ElementWithUtils[]; - private validation?: T extends string - ? SimpleValidation - : SimpleValidation & { - inputValueConvert: (val: string) => T; - }; - - constructor( - configName: K, - mode: Mode, - options?: { - setCallback?: () => void; - updateCallback?: () => void; - validation?: T extends string - ? SimpleValidation - : SimpleValidation & { - inputValueConvert: (val: string) => T; - }; - }, - ) { - this.configName = configName; - this.mode = mode; - this.configFunction = (param, nosave) => - setConfig(configName, param as ConfigType[K], { - nosave: nosave ?? false, - }); - this.setCallback = options?.setCallback; - this.updateCallback = options?.updateCallback; - this.validation = options?.validation; - - const convertValue = (value: string): T => { - let typed = value as T; - if ( - this.validation !== undefined && - "inputValueConvert" in this.validation - ) { - typed = this.validation.inputValueConvert(value); - } - if (typed === "true") typed = true as T; - if (typed === "false") typed = false as T; - - return typed; - }; - - if (this.mode === "select") { - const el = qs( - `.pageSettings .section[data-config-name=${this.configName}] select`, - ); - - if (el === null) { - throw new Error(`Failed to find select element for ${configName}`); - } - - if (el.hasAttribute("multiple")) { - throw new Error( - `multi-select dropdowns not supported. Config: ${this.configName}`, - ); - } - - el.on("change", (e) => { - if ( - e.target instanceof HTMLElement && - (e.target?.classList?.contains("disabled") || - e.target?.classList?.contains("no-auto-handle")) - ) { - return; - } - - this.setValue(el.getValue() as T); - }); - - this.elements = [el]; - } else if (this.mode === "button") { - const els = qsa(` - .pageSettings .section[data-config-name=${this.configName}] .buttons button, .pageSettings .section[data-config-name=${this.configName}] .inputs button`); - - if (els.length === 0) { - throw new Error(`Failed to find a button element for ${configName}`); - } - - for (const button of els) { - button.on("click", (e) => { - if ( - button.hasClass("disabled") || - button.hasClass("no-auto-handle") - ) { - return; - } - - let value = button.getAttribute("data-config-value"); - if (value === null || value === "") { - console.error( - `Failed to handle settings button click for ${configName}: data-${configName} is missing or empty.`, - ); - showErrorNotification( - "Button is missing data property. Please report this.", - ); - return; - } - - let typed = convertValue(value); - this.setValue(typed); - }); - } - - this.elements = Array.from(els); - } else if (this.mode === "input") { - const input = qs(` - .pageSettings .section[data-config-name=${this.configName}] .inputs .inputAndButton input`); - if (input === null) { - throw new Error(`Failed to find input element for ${configName}`); - } - - let validation; - if (this.validation !== undefined) { - validation = { - schema: this.validation.schema ?? false, - isValid: this.validation.isValid, - inputValueConvert: - "inputValueConvert" in this.validation - ? this.validation.inputValueConvert - : undefined, - }; - } - - handleConfigInput({ - input, - configName: this.configName, - validation, - } as ConfigInputOptions); - - this.elements = [input]; - } else if (this.mode === "range") { - const el = qs( - `.pageSettings .section[data-config-name=${this.configName}] input[type=range]`, - ); - - if (el === null) { - throw new Error(`Failed to find range element for ${configName}`); - } - - const debounced = debounce<(val: T) => void>(250, (val) => { - this.setValue(val); - }); - - el.on("input", (e) => { - if (el.hasClass("disabled") || el.hasClass("no-auto-handle")) { - return; - } - const val = parseFloat(el.getValue() ?? "") as T; - this.updateUI(val); - debounced(val); - }); - - this.elements = [el]; - } else { - this.elements = []; - } - - if (this.elements.length === 0 || this.elements === undefined) { - throw new Error( - `Failed to find elements for ${configName} with mode ${mode}`, - ); - } - - this.updateUI(); - } - - setValue(value: T): boolean { - if (Config[this.configName] === value) { - return false; - } - const didSet = this.configFunction(value); - this.updateUI(); - if (this.setCallback) this.setCallback(); - return didSet; - } - - updateUI(valueOverride?: T): void { - const newValue = valueOverride ?? (Config[this.configName] as T); - - if (this.mode === "select") { - const select = this.elements?.[0] as - | ElementWithUtils - | undefined; - if (!select) { - return; - } - - //@ts-expect-error this is fine, slimselect adds slim to the element - const ss = select.native.slim as SlimSelect | undefined; - if (ss !== undefined) { - const currentSelected = ss.getSelected()[0] ?? null; - if (newValue !== currentSelected) { - ss.setSelected(newValue as string); - } - } else { - if (select.getValue() !== newValue) select.setValue(newValue as string); - } - } else if (this.mode === "button") { - for (const button of this.elements) { - let value = button.getAttribute("data-config-value"); - - let typed = value as T; - if (typed === "true") typed = true as T; - if (typed === "false") typed = false as T; - - if (typed !== newValue) { - button.removeClass("active"); - } else { - button.addClass("active"); - } - } - } else if (this.mode === "range") { - const range = this.elements?.[0] as - | ElementWithUtils - | undefined; - - const rangeValue = qs( - `.pageSettings .section[data-config-name='${this.configName}'] .value`, - ); - - if (range === undefined || range === null || rangeValue === null) { - return; - } - - range.setValue(newValue as unknown as string); - rangeValue.setText(`${(newValue as number).toFixed(1)}`); - } - if (this.updateCallback) this.updateCallback(); - } -} diff --git a/frontend/src/ts/elements/settings/theme-picker.ts b/frontend/src/ts/elements/settings/theme-picker.ts deleted file mode 100644 index b66f07d97494..000000000000 --- a/frontend/src/ts/elements/settings/theme-picker.ts +++ /dev/null @@ -1,424 +0,0 @@ -import { Config } from "../../config/store"; -import { setConfig } from "../../config/setters"; -import * as ThemeController from "../../controllers/theme-controller"; -import * as Misc from "../../utils/misc"; -import * as Colors from "../../utils/colors"; -import { - showNoticeNotification, - showErrorNotification, - showSuccessNotification, -} from "../../states/notifications"; -import { showLoaderBar, hideLoaderBar } from "../../states/loader-bar"; -import * as DB from "../../db"; -import { configEvent } from "../../events/config"; -import { getActivePage, isAuthenticated } from "../../states/core"; -import { ThemeName } from "@monkeytype/schemas/configs"; -import { captureException } from "../../sentry"; -import { ColorName, ThemesList, ThemeWithName } from "../../constants/themes"; -import { qs, qsa, qsr } from "../../utils/dom"; -import { getTheme, updateThemeColor } from "../../states/theme"; -import { saveFullConfigToLocalStorage } from "../../config/persistence"; - -export const sortedThemes: ThemeWithName[] = [...ThemesList].sort((a, b) => { - const b1 = Colors.hexToHSL(a.bg); - const b2 = Colors.hexToHSL(b.bg); - return b2.lgt - b1.lgt; -}); - -function updateActiveButton(): void { - let activeThemeName: string = Config.theme; - if ( - Config.randomTheme !== "off" && - Config.randomTheme !== "custom" && - ThemeController.randomTheme !== null - ) { - activeThemeName = ThemeController.randomTheme; - } - - document - .querySelectorAll(".pageSettings .section.themes .theme") - .forEach((el) => { - el.classList.remove("active"); - }); - document - .querySelector( - `.pageSettings .section.themes .theme[theme='${activeThemeName}']`, - ) - ?.classList.add("active"); -} - -function updateColorPicker(key: ColorName, color: string): void { - const colorPicker = qsr(`.colorPicker[data-key="${key}"]`); - const pickerButton = colorPicker.qsr("label"); - pickerButton.setAttribute("value", color); - if (key !== "bg") { - //don't update the color for the background picker - pickerButton.setStyle({ backgroundColor: color }); - } - colorPicker.qsr("input.input").setValue(color); - colorPicker.qsr("input.color").setAttribute("value", color); -} - -export async function fillPresetButtons(): Promise { - // Update theme buttons - const favThemesEl = document.querySelector( - ".pageSettings .section.themes .favThemes.buttons", - ); - const themesEl = document.querySelector( - ".pageSettings .section.themes .allThemes.buttons", - ); - - if (favThemesEl === null || themesEl === null) { - const msg = - "Failed to fill preset theme buttons: favThemes or allThemes element not found"; - showErrorNotification(msg); - void captureException(new Error(msg)); - console.error(msg, { favThemesEl, themesEl }); - return; - } - - favThemesEl.innerHTML = ""; - themesEl.innerHTML = ""; - - let favThemesElHTML = ""; - let themesElHTML = ""; - - let activeThemeName: string = Config.theme; - if ( - Config.randomTheme !== "off" && - Config.randomTheme !== "custom" && - ThemeController.randomTheme !== null - ) { - activeThemeName = ThemeController.randomTheme; - } - - const themes = sortedThemes; - - //first show favourites - if (Config.favThemes.length > 0) { - favThemesEl.style.marginBottom = "1rem"; - for (const theme of themes) { - if (Config.favThemes.includes(theme.name)) { - const activeTheme = activeThemeName === theme.name ? "active" : ""; - favThemesElHTML += `
-
-
${theme.name.replace(/_/g, " ")}
-
-
-
-
-
-
- `; - } - } - favThemesEl.innerHTML = favThemesElHTML; - } else { - favThemesEl.style.marginBottom = "0"; - } - //then the rest - for (const theme of themes) { - if (Config.favThemes.includes(theme.name)) { - continue; - } - - const activeTheme = activeThemeName === theme.name ? "active" : ""; - themesElHTML += `
-
-
${theme.name.replace(/_/g, " ")}
-
-
-
-
-
-
- `; - } - themesEl.innerHTML = themesElHTML; -} - -export async function fillCustomButtons(): Promise { - // Update custom theme buttons - const customThemesEl = qs( - ".pageSettings .section.themes .allCustomThemes.buttons", - )?.empty(); - const addButton = qs(".pageSettings .section.themes .addCustomThemeButton"); - const saveButton = qs( - ".pageSettings .section.themes .tabContent.customTheme #saveCustomThemeButton", - ); - - if (!isAuthenticated()) { - saveButton?.setText("save"); - addButton?.hide(); - customThemesEl?.setStyle({ marginBottom: "0" }); - return; - } - - saveButton?.setText("save as new"); - addButton?.show(); - - const customThemes = DB.getSnapshot()?.customThemes ?? []; - - if (customThemes.length === 0) { - customThemesEl?.setStyle({ marginBottom: "0" }); - } else { - customThemesEl?.setStyle({ marginBottom: "1rem" }); - } - - for (const customTheme of customThemes) { - const bgColor = customTheme.colors[0]; - const mainColor = customTheme.colors[1]; - - customThemesEl?.appendHtml( - `
-
-
${customTheme.name.replace(/_/g, " ")}
-
-
`, - ); - } -} - -export function setCustomInputs(): void { - const theme = ThemeController.convertCustomColorsToTheme( - Config.customThemeColors, - ); - qsa( - ".pageSettings .section.themes .tabContainer .customTheme .colorPicker", - ).forEach((element) => { - const key = element.getAttribute("data-key") as ColorName; - const color = Colors.convertStringToHex(theme[key]); - updateColorPicker(key, color); - }); -} - -function toggleFavourite(themeName: ThemeName): void { - if (Config.favThemes.includes(themeName)) { - // already favourite, remove - setConfig( - "favThemes", - Config.favThemes.filter((t) => t !== themeName), - ); - } else { - // add to favourites - const newList: ThemeName[] = Config.favThemes; - newList.push(themeName); - setConfig("favThemes", newList); - } - saveFullConfigToLocalStorage(); -} - -function saveCustomThemeColors(): void { - const colors = ThemeController.convertThemeToCustomColors(getTheme()); - - setConfig("customThemeColors", colors); - showSuccessNotification("Custom theme saved"); -} - -export function updateActiveTab(): void { - // Set force to true only when some change for the active tab has taken place - // Prevent theme buttons from being added twice by doing an update only when the state has changed - qsa(".pageSettings .section.themes .tabs button")?.removeClass("active"); - qs( - `.pageSettings .section.themes .tabs button[data-tab="${ - Config.customTheme ? "custom" : "preset" - }"]`, - )?.addClass("active"); - - if (Config.customTheme) { - void Misc.swapElements( - qs('.pageSettings [tabContent="preset"]'), - qs('.pageSettings [tabContent="custom"]'), - 250, - ); - } else { - void Misc.swapElements( - qs('.pageSettings [tabContent="custom"]'), - qs('.pageSettings [tabContent="preset"]'), - 250, - ); - } -} - -// separated to avoid repeated calls -export async function updateThemeUI(): Promise { - await fillPresetButtons(); - updateActiveButton(); -} - -// Add events to the DOM - -// Handle click on theme: preset or custom tab -qsa(".pageSettings .section.themes .tabs button")?.on("click", (e) => { - qsa(".pageSettings .section.themes .tabs button")?.removeClass("active"); - (e.currentTarget as HTMLElement).classList.add("active"); - if ((e.currentTarget as HTMLElement).getAttribute("data-tab") === "preset") { - setConfig("customTheme", false); - } else { - setConfig("customTheme", true); - } -}); - -// Handle click on custom theme button -qs(".pageSettings")?.onChild( - "click", - ".section.themes .customTheme.button", - (e) => { - // Do not apply if user wanted to delete it - - const target = e.childTarget as HTMLElement; - - if ((e.target as HTMLElement).classList.contains("delButton")) return; - if ((e.target as HTMLElement).classList.contains("editButton")) return; - const customThemeId = target.getAttribute("customThemeId") ?? ""; - const theme = DB.getSnapshot()?.customThemes?.find( - (e) => e._id === customThemeId, - ); - - if (theme === undefined) { - //this shouldnt happen but typescript needs this check - console.error( - "Could not find custom theme in snapshot for id ", - customThemeId, - ); - return; - } - - setConfig("customThemeColors", theme.colors); - }, -); - -// Handle click on favorite preset theme button -qs(".pageSettings")?.onChild( - "click", - ".section.themes .theme .favButton", - (e) => { - const theme = (e.childTarget as HTMLElement) - .closest(".theme.button") - ?.getAttribute("theme") as ThemeName; - if (theme !== undefined) { - toggleFavourite(theme); - } else { - console.error( - "Could not find the theme attribute attached to the button clicked!", - ); - } - }, -); - -// Handle click on preset theme button -qs(".pageSettings")?.onChild("click", ".section.themes .theme.button", (e) => { - const theme = (e.childTarget as HTMLElement).getAttribute( - "theme", - ) as ThemeName; - if ( - !(e.childTarget as HTMLElement).classList.contains("favButton") && - theme !== undefined - ) { - setConfig("theme", theme); - } -}); - -function handleColorInput(options: { - convertColor: boolean; -}): (e: Event) => void { - return (e) => { - const target = e.target as HTMLInputElement; - const key = target - ?.closest(".colorPicker") - ?.getAttribute("data-key") as ColorName; - - let color: string; - - if (options.convertColor) { - try { - color = Colors.convertStringToHex(target.value); - } catch { - showNoticeNotification("Invalid color format"); - color = "#000000"; - } - } else { - color = target.value; - } - - updateColorPicker(key, color); - updateThemeColor(key, color); - }; -} - -const convertColorAndUpdate = handleColorInput({ convertColor: true }); -/*const pickerInputDebounced = debounce( - 100, - handleColorInput({ convertColor: false }), -); -*/ -const pickerInputDebounced = handleColorInput({ convertColor: false }); - -qsa( - ".pageSettings .section.themes .tabContainer .customTheme input[type=color]", -) - .on("input", pickerInputDebounced) - .on("change", convertColorAndUpdate); - -qsa(".pageSettings .section.themes .tabContainer .customTheme input.input") - .on("blur", (e) => { - if ((e.target as HTMLInputElement).id === "name") return; - convertColorAndUpdate(e); - }) - .on("keypress", function (e) { - const target = e.target as HTMLInputElement; - if (target.id === "name") return; - if (e.code === "Enter") { - target.setAttribute("disabled", "disabled"); - convertColorAndUpdate(e); - target.removeAttribute("disabled"); - } - }); - -qs(".pageSettings #loadCustomColorsFromPreset")?.on("click", async () => { - ThemeController.applyPreset(Config.theme); - const themeColors = getTheme(); - - Misc.typedKeys(themeColors) - .filter((key) => key !== "hasCss" && key !== "name") - .forEach((key) => - updateColorPicker(key, Colors.convertStringToHex(themeColors[key])), - ); -}); - -qs(".pageSettings #saveCustomThemeButton")?.on("click", async () => { - saveCustomThemeColors(); - if (isAuthenticated()) { - const newCustomTheme = { - name: "custom", - colors: Config.customThemeColors, - }; - - showLoaderBar(); - await DB.addCustomTheme(newCustomTheme); - hideLoaderBar(); - } - void fillCustomButtons(); -}); - -configEvent.subscribe(({ key }) => { - if (key === "theme" && getActivePage() === "settings") { - updateActiveButton(); - } - if (key === "favThemes" && getActivePage() === "settings") { - void fillPresetButtons(); - } -}); diff --git a/frontend/src/ts/event-handlers/settings.ts b/frontend/src/ts/event-handlers/settings.ts deleted file mode 100644 index e1a327e8cc6b..000000000000 --- a/frontend/src/ts/event-handlers/settings.ts +++ /dev/null @@ -1,88 +0,0 @@ -import * as ShareCustomThemeModal from "../modals/share-custom-theme"; -import * as CookiesModal from "../modals/cookies"; -import * as EditPresetPopup from "../modals/edit-preset"; -import * as EditTagPopup from "../modals/edit-tag"; - -import { showErrorNotification } from "../states/notifications"; -import { qs } from "../utils/dom"; - -const settingsPage = qs("#pageSettings"); - -settingsPage?.qs("#shareCustomThemeButton")?.on("click", () => { - ShareCustomThemeModal.show(); -}); - -settingsPage - ?.qs(".section.updateCookiePreferences .buttons button") - ?.on("click", () => { - CookiesModal.show(true); - }); - -settingsPage?.qs(".section.presets")?.on("click", (e) => { - const target = e.target as HTMLElement; - if (target.classList.contains("addPresetButton")) { - EditPresetPopup.show("add"); - } else if (target.classList.contains("editButton")) { - const presetid = target.parentElement?.getAttribute("data-id"); - const name = target.parentElement?.getAttribute("data-name"); - if ( - presetid === undefined || - name === undefined || - presetid === "" || - name === "" || - presetid === null || - name === null - ) { - showErrorNotification( - "Failed to edit preset: Could not find preset id or name", - ); - return; - } - EditPresetPopup.show("edit", presetid, name); - } else if (target.classList.contains("removeButton")) { - const presetid = target.parentElement?.getAttribute("data-id"); - const name = target.parentElement?.getAttribute("data-name"); - if ( - presetid === undefined || - name === undefined || - presetid === "" || - name === "" || - presetid === null || - name === null - ) { - showErrorNotification( - "Failed to remove preset: Could not find preset id or name", - ); - return; - } - EditPresetPopup.show("remove", presetid, name); - } -}); - -settingsPage?.qs(".section.tags")?.on("click", (e) => { - const target = e.target as HTMLElement; - if (target.classList.contains("addTagButton")) { - EditTagPopup.show("add"); - } else if (target.classList.contains("editButton")) { - const tagid = target.parentElement?.getAttribute("data-id"); - if (tagid === undefined || tagid === "" || tagid === null) { - showErrorNotification("Failed to edit tag: Could not find tag id"); - return; - } - EditTagPopup.show("edit", tagid); - } else if (target.classList.contains("clearPbButton")) { - const tagid = target.parentElement?.getAttribute("data-id"); - if (tagid === undefined || tagid === "" || tagid === null) { - showErrorNotification("Failed to clear tag PB: Could not find tag id"); - return; - } - EditTagPopup.show("clearPb", tagid); - } else if (target.classList.contains("removeButton")) { - const tagid = target.parentElement?.getAttribute("data-id"); - if (tagid === undefined || tagid === "" || tagid === null) { - showErrorNotification("Failed to remove tag: Could not find tag id"); - return; - } - EditTagPopup.show("remove", tagid); - } -}); diff --git a/frontend/src/ts/hooks/useSavedIndicator.tsx b/frontend/src/ts/hooks/useSavedIndicator.tsx new file mode 100644 index 000000000000..bed09d67cfb7 --- /dev/null +++ b/frontend/src/ts/hooks/useSavedIndicator.tsx @@ -0,0 +1,48 @@ +import { createSignal, JSXElement, onCleanup } from "solid-js"; + +import { AnimeShow } from "../components/common/anime"; +import { Fa } from "../components/common/Fa"; + +export function useSavedIndicator(): { + component: () => JSXElement; + flash: () => void; + hide: () => void; +} { + const [show, setShow] = createSignal(false); + let timeout: ReturnType | undefined; + + function clear(): void { + if (timeout !== undefined) { + clearTimeout(timeout); + timeout = undefined; + } + } + + function flash(): void { + clear(); + setShow(true); + timeout = setTimeout(() => { + setShow(false); + timeout = undefined; + }, 2000); + } + + function hide(): void { + clear(); + setShow(false); + } + + function component(): JSXElement { + return ( + +
+ +
+
+ ); + } + + onCleanup(clear); + + return { component, flash, hide }; +} diff --git a/frontend/src/ts/index.ts b/frontend/src/ts/index.ts index 04505a49e169..10a5f7efaf93 100644 --- a/frontend/src/ts/index.ts +++ b/frontend/src/ts/index.ts @@ -7,14 +7,12 @@ import "solid-devtools"; import "./event-handlers/global"; import "./event-handlers/keymap"; import "./event-handlers/test"; -import "./event-handlers/settings"; import "./modals/google-sign-up"; import { init } from "./firebase"; import * as Logger from "./utils/logger"; import * as DB from "./db"; import "./ui"; -import "./elements/settings/account-settings-notice"; import "./controllers/ad-controller"; import { Config } from "./config/store"; import * as TestStats from "./test/test-stats"; @@ -25,7 +23,6 @@ import { onAuthStateChanged } from "./auth"; import { enable } from "./legacy-states/glarses-mode"; import "./test/caps-warning"; import "./modals/simple-modals"; -import * as CookiesModal from "./modals/cookies"; import "./input/listeners"; import "./controllers/route-controller"; import "./elements/no-css"; @@ -48,6 +45,7 @@ import { setVersion } from "./states/core"; import { loadFromLocalStorage } from "./config/lifecycle"; import "./input/hotkeys"; +import { showModal } from "./states/modals"; // Lock Math.random Object.defineProperty(Math, "random", { @@ -78,7 +76,7 @@ void fetchLatestVersion().then((data) => { Focus.set(true, true); const accepted = Cookies.getAcceptedCookies(); if (accepted === null) { - CookiesModal.show(); + showModal("Cookies"); } void init(onAuthStateChanged).then(() => { if (accepted !== null) { diff --git a/frontend/src/ts/modals/cookies.ts b/frontend/src/ts/modals/cookies.ts deleted file mode 100644 index 17ed19cfe2f3..000000000000 --- a/frontend/src/ts/modals/cookies.ts +++ /dev/null @@ -1,112 +0,0 @@ -import { showErrorNotification } from "../states/notifications"; -import { isPopupVisible } from "../utils/misc"; -import * as AdController from "../controllers/ad-controller"; -import AnimatedModal from "../utils/animated-modal"; -import { focusWords } from "../test/test-ui"; -import { - AcceptedCookies, - getAcceptedCookies, - setAcceptedCookies, -} from "../cookies"; - -export function show(goToSettings?: boolean): void { - void modal.show({ - beforeAnimation: async () => { - if (goToSettings) { - const currentAcceptedCookies = getAcceptedCookies(); - showSettings(currentAcceptedCookies); - } - }, - afterAnimation: async () => { - if (!isPopupVisible("cookiesModal")) { - modal.destroy(); - } - }, - }); -} - -function showSettings(currentAcceptedCookies?: AcceptedCookies): void { - modal.getModal().qs(".main")?.hide(); - modal.getModal().qs(".settings")?.show(); - - if (currentAcceptedCookies) { - if (currentAcceptedCookies.analytics) { - modal - .getModal() - .qs(".cookie.analytics input") - ?.setChecked(true); - } - if (currentAcceptedCookies.sentry) { - modal - .getModal() - .qs(".cookie.sentry input") - ?.setChecked(true); - } - } -} - -async function hide(): Promise { - void modal.hide({ - afterAnimation: async () => { - focusWords(); - }, - }); -} - -const modal = new AnimatedModal({ - dialogId: "cookiesModal", - customEscapeHandler: (): void => { - // - }, - customWrapperClickHandler: (): void => { - // - }, - setup: async (modalEl): Promise => { - modalEl.qs(".acceptAll")?.on("click", () => { - const accepted = { - security: true, - analytics: true, - sentry: true, - }; - setAcceptedCookies(accepted); - void hide(); - }); - modalEl.qs(".rejectAll")?.on("click", () => { - const accepted = { - security: true, - analytics: false, - sentry: false, - }; - setAcceptedCookies(accepted); - void hide(); - }); - modalEl.qs(".openSettings")?.on("click", () => { - showSettings(); - }); - modalEl.qs(".cookie.ads .textButton")?.on("click", () => { - try { - AdController.showConsentPopup(); - } catch (e) { - console.error("Failed to open ad consent UI"); - showErrorNotification( - "Failed to open Ad consent popup. Do you have an ad or cookie popup blocker enabled?", - ); - } - }); - modalEl.qs(".acceptSelected")?.on("click", () => { - const analyticsChecked = - modalEl.qs(".cookie.analytics input")?.getChecked() ?? - false; - const sentryChecked = - modalEl.qs(".cookie.sentry input")?.getChecked() ?? - false; - const accepted = { - security: true, - analytics: analyticsChecked, - sentry: sentryChecked, - }; - setAcceptedCookies(accepted); - void hide(); - }); - }, -}); diff --git a/frontend/src/ts/modals/edit-preset.ts b/frontend/src/ts/modals/edit-preset.ts deleted file mode 100644 index 8433942dcbb4..000000000000 --- a/frontend/src/ts/modals/edit-preset.ts +++ /dev/null @@ -1,394 +0,0 @@ -import { __nonReactive as __nonReactiveTags } from "../collections/tags"; -import { showLoaderBar, hideLoaderBar } from "../states/loader-bar"; -import * as Settings from "../pages/settings"; -import { - showNoticeNotification, - showErrorNotification, - showSuccessNotification, -} from "../states/notifications"; -import AnimatedModal from "../utils/animated-modal"; -import { - PresetNameSchema, - PresetType, - PresetTypeSchema, -} from "@monkeytype/schemas/presets"; -import { - __nonReactive as __nonReactivePresets, - addPreset, - editPreset, - deletePreset, -} from "../collections/presets"; -import { - ConfigGroupName, - ConfigGroupNameSchema, - ConfigKey, - Config as ConfigType, -} from "@monkeytype/schemas/configs"; -import { getDefaultConfig } from "../constants/default-config"; -import { ValidatedHtmlInputElement } from "../elements/input-validation"; -import { ElementWithUtils } from "../utils/dom"; -import { configMetadata } from "../config/metadata"; -import { getConfigChanges as getConfigChangesFromConfig } from "../config/utils"; -import { normalizeName } from "../utils/strings"; - -const state = { - presetType: "full" as PresetType, - checkboxes: new Map( - ConfigGroupNameSchema.options.map((key: ConfigGroupName) => [key, true]), - ), - setPresetToCurrent: false, -}; - -let presetNameEl: ValidatedHtmlInputElement | null = null; - -export function show(action: string, id?: string, name?: string): void { - void modal.show({ - focusFirstInput: true, - beforeAnimation: async (modalEl) => { - modalEl.qsr(".text").hide(); - addCheckBoxes(); - presetNameEl ??= new ValidatedHtmlInputElement( - modalEl.qsr("input[type=text]"), - { - isValid: async (name) => { - const parsed = PresetNameSchema.safeParse(normalizeName(name)); - if (parsed.success) return true; - return parsed.error.errors.map((err) => err.message).join(", "); - }, - debounceDelay: 0, - }, - ); - if (action === "add") { - modalEl.setAttribute("data-action", "add"); - modalEl.qsr(".popupTitle").setHtml("Add new preset"); - modalEl.qsr(".submit").setHtml("add"); - presetNameEl.setValue(null); - presetNameEl.getParent()?.show(); - modalEl.qsa("input").show(); - modalEl.qsr("label.changePresetToCurrentCheckbox").hide(); - modalEl.qsr(".inputs").show(); - modalEl.qsr(".presetType").show(); - modalEl.qsr(".presetNameTitle").show(); - state.presetType = "full"; - } else if (action === "edit" && id !== undefined && name !== undefined) { - modalEl.setAttribute("data-action", "edit"); - modalEl.setAttribute("data-preset-id", id); - modalEl.qsr(".popupTitle").setHtml("Edit preset"); - modalEl.qsr(".submit").setHtml(`save`); - presetNameEl?.setValue(name); - presetNameEl?.getParent()?.show(); - - modalEl.qsa("input").show(); - modalEl.qsr("label.changePresetToCurrentCheckbox").show(); - modalEl.qsr(".presetNameTitle").show(); - state.setPresetToCurrent = false; - updateEditPresetUI(); - } else if ( - action === "remove" && - id !== undefined && - name !== undefined - ) { - modalEl.setAttribute("data-action", "remove"); - modalEl.setAttribute("data-preset-id", id); - modalEl.qsr(".popupTitle").setHtml("Delete preset"); - modalEl.qsr(".submit").setHtml("delete"); - modalEl.qsa("input").hide(); - modalEl.qsr("label.changePresetToCurrentCheckbox").hide(); - modalEl.qsr(".text").show(); - modalEl - .qsr(".deletePrompt") - .setText(`Are you sure you want to delete the preset ${name}?`); - modalEl.qsr(".inputs").hide(); - modalEl.qsr(".presetType").hide(); - modalEl.qsr(".presetNameTitle").hide(); - presetNameEl?.getParent()?.hide(); - } - updateUI(); - }, - }); -} - -function initializeEditState(id: string): void { - for (const key of state.checkboxes.keys()) { - state.checkboxes.set(key, false); - } - const edittedPreset = __nonReactivePresets.getPreset(id); - if (edittedPreset === undefined) { - showErrorNotification("Preset not found"); - return; - } - if ( - edittedPreset.settingGroups === undefined || - edittedPreset.settingGroups === null - ) { - state.presetType = "full"; - for (const key of state.checkboxes.keys()) { - state.checkboxes.set(key, true); - } - } else { - state.presetType = "partial"; - edittedPreset.settingGroups.forEach((currentActiveSettingGroup) => - state.checkboxes.set(currentActiveSettingGroup, true), - ); - } - state.setPresetToCurrent = false; - updateUI(); -} - -function addCheckboxListeners(): void { - const modalEl = modal.getModal(); - ConfigGroupNameSchema.options.forEach((settingGroup: ConfigGroupName) => { - const checkboxInput = modalEl.qsr( - `.checkboxList .checkboxTitlePair[data-id="${settingGroup}"] input`, - ); - - checkboxInput.on("change", async () => { - state.checkboxes.set(settingGroup, checkboxInput.isChecked() as boolean); - }); - }); - - const presetToCurrentCheckbox = modalEl.qsr( - `.changePresetToCurrentCheckbox input`, - ); - presetToCurrentCheckbox.on("change", () => { - state.setPresetToCurrent = presetToCurrentCheckbox.isChecked() as boolean; - updateEditPresetUI(); - }); -} - -function addCheckBoxes(): void { - const modalEl = modal.getModal(); - function camelCaseToSpaced(input: string): string { - return input.replace(/([a-z])([A-Z])/g, "$1 $2"); - } - const settingGroupListEl = modalEl.qsr(".inputs .checkboxList").empty(); - - ConfigGroupNameSchema.options.forEach((currSettingGroup) => { - const currSettingGroupTitle = camelCaseToSpaced(currSettingGroup); - const settingGroupCheckbox: string = ``; - settingGroupListEl.appendHtml(settingGroupCheckbox); - }); - for (const key of state.checkboxes.keys()) { - state.checkboxes.set(key, true); - } - addCheckboxListeners(); -} - -function updateUI(): void { - const modalEl = modal.getModal(); - ConfigGroupNameSchema.options.forEach((settingGroup: ConfigGroupName) => { - if (state.checkboxes.get(settingGroup)) { - modalEl - .qsr( - `.checkboxList .checkboxTitlePair[data-id="${settingGroup}"] input`, - ) - .setChecked(true); - } else { - modalEl - .qsr( - `.checkboxList .checkboxTitlePair[data-id="${settingGroup}"] input`, - ) - .setChecked(false); - } - }); - - modalEl.qsa(".presetType button").removeClass("active"); - modalEl - .qsr(`.presetType button[value="${state.presetType}"]`) - .addClass("active"); - modalEl.qsr(`.partialPresetGroups`).show(); - if (state.presetType === "full") { - modalEl.qsr(".partialPresetGroups").hide(); - } -} -function updateEditPresetUI(): void { - const modalEl = modal.getModal(); - if (state.setPresetToCurrent) { - modalEl - .qsr("label.changePresetToCurrentCheckbox input") - .setChecked(true); - const presetId = modalEl.getAttribute("data-preset-id") as string; - initializeEditState(presetId); - modalEl.qsr(".inputs").show(); - modalEl.qsr(".presetType").show(); - } else { - modalEl - .qsr("label.changePresetToCurrentCheckbox input") - .setChecked(false); - modalEl.qsr(".inputs").hide(); - modalEl.qsr(".presetType").hide(); - } -} - -function hide(): void { - void modal.hide(); -} - -async function apply(): Promise { - const modalEl = modal.getModal(); - const action = modalEl.getAttribute("data-action"); - const propPresetName = modalEl - .qsr(".group input[title='presets']") - .getValue() as string; - const presetId = modalEl.getAttribute("data-preset-id") as string; - - const updateConfig = modalEl - .qsr("label.changePresetToCurrentCheckbox input") - .isChecked(); - - if (action === null || action === "") { - return; - } - - const noPartialGroupSelected: boolean = - ["add", "edit"].includes(action) && - state.presetType === "partial" && - Array.from(state.checkboxes.values()).every((val: boolean) => !val); - if (noPartialGroupSelected) { - showNoticeNotification( - "At least one setting group must be active while saving partial presets", - ); - return; - } - - const addOrEditAction = action === "add" || action === "edit"; - - if (addOrEditAction && propPresetName.trim().length === 0) { - showNoticeNotification("Preset name cannot be empty"); - return; - } - - const cleanedPresetName = normalizeName(propPresetName); - const parsedPresetName = addOrEditAction - ? PresetNameSchema.safeParse(cleanedPresetName) - : null; - - if (parsedPresetName && !parsedPresetName.success) { - showNoticeNotification("Preset name is not valid"); - return; - } - - const presetName = parsedPresetName?.data ?? ""; - - hide(); - - showLoaderBar(); - - try { - if (action === "add") { - const configChanges = getConfigChanges(); - const activeSettingGroups = getActiveSettingGroupsFromState(); - await addPreset({ - name: presetName, - config: configChanges, - settingGroups: - state.presetType === "partial" ? activeSettingGroups : undefined, - }); - showSuccessNotification("Preset added", { durationMs: 2000 }); - } else if (action === "edit") { - const existing = __nonReactivePresets.getPreset(presetId); - if (existing === undefined) { - showErrorNotification("Preset not found"); - return; - } - const configChanges = getConfigChanges(); - const activeSettingGroups: ConfigGroupName[] | null = - state.presetType === "partial" - ? getActiveSettingGroupsFromState() - : null; - await editPreset({ - presetId, - name: presetName, - config: updateConfig ? configChanges : undefined, - settingGroups: updateConfig ? activeSettingGroups : undefined, - }); - showSuccessNotification("Preset updated"); - } else if (action === "remove") { - await deletePreset({ presetId }); - showSuccessNotification("Preset removed"); - } - } catch (e) { - showErrorNotification( - e instanceof Error ? e.message : "Failed to update preset", - ); - } - - void Settings.update(); - hideLoaderBar(); -} - -function getSettingGroup(configFieldName: ConfigKey): ConfigGroupName { - return configMetadata[configFieldName].group; -} - -function getPartialConfigChanges( - configChanges: Partial, -): Partial { - const activeConfigChanges: Partial = {}; - const defaultConfig = getDefaultConfig(); - - (Object.keys(defaultConfig) as ConfigKey[]) - .filter((settingName) => { - const group = getSettingGroup(settingName); - if (group === null) return false; - return state.checkboxes.get(group) === true; - }) - .forEach((settingName) => { - const safeSettingName = settingName; - const newValue = - configChanges[safeSettingName] ?? defaultConfig[safeSettingName]; - // @ts-expect-error cant figure this one out, but it works - activeConfigChanges[safeSettingName] = newValue; - }); - return activeConfigChanges; -} - -function getActiveSettingGroupsFromState(): ConfigGroupName[] { - return Array.from(state.checkboxes.entries()) - .filter(([, value]) => value) - .map(([key]) => key); -} - -function getConfigChanges(): Partial { - const activeConfigChanges = - state.presetType === "partial" - ? getPartialConfigChanges(getConfigChangesFromConfig()) - : getConfigChangesFromConfig(); - const activeTagIds: string[] = __nonReactiveTags - .getActiveTags() - .map((tag) => tag._id); - - const setTags: boolean = - state.presetType === "full" || state.checkboxes.get("behavior") === true; - return { - ...activeConfigChanges, - ...(setTags && { - tags: activeTagIds, - }), - }; -} - -async function setup(modalEl: ElementWithUtils): Promise { - modalEl.on("submit", (e) => { - e.preventDefault(); - void apply(); - }); - PresetTypeSchema.options.forEach((presetType) => { - const presetOption = modalEl.qs( - `.presetType button[value="${presetType}"]`, - ); - if (presetOption === null) return; - - presetOption.on("click", () => { - state.presetType = presetType; - updateUI(); - }); - }); -} -const modal = new AnimatedModal({ - dialogId: "editPresetModal", - setup, -}); diff --git a/frontend/src/ts/modals/edit-tag.ts b/frontend/src/ts/modals/edit-tag.ts deleted file mode 100644 index 08e1cead2b54..000000000000 --- a/frontend/src/ts/modals/edit-tag.ts +++ /dev/null @@ -1,153 +0,0 @@ -import * as Settings from "../pages/settings"; -import AnimatedModal, { ShowOptions } from "../utils/animated-modal"; -import { SimpleModal, TextInput } from "../elements/simple-modal"; -import { TagNameSchema } from "@monkeytype/schemas/users"; -import { IsValidResponse } from "../types/validation"; -import { - insertTag, - deleteTag, - updateTagName, - clearTagPBs, - __nonReactive, -} from "../collections/tags"; -import { normalizeName } from "../utils/strings"; -import { deleteLocalTag } from "../collections/results"; - -function errorMessage(e: unknown): string { - return e instanceof Error ? e.message : String(e); -} - -const tagNameValidation = async (tagName: string): Promise => { - const validationResult = TagNameSchema.safeParse(normalizeName(tagName)); - if (validationResult.success) return true; - return validationResult.error.errors.map((err) => err.message).join(", "); -}; - -type Action = "add" | "edit" | "remove" | "clearPb"; -const actionModals: Record = { - add: new SimpleModal({ - id: "addTag", - title: "Add new tag", - inputs: [ - { - placeholder: "tag name", - type: "text", - validation: { isValid: tagNameValidation, debounceDelay: 0 }, - }, - ], - buttonText: "add", - execFn: async (_thisPopup, propTagName) => { - const tagName = TagNameSchema.parse(normalizeName(propTagName)); - - try { - //todo: do we await? if we do, optimistic updates are kinda pointless? - await insertTag({ name: tagName }); - } catch (e) { - return { - status: "error", - message: `Failed to add tag: ${errorMessage(e)}`, - }; - } - - void Settings.update(); - return { status: "success", message: `Tag added` }; - }, - }), - edit: new SimpleModal({ - id: "editTag", - title: "Edit tag", - inputs: [ - { - placeholder: "tag name", - type: "text", - validation: { isValid: tagNameValidation, debounceDelay: 0 }, - }, - ], - buttonText: "save", - beforeInitFn: (_thisPopup) => { - const tag = __nonReactive.getTag(_thisPopup.parameters[0] as string); - (_thisPopup.inputs[0] as TextInput).initVal = tag?.name ?? ""; - }, - execFn: async (_thisPopup, propTagName) => { - const tagName = TagNameSchema.parse(normalizeName(propTagName)); - const tagId = _thisPopup.parameters[0] as string; - - try { - await updateTagName({ tagId, newName: tagName }); - } catch (e) { - return { - status: "error", - message: `Failed to update tag: ${errorMessage(e)}`, - }; - } - - void Settings.update(); - - return { status: "success", message: `Tag updated` }; - }, - }), - remove: new SimpleModal({ - id: "removeTag", - title: "Delete tag", - buttonText: "delete", - beforeInitFn: (_thisPopup) => { - const tag = __nonReactive.getTag(_thisPopup.parameters[0] as string); - _thisPopup.text = `Are you sure you want to delete tag ${tag?.name ?? _thisPopup.parameters[0]}?`; - }, - execFn: async (_thisPopup) => { - const tagId = _thisPopup.parameters[0] as string; - - try { - await deleteTag({ tagId }); - } catch (e) { - return { - status: "error", - message: `Failed to remove tag: ${errorMessage(e)}`, - }; - } - - deleteLocalTag(tagId); - void Settings.update(); - - return { status: "success", message: `Tag removed` }; - }, - }), - clearPb: new SimpleModal({ - id: "clearTagPb", - title: "Clear personal bests", - buttonText: "clear", - beforeInitFn: (_thisPopup) => { - const tag = __nonReactive.getTag(_thisPopup.parameters[0] as string); - _thisPopup.text = `Are you sure you want to clear personal bests for tag ${tag?.name ?? _thisPopup.parameters[0]}?`; - }, - execFn: async (_thisPopup) => { - const tagId = _thisPopup.parameters[0] as string; - - try { - await clearTagPBs({ tagId }); - } catch (e) { - return { - status: "error", - message: `Failed to clear tag PBs: ${errorMessage(e)}`, - }; - } - - void Settings.update(); - return { status: "success", message: `Tag PB cleared` }; - }, - }), -}; - -export function show( - action: Action, - id?: string, - modalChain?: AnimatedModal, -): void { - const options: ShowOptions = { - modalChain, - focusFirstInput: "focusAndSelect", - }; - if (action !== "add" && id === undefined) return; - - actionModals[action].show([id ?? ""], options); -} diff --git a/frontend/src/ts/modals/import-export-settings.ts b/frontend/src/ts/modals/import-export-settings.ts deleted file mode 100644 index f2be415871d1..000000000000 --- a/frontend/src/ts/modals/import-export-settings.ts +++ /dev/null @@ -1,50 +0,0 @@ -import { applyConfigFromJson } from "../config/lifecycle"; -import AnimatedModal from "../utils/animated-modal"; - -type State = { - mode: "import" | "export"; - value: string; -}; - -const state: State = { - mode: "import", - value: "", -}; - -export function show(mode: "import" | "export", config?: string): void { - state.mode = mode; - state.value = config ?? ""; - - void modal.show({ - focusFirstInput: "focusAndSelect", - beforeAnimation: async (modal) => { - modal.qs("input")?.setValue(state.value); - if (state.mode === "export") { - modal.qs("button")?.hide(); - modal.qs("input")?.setAttribute("readonly", "true"); - } else if (state.mode === "import") { - modal.qs("button")?.show(); - modal.qs("input")?.removeAttribute("readonly"); - } - }, - }); -} - -const modal = new AnimatedModal({ - dialogId: "importExportSettingsModal", - setup: async (modalEl): Promise => { - modalEl.qs("input")?.on("input", (e) => { - state.value = (e.currentTarget as HTMLInputElement).value; - }); - modalEl?.on("submit", async (e) => { - e.preventDefault(); - if (state.mode !== "import") return; - if (state.value === "") { - void modal.hide(); - return; - } - await applyConfigFromJson(state.value); - void modal.hide(); - }); - }, -}); diff --git a/frontend/src/ts/modals/share-custom-theme.ts b/frontend/src/ts/modals/share-custom-theme.ts deleted file mode 100644 index 3c3ff534731b..000000000000 --- a/frontend/src/ts/modals/share-custom-theme.ts +++ /dev/null @@ -1,94 +0,0 @@ -import * as ThemeController from "../controllers/theme-controller"; -import { Config } from "../config/store"; -import { - showNoticeNotification, - showSuccessNotification, -} from "../states/notifications"; -import AnimatedModal from "../utils/animated-modal"; -import { getTheme } from "../states/theme"; - -type State = { - includeBackground: boolean; -}; - -const state: State = { - includeBackground: false, -}; - -export function show(): void { - void modal.show({ - beforeAnimation: async (m) => { - m.qs("input[type='checkbox']")?.setChecked(false); - state.includeBackground = false; - }, - }); -} - -async function generateUrl(): Promise { - const newTheme: { - c: string[]; //colors - i?: string; //image - s?: string; //size - f?: object; //filter - } = { - c: ThemeController.convertThemeToCustomColors(getTheme()), - }; - - if (state.includeBackground) { - newTheme.i = Config.customBackground; - newTheme.s = Config.customBackgroundSize; - newTheme.f = Config.customBackgroundFilter; - } - - return `${window.location.origin}?customTheme=${btoa(JSON.stringify(newTheme))}`; -} - -async function copy(): Promise { - const url = await generateUrl(); - - try { - await navigator.clipboard.writeText(url); - showSuccessNotification("URL Copied to clipboard"); - void modal.hide(); - } catch (e) { - showNoticeNotification( - "Looks like we couldn't copy the link straight to your clipboard. Please copy it manually.", - { - durationMs: 5000, - }, - ); - void urlModal.show({ - modalChain: modal, - focusFirstInput: "focusAndSelect", - beforeAnimation: async (m) => { - m.qs("input")?.setValue(url); - }, - }); - } -} - -const modal = new AnimatedModal({ - dialogId: "shareCustomThemeModal", - setup: async (modalEl): Promise => { - modalEl.qs("button")?.on("click", copy); - modalEl - .qs("input[type='checkbox']") - ?.on("change", (e) => { - state.includeBackground = (e.target as HTMLInputElement).checked; - }); - }, -}); - -const urlModal = new AnimatedModal({ - dialogId: "shareCustomThemeUrlModal", - customEscapeHandler: async (): Promise => { - await urlModal.hide({ - clearModalChain: true, - }); - }, - customWrapperClickHandler: async (): Promise => { - await urlModal.hide({ - clearModalChain: true, - }); - }, -}); diff --git a/frontend/src/ts/modals/simple-modals-base.ts b/frontend/src/ts/modals/simple-modals-base.ts index 19d2b5bc51eb..e72ff35349ee 100644 --- a/frontend/src/ts/modals/simple-modals-base.ts +++ b/frontend/src/ts/modals/simple-modals-base.ts @@ -15,7 +15,6 @@ export type PopupKey = | "optOutOfLeaderboards" | "applyCustomFont" | "resetPersonalBests" - | "resetSettings" | "revokeAllTokens" | "unlinkDiscord" | "editApeKey" @@ -36,7 +35,6 @@ export const list: Record = { optOutOfLeaderboards: undefined, applyCustomFont: undefined, resetPersonalBests: undefined, - resetSettings: undefined, revokeAllTokens: undefined, unlinkDiscord: undefined, editApeKey: undefined, diff --git a/frontend/src/ts/modals/simple-modals.ts b/frontend/src/ts/modals/simple-modals.ts index cf411ba2731a..b0e3957c789f 100644 --- a/frontend/src/ts/modals/simple-modals.ts +++ b/frontend/src/ts/modals/simple-modals.ts @@ -4,8 +4,6 @@ import * as DB from "../db"; import { resetConfig } from "../config/lifecycle"; 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 { FirebaseError } from "firebase/app"; import { getAuthenticatedUser, isAuthAvailable } from "../firebase"; import { isAuthenticated } from "../states/core"; @@ -21,6 +19,7 @@ import { reloadAfter } from "../utils/misc"; import { isDevEnvironment } from "../utils/env"; import { createErrorMessage } from "../utils/error"; import * as ThemeController from "../controllers/theme-controller"; +import * as CustomThemes from "../collections/custom-themes"; import * as AccountSettings from "../pages/account-settings"; import { ExecReturn, @@ -802,22 +801,6 @@ list.optOutOfLeaderboards = new SimpleModal({ }, }); -list.applyCustomFont = new SimpleModal({ - id: "applyCustomFont", - title: "Custom font", - inputs: [{ type: "text", placeholder: "Font name", initVal: "" }], - text: "Make sure you have the font installed on your computer before applying", - buttonText: "apply", - execFn: async (_thisPopup, fontName): Promise => { - Settings.groups["fontFamily"]?.setValue(fontName.replace(/\s/g, "_")); - - return { - status: "success", - message: "Font applied", - }; - }, -}); - list.resetPersonalBests = new SimpleModal({ id: "resetPersonalBests", title: "Reset personal bests", @@ -877,21 +860,6 @@ list.resetPersonalBests = new SimpleModal({ }, }); -list.resetSettings = new SimpleModal({ - id: "resetSettings", - title: "Reset settings", - text: "Are you sure you want to reset all your settings?", - buttonText: "reset", - execFn: async (): Promise => { - await resetConfig(); - await FileStorage.deleteFile("LocalBackgroundFile"); - return { - status: "success", - message: "Settings reset", - }; - }, -}); - list.revokeAllTokens = new SimpleModal({ id: "revokeAllTokens", title: "Revoke all tokens", @@ -1002,17 +970,8 @@ list.updateCustomTheme = new SimpleModal({ ], buttonText: "update", execFn: async (_thisPopup, name, updateColors): Promise => { - const snapshot = DB.getSnapshot(); - if (!snapshot) { - return { - status: "error", - message: "Failed to update custom theme: no snapshot", - }; - } - - const customTheme = snapshot.customThemes?.find( - (t) => t._id === _thisPopup.parameters[0], - ); + const themeId = _thisPopup.parameters[0] as string; + const customTheme = CustomThemes.__nonReactive.getCustomTheme(themeId); if (customTheme === undefined) { return { status: "error", @@ -1025,19 +984,12 @@ list.updateCustomTheme = new SimpleModal({ ? ThemeController.convertThemeToCustomColors(getTheme()) : customTheme.colors; - const newTheme = { + await CustomThemes.editCustomTheme({ + themeId: customTheme._id, name: normalizeName(name), colors: newColors, - }; - const validation = await DB.editCustomTheme(customTheme._id, newTheme); - if (!validation) { - return { - status: "error", - message: "Failed to update custom theme", - }; - } + }); setConfig("customThemeColors", newColors); - void ThemePicker.fillCustomButtons(); return { status: "success", @@ -1045,12 +997,8 @@ list.updateCustomTheme = new SimpleModal({ }; }, beforeInitFn: (_thisPopup): void => { - const snapshot = DB.getSnapshot(); - if (!snapshot) return; - - const customTheme = snapshot.customThemes?.find( - (t) => t._id === _thisPopup.parameters[0], - ); + const themeId = _thisPopup.parameters[0] as string; + const customTheme = CustomThemes.__nonReactive.getCustomTheme(themeId); if (!customTheme) return; (_thisPopup.inputs[0] as TextInput).initVal = customTheme.name.replace( /_/g, @@ -1065,8 +1013,9 @@ list.deleteCustomTheme = new SimpleModal({ text: "Are you sure?", buttonText: "delete", execFn: async (_thisPopup): Promise => { - await DB.deleteCustomTheme(_thisPopup.parameters[0] as string); - void ThemePicker.fillCustomButtons(); + await CustomThemes.deleteCustomTheme({ + themeId: _thisPopup.parameters[0] as string, + }); return { status: "success", diff --git a/frontend/src/ts/modals/streak-hour-offset.ts b/frontend/src/ts/modals/streak-hour-offset.ts index 0b318c48a189..42bde3323f5c 100644 --- a/frontend/src/ts/modals/streak-hour-offset.ts +++ b/frontend/src/ts/modals/streak-hour-offset.ts @@ -7,7 +7,6 @@ import { } from "../states/notifications"; import { showLoaderBar, hideLoaderBar } from "../states/loader-bar"; -// import * as Settings from "../pages/settings"; import { getSnapshot, setSnapshot } from "../db"; import AnimatedModal from "../utils/animated-modal"; import { Snapshot } from "../constants/default-snapshot"; diff --git a/frontend/src/ts/pages/settings.ts b/frontend/src/ts/pages/settings.ts deleted file mode 100644 index 3b72f5e54f60..000000000000 --- a/frontend/src/ts/pages/settings.ts +++ /dev/null @@ -1,1085 +0,0 @@ -import SettingsGroup from "../elements/settings/settings-group"; - -import { Config } from "../config/store"; -import { configLoadPromise } from "../config/lifecycle"; -import { setConfig } from "../config/setters"; -import * as Sound from "../controllers/sound-controller"; -import * as Misc from "../utils/misc"; -import * as Strings from "../utils/strings"; -import * as DB from "../db"; -import * as Funbox from "../test/funbox/funbox"; -import { - __nonReactive as __nonReactiveTags, - toggleTagActive, - useTagsLiveQuery, -} from "../collections/tags"; -import * as PresetController from "../controllers/preset-controller"; -import * as ThemePicker from "../elements/settings/theme-picker"; -import { - showNoticeNotification, - showErrorNotification, - showSuccessNotification, -} from "../states/notifications"; -import * as ImportExportSettingsModal from "../modals/import-export-settings"; -import { configEvent, type ConfigEventKey } from "../events/config"; -import { getActivePage, isAuthenticated } from "../states/core"; -import { PageWithUrlParams } from "./page"; -import { get as getTypingSpeedUnit } from "../utils/typing-speed-units"; -import SlimSelect from "slim-select"; -import * as Skeleton from "../utils/skeleton"; -import * as CustomBackgroundFilter from "../elements/custom-background-filter"; -import { - ThemeName, - CustomLayoutFluid, - FunboxName, - ConfigKeySchema, - ConfigKey, -} from "@monkeytype/schemas/configs"; -import { getAllFunboxes, checkCompatibility } from "@monkeytype/funbox"; -import { getActiveFunboxNames } from "../test/funbox/list"; -import { - __nonReactive as __nonReactivePresets, - usePresetsLiveQuery, -} from "../collections/presets"; -import { LayoutsList } from "../constants/layouts"; -import { DataArrayPartial, Optgroup, OptionOptional } from "slim-select/store"; -import { ThemesList, ThemeWithName } from "../constants/themes"; -import { areSortedArraysEqual, areUnsortedArraysEqual } from "../utils/arrays"; -import { LayoutName } from "@monkeytype/schemas/layouts"; -import { LanguageGroupNames, LanguageGroups } from "../constants/languages"; -import { Language } from "@monkeytype/schemas/languages"; -import FileStorage from "../utils/file-storage"; -import { z } from "zod"; -import { handleConfigInput } from "../elements/input-validation"; -import { Fonts } from "../constants/fonts"; -import * as CustomBackgroundPicker from "../elements/settings/custom-background-picker"; -import * as CustomFontPicker from "../elements/settings/custom-font-picker"; -import { authEvent } from "../events/auth"; -import * as FpsLimitSection from "../elements/settings/fps-limit-section"; -import { qs, qsa, qsr, onDOMReady } from "../utils/dom"; -import { showPopup } from "../modals/simple-modals-base"; -import { createEffectOn } from "../hooks/effects"; -import { createMemo } from "solid-js"; - -let settingsInitialized = false; - -type SettingsGroups = Partial<{ [K in ConfigKey]: SettingsGroup }>; -let customLayoutFluidSelect: SlimSelect | undefined; -let customPolyglotSelect: SlimSelect | undefined; - -export const groups: SettingsGroups = {}; - -const HighlightSchema = ConfigKeySchema.or( - z.enum([ - "resetSettings", - "updateCookiePreferences", - "importexportSettings", - "theme", - "presets", - "tags", - ]), -); -type Highlight = z.infer; - -const StateSchema = z - .object({ - highlight: HighlightSchema, - }) - .partial(); - -async function initGroups(): Promise { - groups["smoothCaret"] = new SettingsGroup("smoothCaret", "button"); - groups["codeUnindentOnBackspace"] = new SettingsGroup( - "codeUnindentOnBackspace", - "button", - ); - groups["difficulty"] = new SettingsGroup("difficulty", "button"); - groups["quickRestart"] = new SettingsGroup("quickRestart", "button"); - groups["resultSaving"] = new SettingsGroup("resultSaving", "button"); - groups["showAverage"] = new SettingsGroup("showAverage", "button"); - groups["keymapMode"] = new SettingsGroup("keymapMode", "button", { - updateCallback: () => { - if (Config.keymapMode === "off") { - qs(".pageSettings .section[data-config-name='keymapStyle']")?.hide(); - qs(".pageSettings .section[data-config-name='keymapLayout']")?.hide(); - qs( - ".pageSettings .section[data-config-name='keymapLegendStyle']", - )?.hide(); - qs( - ".pageSettings .section[data-config-name='keymapShowTopRow']", - )?.hide(); - qs(".pageSettings .section[data-config-name='keymapSize']")?.hide(); - } else { - qs(".pageSettings .section[data-config-name='keymapStyle']")?.show(); - qs(".pageSettings .section[data-config-name='keymapLayout']")?.show(); - qs( - ".pageSettings .section[data-config-name='keymapLegendStyle']", - )?.show(); - qs( - ".pageSettings .section[data-config-name='keymapShowTopRow']", - )?.show(); - qs(".pageSettings .section[data-config-name='keymapSize']")?.show(); - } - }, - }); - groups["keymapStyle"] = new SettingsGroup("keymapStyle", "button"); - groups["keymapLayout"] = new SettingsGroup("keymapLayout", "select"); - groups["keymapLegendStyle"] = new SettingsGroup( - "keymapLegendStyle", - "button", - ); - groups["keymapShowTopRow"] = new SettingsGroup("keymapShowTopRow", "button"); - groups["keymapSize"] = new SettingsGroup("keymapSize", "range"); - groups["showKeyTips"] = new SettingsGroup("showKeyTips", "button"); - groups["freedomMode"] = new SettingsGroup("freedomMode", "button", { - setCallback: () => { - groups["confidenceMode"]?.updateUI(); - }, - }); - groups["strictSpace"] = new SettingsGroup("strictSpace", "button"); - groups["oppositeShiftMode"] = new SettingsGroup( - "oppositeShiftMode", - "button", - ); - groups["confidenceMode"] = new SettingsGroup("confidenceMode", "button", { - setCallback: () => { - groups["freedomMode"]?.updateUI(); - groups["stopOnError"]?.updateUI(); - }, - }); - groups["indicateTypos"] = new SettingsGroup("indicateTypos", "button"); - groups["compositionDisplay"] = new SettingsGroup( - "compositionDisplay", - "button", - ); - groups["hideExtraLetters"] = new SettingsGroup("hideExtraLetters", "button"); - groups["blindMode"] = new SettingsGroup("blindMode", "button"); - groups["quickEnd"] = new SettingsGroup("quickEnd", "button"); - groups["repeatQuotes"] = new SettingsGroup("repeatQuotes", "button"); - groups["ads"] = new SettingsGroup("ads", "button"); - groups["alwaysShowWordsHistory"] = new SettingsGroup( - "alwaysShowWordsHistory", - "button", - ); - groups["britishEnglish"] = new SettingsGroup("britishEnglish", "button"); - groups["singleListCommandLine"] = new SettingsGroup( - "singleListCommandLine", - "button", - ); - groups["capsLockWarning"] = new SettingsGroup("capsLockWarning", "button"); - groups["flipTestColors"] = new SettingsGroup("flipTestColors", "button"); - groups["showOutOfFocusWarning"] = new SettingsGroup( - "showOutOfFocusWarning", - "button", - ); - groups["colorfulMode"] = new SettingsGroup("colorfulMode", "button"); - groups["startGraphsAtZero"] = new SettingsGroup( - "startGraphsAtZero", - "button", - ); - groups["autoSwitchTheme"] = new SettingsGroup("autoSwitchTheme", "button"); - groups["randomTheme"] = new SettingsGroup("randomTheme", "button"); - groups["stopOnError"] = new SettingsGroup("stopOnError", "button", { - setCallback: () => { - groups["confidenceMode"]?.updateUI(); - }, - }); - groups["soundVolume"] = new SettingsGroup("soundVolume", "range"); - groups["playTimeWarning"] = new SettingsGroup("playTimeWarning", "button", { - setCallback: () => { - if (Config.playTimeWarning !== "off") void Sound.playTimeWarning(); - }, - }); - groups["playSoundOnError"] = new SettingsGroup("playSoundOnError", "button", { - setCallback: () => { - if (Config.playSoundOnError !== "off") void Sound.playError(); - }, - }); - groups["playSoundOnClick"] = new SettingsGroup("playSoundOnClick", "button", { - setCallback: () => { - if (Config.playSoundOnClick !== "off") void Sound.playClick("KeyQ"); - }, - }); - groups["showAllLines"] = new SettingsGroup("showAllLines", "button"); - groups["paceCaret"] = new SettingsGroup("paceCaret", "button"); - groups["repeatedPace"] = new SettingsGroup("repeatedPace", "button"); - groups["minWpm"] = new SettingsGroup("minWpm", "button"); - groups["minAcc"] = new SettingsGroup("minAcc", "button"); - groups["minBurst"] = new SettingsGroup("minBurst", "button"); - groups["smoothLineScroll"] = new SettingsGroup("smoothLineScroll", "button"); - groups["lazyMode"] = new SettingsGroup("lazyMode", "button"); - groups["layout"] = new SettingsGroup("layout", "select"); - groups["language"] = new SettingsGroup("language", "select"); - groups["fontSize"] = new SettingsGroup("fontSize", "input", { - validation: { schema: true, inputValueConvert: Number }, - }); - groups["maxLineWidth"] = new SettingsGroup("maxLineWidth", "input", { - validation: { schema: true, inputValueConvert: Number }, - }); - groups["caretStyle"] = new SettingsGroup("caretStyle", "button"); - groups["paceCaretStyle"] = new SettingsGroup("paceCaretStyle", "button"); - groups["timerStyle"] = new SettingsGroup("timerStyle", "button"); - groups["liveSpeedStyle"] = new SettingsGroup("liveSpeedStyle", "button"); - groups["liveAccStyle"] = new SettingsGroup("liveAccStyle", "button"); - groups["liveBurstStyle"] = new SettingsGroup("liveBurstStyle", "button"); - groups["highlightMode"] = new SettingsGroup("highlightMode", "button"); - groups["typedEffect"] = new SettingsGroup("typedEffect", "button"); - groups["tapeMode"] = new SettingsGroup("tapeMode", "button"); - groups["tapeMargin"] = new SettingsGroup("tapeMargin", "input", { - validation: { schema: true, inputValueConvert: Number }, - }); - groups["timerOpacity"] = new SettingsGroup("timerOpacity", "button"); - groups["timerColor"] = new SettingsGroup("timerColor", "button"); - groups["fontFamily"] = new SettingsGroup("fontFamily", "button", { - updateCallback: () => { - const customButton = qs( - ".pageSettings .section[data-config-name='fontFamily'] .buttons button[data-config-value='custom']", - ); - - if ( - qsa( - ".pageSettings .section[data-config-name='fontFamily'] .buttons .active", - ).length === 0 - ) { - customButton?.addClass("active"); - customButton?.setText( - `Custom (${Config.fontFamily.replace(/_/g, " ")})`, - ); - } else { - customButton?.setText("Custom"); - } - }, - }); - groups["alwaysShowDecimalPlaces"] = new SettingsGroup( - "alwaysShowDecimalPlaces", - "button", - ); - groups["typingSpeedUnit"] = new SettingsGroup("typingSpeedUnit", "button"); - groups["customBackgroundSize"] = new SettingsGroup( - "customBackgroundSize", - "button", - ); -} - -async function fillSettingsPage(): Promise { - if (settingsInitialized) { - return; - } - // Language Selection Combobox - new SlimSelect({ - select: ".pageSettings .section[data-config-name='language'] select", - data: getLanguageDropdownData((language) => language === Config.language), - settings: { - searchPlaceholder: "search", - }, - }); - - const layoutToOption: (layout: LayoutName) => OptionOptional = (layout) => ({ - value: layout, - text: layout.replace(/_/g, " "), - }); - - new SlimSelect({ - select: ".pageSettings .section[data-config-name='layout'] select", - data: [ - { text: "off", value: "default" }, - ...LayoutsList.filter((layout) => layout !== "korean").map( - layoutToOption, - ), - ], - }); - - new SlimSelect({ - select: ".pageSettings .section[data-config-name='keymapLayout'] select", - data: [ - { text: "emulator sync", value: "overrideSync" }, - ...LayoutsList.map(layoutToOption), - ], - }); - - new SlimSelect({ - select: - ".pageSettings .section[data-config-name='autoSwitchThemeInputs'] select.light", - data: getThemeDropdownData((theme) => theme.name === Config.themeLight), - events: { - afterChange: (newVal): void => { - setConfig("themeLight", newVal[0]?.value as ThemeName); - }, - }, - }); - - new SlimSelect({ - select: - ".pageSettings .section[data-config-name='autoSwitchThemeInputs'] select.dark", - data: getThemeDropdownData((theme) => theme.name === Config.themeDark), - events: { - afterChange: (newVal): void => { - setConfig("themeDark", newVal[0]?.value as ThemeName); - }, - }, - }); - - const funboxEl = document.querySelector( - ".pageSettings .section[data-config-name='funbox'] .buttons", - ) as HTMLDivElement; - let funboxElHTML = ""; - - for (const funbox of getAllFunboxes()) { - if (funbox.name === "mirror") { - funboxElHTML += ``; - } else if (funbox.name === "upside_down") { - funboxElHTML += ``; - } else if (funbox.name === "underscore_spaces") { - // Display as "underscore_spaces". Does not replace underscores with spaces. - funboxElHTML += ``; - } else { - funboxElHTML += ``; - } - } - funboxEl.innerHTML = funboxElHTML; - - const fontsEl = document.querySelector( - ".pageSettings .section[data-config-name='fontFamily'] .buttons", - ) as HTMLDivElement; - - if (fontsEl.innerHTML === "") { - let fontsElHTML = ""; - - for (const name of Misc.typedKeys(Fonts).sort((a, b) => - (Fonts[a].display ?? a.replace(/_/g, " ")).localeCompare( - Fonts[b].display ?? b.replace(/_/g, " "), - ), - )) { - const font = Fonts[name]; - let fontFamily = name.replace(/_/g, " "); - - if (!font.systemFont) { - fontFamily += " Preview"; - } - const activeClass = Config.fontFamily === name ? " active" : ""; - const display = font.display ?? name.replace(/_/g, " "); - - fontsElHTML += ``; - } - - fontsElHTML += - ''; - - fontsEl.innerHTML = fontsElHTML; - } - - customLayoutFluidSelect = new SlimSelect({ - select: - ".pageSettings .section[data-config-name='customLayoutfluid'] select", - settings: { keepOrder: true, minSelected: 2 }, - events: { - afterChange: (newVal): void => { - const customLayoutfluid = newVal.map( - (it) => it.value, - ) as CustomLayoutFluid; - //checking equal with order, because customLayoutfluid is ordered - if ( - !areSortedArraysEqual(customLayoutfluid, Config.customLayoutfluid) - ) { - void setConfig("customLayoutfluid", customLayoutfluid); - } - }, - }, - }); - - customPolyglotSelect = new SlimSelect({ - select: ".pageSettings .section[data-config-name='customPolyglot'] select", - settings: { minSelected: 2 }, - data: getLanguageDropdownData((language) => - Config.customPolyglot.includes(language), - ), - events: { - afterChange: (newVal): void => { - const customPolyglot = newVal.map((it) => it.value) as Language[]; - //checking equal without order, because customPolyglot is not ordered - if (!areUnsortedArraysEqual(customPolyglot, Config.customPolyglot)) { - void setConfig("customPolyglot", customPolyglot); - } - }, - }, - }); - - handleConfigInput({ - input: qsr(".pageSettings .section[data-config-name='minWpm'] input"), - configName: "minWpmCustomSpeed", - validation: { - schema: true, - inputValueConvert: (it) => - getTypingSpeedUnit(Config.typingSpeedUnit).toWpm( - new Number(it).valueOf(), - ), - }, - }); - - handleConfigInput({ - input: qsr(".pageSettings .section[data-config-name='minAcc'] input"), - configName: "minAccCustom", - validation: { - schema: true, - inputValueConvert: Number, - }, - }); - - handleConfigInput({ - input: qsr(".pageSettings .section[data-config-name='minBurst'] input"), - configName: "minBurstCustomSpeed", - validation: { - schema: true, - inputValueConvert: (it) => - getTypingSpeedUnit(Config.typingSpeedUnit).toWpm( - new Number(it).valueOf(), - ), - }, - }); - - handleConfigInput({ - input: qsr(".pageSettings .section[data-config-name='paceCaret'] input"), - configName: "paceCaretCustomSpeed", - validation: { - schema: true, - inputValueConvert: Number, - }, - }); - - handleConfigInput({ - input: qsr( - ".pageSettings .section[data-config-name='customBackgroundSize'] input[type='text']", - ), - configName: "customBackground", - validation: { - schema: true, - resetIfEmpty: false, - }, - }); - - setEventDisabled(true); - - await initGroups(); - await ThemePicker.fillCustomButtons(); - - setEventDisabled(false); - settingsInitialized = true; -} - -// export let settingsFillPromise = fillSettingsPage(); - -export function hideAccountSection(): void { - qsa(`.pageSettings .section.needsAccount`)?.hide(); -} - -function showAccountSection(): void { - qsa(`.pageSettings .section.needsAccount`)?.show(); - refreshTagsSettingsSection(); - refreshPresetsSettingsSection(); -} - -function setActiveFunboxButton(): void { - const buttons = document.querySelectorAll( - `.pageSettings .section[data-config-name='funbox'] button`, - ); - - for (const button of buttons) { - button.classList.remove("active"); - button.classList.remove("disabled"); - - const configValue = button.getAttribute("data-config-value"); - const funboxName = button.getAttribute("data-funbox-name"); - - if (configValue === null || funboxName === null) { - continue; - } - - if (Config.funbox.includes(funboxName as FunboxName)) { - button.classList.add("active"); - } else if ( - !checkCompatibility(getActiveFunboxNames(), funboxName as FunboxName) - ) { - button.classList.add("disabled"); - } - } -} - -const tagsQuery = useTagsLiveQuery(); -const activeTags = createMemo(() => tagsQuery().filter((tag) => tag.active)); -createEffectOn(activeTags, refreshTagsSettingsSection); - -function refreshTagsSettingsSection(): void { - if (isAuthenticated() && DB.getSnapshot()) { - const tagsEl = qs(".pageSettings .section.tags .tagsList")?.empty(); - __nonReactiveTags.getTags().forEach((tag) => { - // let tagPbString = "No PB found"; - // if (tag.pb !== undefined && tag.pb > 0) { - // tagPbString = `PB: ${tag.pb}`; - // } - tagsEl?.appendHtml(` - -
- - - - -
- - `); - }); - qs(".pageSettings .section.tags")?.show(); - } else { - qs(".pageSettings .section.tags")?.hide(); - } -} - -const presetsQuery = usePresetsLiveQuery(); -createEffectOn(presetsQuery, refreshPresetsSettingsSection); - -function refreshPresetsSettingsSection(): void { - if (isAuthenticated() && DB.getSnapshot()) { - const presetsEl = qs( - ".pageSettings .section.presets .presetsList", - )?.empty(); - __nonReactivePresets.getPresets().forEach((preset) => { - presetsEl?.appendHtml(` -
- - - -
- - `); - }); - qs(".pageSettings .section.presets")?.show(); - } else { - qs(".pageSettings .section.presets")?.hide(); - } -} - -export async function updateFilterSectionVisibility(): Promise { - const hasBackgroundUrl = - Config.customBackground !== "" || - (await FileStorage.hasFile("LocalBackgroundFile")); - const isImageVisible = qs(".customBackground img")?.isVisible(); - - if (hasBackgroundUrl && isImageVisible) { - qs( - ".pageSettings .section[data-config-name='customBackgroundFilter']", - )?.show(); - } else { - qs( - ".pageSettings .section[data-config-name='customBackgroundFilter']", - )?.hide(); - } -} - -export async function update( - options: { - eventKey?: ConfigEventKey; - } = {}, -): Promise { - if (getActivePage() !== "settings") { - return; - } - - if (Config.showKeyTips) { - qs(".pageSettings .tip")?.show(); - } else { - qs(".pageSettings .tip")?.hide(); - } - - for (const group of Object.values(groups)) { - if ("updateUI" in group) { - group.updateUI(); - } - } - - refreshTagsSettingsSection(); - refreshPresetsSettingsSection(); - // LanguagePicker.setActiveGroup(); Shifted from grouped btns to combo-box - setActiveFunboxButton(); - await Misc.sleep(0); - ThemePicker.updateActiveTab(); - ThemePicker.setCustomInputs(); - await CustomBackgroundPicker.updateUI(); - await updateFilterSectionVisibility(); - await CustomFontPicker.updateUI(); - FpsLimitSection.update(); - - const setInputValue = ( - key: ConfigKey, - query: string, - value: string | number, - ): void => { - if (options.eventKey === undefined || options.eventKey === key) { - const element = document.querySelector(query) as HTMLInputElement; - if (element === null) { - throw new Error(`Unknown input element ${query}`); - } - - element.value = new String(value).toString(); - element.dispatchEvent(new Event("input")); - } - }; - - setInputValue( - "paceCaret", - ".pageSettings .section[data-config-name='paceCaret'] input.customPaceCaretSpeed", - getTypingSpeedUnit(Config.typingSpeedUnit).fromWpm( - Config.paceCaretCustomSpeed, - ), - ); - - setInputValue( - "minWpmCustomSpeed", - ".pageSettings .section[data-config-name='minWpm'] input.customMinWpmSpeed", - getTypingSpeedUnit(Config.typingSpeedUnit).fromWpm( - Config.minWpmCustomSpeed, - ), - ); - - setInputValue( - "minAccCustom", - ".pageSettings .section[data-config-name='minAcc'] input.customMinAcc", - Config.minAccCustom, - ); - - setInputValue( - "minBurstCustomSpeed", - ".pageSettings .section[data-config-name='minBurst'] input.customMinBurst", - getTypingSpeedUnit(Config.typingSpeedUnit).fromWpm( - Config.minBurstCustomSpeed, - ), - ); - - if (Config.autoSwitchTheme) { - qs( - ".pageSettings .section[data-config-name='autoSwitchThemeInputs']", - )?.show(); - } else { - qs( - ".pageSettings .section[data-config-name='autoSwitchThemeInputs']", - )?.hide(); - } - - setInputValue( - "fontSize", - ".pageSettings .section[data-config-name='fontSize'] input", - Config.fontSize, - ); - - setInputValue( - "maxLineWidth", - ".pageSettings .section[data-config-name='maxLineWidth'] input", - Config.maxLineWidth, - ); - - setInputValue( - "keymapSize", - ".pageSettings .section[data-config-name='keymapSize'] input", - Config.keymapSize, - ); - - setInputValue( - "tapeMargin", - ".pageSettings .section[data-config-name='tapeMargin'] input", - Config.tapeMargin, - ); - - setInputValue( - "customBackground", - ".pageSettings .section[data-config-name='customBackgroundSize'] input[type='text']", - Config.customBackground, - ); - - if (isAuthenticated()) { - showAccountSection(); - } else { - hideAccountSection(); - } - - CustomBackgroundFilter.updateUI(); - - if ( - customLayoutFluidSelect !== undefined && - //checking equal with order, because customLayoutFluid is ordered - !areSortedArraysEqual( - customLayoutFluidSelect.getSelected(), - Config.customLayoutfluid, - ) - ) { - //replace the data because the data is ordered. do not use setSelected - customLayoutFluidSelect.setData(getLayoutfluidDropdownData()); - } - - if ( - customPolyglotSelect !== undefined && - //checking equal without order, because customPolyglot is not ordered - !areUnsortedArraysEqual( - customPolyglotSelect.getSelected(), - Config.customPolyglot, - ) - ) { - customPolyglotSelect.setSelected(Config.customPolyglot); - } -} -function toggleSettingsGroup(groupName: string): void { - //The highlight is repeated/broken when toggling the group - handleHighlightSection(undefined); - - const groupEl = qs(`.pageSettings .settingsGroup.${groupName}`); - if (!groupEl?.hasClass("slideup")) { - void groupEl?.slideUp(250, { - hide: false, - }); - groupEl?.addClass("slideup"); - qs(`.pageSettings .sectionGroupTitle[group=${groupName}]`)?.addClass( - "rotateIcon", - ); - } else { - void groupEl?.slideDown(250); - groupEl?.removeClass("slideup"); - qs(`.pageSettings .sectionGroupTitle[group=${groupName}]`)?.removeClass( - "rotateIcon", - ); - } -} - -//funbox -qs(".pageSettings .section[data-config-name='funbox'] .buttons")?.onChild( - "click", - "button", - (e) => { - const target = e.childTarget as HTMLElement; - const funbox = target?.getAttribute("data-config-value") as FunboxName; - Funbox.toggleFunbox(funbox); - setActiveFunboxButton(); - }, -); - -//tags -qs(".pageSettings .section.tags")?.onChild( - "click", - ".tagsList .tag .tagButton", - (e) => { - const target = e.childTarget as HTMLElement; - const tagid = target.parentElement?.getAttribute("data-id") as string; - toggleTagActive(tagid); - target.classList.toggle("active"); - }, -); - -qs(".pageSettings .section.presets")?.onChild( - "click", - ".presetsList .preset .presetButton", - async (e) => { - const target = e.childTarget as HTMLElement; - const presetid = target.parentElement?.getAttribute("data-id") as string; - await PresetController.apply(presetid); - void update(); - }, -); - -qs("#importSettingsButton")?.on("click", () => { - ImportExportSettingsModal.show("import"); -}); - -qs("#exportSettingsButton")?.on("click", () => { - const configJSON = JSON.stringify(Config); - navigator.clipboard.writeText(configJSON).then( - function () { - showNoticeNotification("JSON Copied to clipboard"); - }, - function () { - ImportExportSettingsModal.show("export"); - }, - ); -}); - -qsa(".pageSettings .sectionGroupTitle")?.on("click", (e) => { - const target = e.currentTarget as HTMLElement; - toggleSettingsGroup(target.getAttribute("group") as string); -}); - -qs( - ".pageSettings .section[data-config-name='keymapSize'] .inputAndButton button.save", -)?.on("click", () => { - const didConfigSave = setConfig( - "keymapSize", - parseFloat( - qs( - ".pageSettings .section[data-config-name='keymapSize'] .inputAndButton input", - )?.getValue() as string, - ), - ); - if (didConfigSave) { - showSuccessNotification("Saved", { durationMs: 1000 }); - } -}); - -qs( - ".pageSettings .section[data-config-name='keymapSize'] .inputAndButton input", -)?.on("focusout", () => { - const didConfigSave = setConfig( - "keymapSize", - parseFloat( - qs( - ".pageSettings .section[data-config-name='keymapSize'] .inputAndButton input", - )?.getValue() as string, - ), - ); - if (didConfigSave) { - showSuccessNotification("Saved", { durationMs: 1000 }); - } -}); - -qs( - ".pageSettings .section[data-config-name='keymapSize'] .inputAndButton input", -)?.on("keypress", (e) => { - if (e.key === "Enter") { - const didConfigSave = setConfig( - "keymapSize", - parseFloat( - qs( - ".pageSettings .section[data-config-name='keymapSize'] .inputAndButton input", - )?.getValue() as string, - ), - ); - if (didConfigSave) { - showSuccessNotification("Saved", { durationMs: 1000 }); - } - } -}); - -qsa(".pageSettings .quickNav .links a")?.on("click", (e) => { - const target = e.currentTarget as HTMLAnchorElement; - const href = target.getAttribute("href") ?? ""; - if (!href.startsWith("#group_")) return; - const settingsGroup = href.slice("#group_".length); - if (settingsGroup === "") return; - const isClosed = qs( - `.pageSettings .settingsGroup.${settingsGroup}`, - )?.hasClass("slideup"); - if (isClosed) { - toggleSettingsGroup(settingsGroup); - } -}); - -let configEventDisabled = false; -export function setEventDisabled(value: boolean): void { - configEventDisabled = value; -} - -function getLanguageDropdownData( - isActive: (val: Language) => boolean, -): DataArrayPartial { - return LanguageGroupNames.map( - (group) => - ({ - label: group, - options: LanguageGroups[group]?.map((language) => ({ - text: Strings.getLanguageDisplayString(language), - value: language, - selected: isActive(language), - })), - }) as Optgroup, - ); -} - -function getLayoutfluidDropdownData(): DataArrayPartial { - const customLayoutfluidActive = Config.customLayoutfluid; - return [ - ...customLayoutfluidActive, - ...LayoutsList.filter((it) => !customLayoutfluidActive.includes(it)), - ].map((layout) => ({ - text: layout.replace(/_/g, " "), - value: layout, - selected: customLayoutfluidActive.includes(layout), - })); -} - -function getThemeDropdownData( - isActive: (theme: ThemeWithName) => boolean, -): DataArrayPartial { - return ThemesList.map((theme) => ({ - value: theme.name, - text: theme.name.replace(/_/g, " "), - selected: isActive(theme), - })); -} - -function handleHighlightSection(highlight: Highlight | undefined): void { - if (highlight === undefined) { - const element = document.querySelector(".section.highlight"); - if (element !== null) { - element.classList.remove("highlight"); - } - return; - } - - const element = document.querySelector( - `[data-config-name="${highlight}"] .groupTitle,[data-section-id="${highlight}"] .groupTitle`, - ); - - if (element !== null) { - setTimeout(() => { - element.scrollIntoView({ block: "center", behavior: "auto" }); - element.parentElement?.classList.remove("highlight"); - element.parentElement?.classList.add("highlight"); - }, 250); - } -} - -qsa(".pageSettings .section .groupTitle button")?.on("click", (e) => { - const target = e.currentTarget as HTMLElement; - const section = target.parentElement?.parentElement; - const configName = (section?.dataset?.["configName"] ?? - section?.dataset?.["sectionId"]) as Highlight | undefined; - if (configName === undefined) { - return; - } - - page.setUrlParams({ highlight: configName }); - - navigator.clipboard - .writeText(window.location.toString()) - .then(() => { - showSuccessNotification("Link copied to clipboard"); - }) - .catch((e: unknown) => { - showErrorNotification("Failed to copy to clipboard", { error: e }); - }); -}); - -qs(".pageSettings")?.onChild( - "click", - ".section.themes .customTheme .delButton", - (e) => { - const parentElement = (e.childTarget as HTMLElement | null)?.closest( - ".customTheme.button", - ); - const customThemeId = parentElement?.getAttribute( - "customThemeId", - ) as string; - showPopup("deleteCustomTheme", [customThemeId]); - }, -); - -qs(".pageSettings")?.onChild( - "click", - ".section.themes .customTheme .editButton", - (e) => { - const parentElement = (e.childTarget as HTMLElement | null)?.closest( - ".customTheme.button", - ); - const customThemeId = parentElement?.getAttribute( - "customThemeId", - ) as string; - showPopup("updateCustomTheme", [customThemeId], { - focusFirstInput: "focusAndSelect", - }); - }, -); - -qs(".pageSettings")?.onChild( - "click", - ".section[data-config-name='fontFamily'] button[data-config-value='custom']", - () => { - showPopup("applyCustomFont"); - }, -); - -qs(".pageSettings #resetSettingsButton")?.on("click", () => { - showPopup("resetSettings"); -}); - -configEvent.subscribe(({ key, newValue }) => { - if (key === "fullConfigChange") setEventDisabled(true); - if (key === "fullConfigChangeFinished") setEventDisabled(false); - if (key === "themeLight") { - qs( - `.pageSettings .section[data-config-name='autoSwitchThemeInputs'] select.light option[value="${newValue}"]`, - )?.setAttribute("selected", "true"); - } else if (key === "themeDark") { - qs( - `.pageSettings .section[data-config-name='autoSwitchThemeInputs'] select.dark option[value="${newValue}"]`, - )?.setAttribute("selected", "true"); - } - //make sure the page doesnt update a billion times when applying a preset/config at once - if (configEventDisabled) return; - if (getActivePage() === "settings" && key !== "theme") { - void (key === "customBackground" - ? updateFilterSectionVisibility() - : update({ eventKey: key })); - } -}); - -authEvent.subscribe((event) => { - if (event.type === "authStateChanged") { - if (event.data.isUserSignedIn) { - showAccountSection(); - } else { - hideAccountSection(); - } - } -}); - -export const page = new PageWithUrlParams({ - id: "settings", - element: qsr(".page.pageSettings"), - path: "/settings", - urlParamsSchema: StateSchema, - afterHide: async (): Promise => { - Skeleton.remove("pageSettings"); - }, - beforeShow: async (options): Promise => { - Skeleton.append("pageSettings", "main"); - await configLoadPromise; //todo: is this actually needed here if we await it in ready? - await fillSettingsPage(); - await update(); - // theme UI updates manually to avoid duplication - await ThemePicker.updateThemeUI(); - - handleHighlightSection(options.urlParams?.highlight); - }, -}); - -onDOMReady(async () => { - Skeleton.save("pageSettings"); -}); diff --git a/frontend/src/ts/states/edit-preset-modal.ts b/frontend/src/ts/states/edit-preset-modal.ts new file mode 100644 index 000000000000..7420d1bc82ac --- /dev/null +++ b/frontend/src/ts/states/edit-preset-modal.ts @@ -0,0 +1,19 @@ +import { createSignal } from "solid-js"; + +import { showModal } from "./modals"; + +export type EditPresetData = { + presetId: string; + name: string; +}; + +const [editPresetData, setEditPresetData] = createSignal( + null, +); + +export { editPresetData }; + +export function showEditPresetModal(data: EditPresetData): void { + setEditPresetData(data); + showModal("EditPresetModal"); +} diff --git a/frontend/src/ts/states/modals.ts b/frontend/src/ts/states/modals.ts index 8989105176c7..c3b10d2460d1 100644 --- a/frontend/src/ts/states/modals.ts +++ b/frontend/src/ts/states/modals.ts @@ -24,7 +24,10 @@ export type ModalId = | "ShareTestSettings" | "CustomWordAmount" | "MobileTestConfig" - | "MiniResultChartModal"; + | "MiniResultChartModal" + | "Cookies" + | "AddPresetModal" + | "EditPresetModal"; export type ModalVisibility = { visible: boolean; diff --git a/frontend/src/ts/states/simple-modal.ts b/frontend/src/ts/states/simple-modal.ts index de950b3bdaac..a9a6e3544fba 100644 --- a/frontend/src/ts/states/simple-modal.ts +++ b/frontend/src/ts/states/simple-modal.ts @@ -18,12 +18,19 @@ type CommonInput = { disabled?: boolean; optional?: boolean; label?: string; + class?: string; oninput?: (event: Event) => void; validation?: Validation; }; -export type TextInput = CommonInput<"text", string>; -export type TextArea = CommonInput<"textarea", string>; +export type TextInput = { + readOnly?: boolean; + clickToSelect?: boolean; +} & CommonInput<"text", string>; +export type TextArea = { + readOnly?: boolean; + clickToSelect?: boolean; +} & CommonInput<"textarea", string>; export type PasswordInput = CommonInput<"password", string>; type EmailInput = CommonInput<"email", string>; @@ -75,11 +82,15 @@ export type ExecReturn = { }; export type SimpleModalConfig = { + class?: string; title: string; inputs?: SimpleModalInput[]; text?: string; + textClass?: string; textAllowHtml?: boolean; - buttonText: string; + buttonText?: string; + buttonAlwaysEnabled?: boolean; + focusFirstInput?: true | "focusAndSelect"; execFn: (...inputValues: string[]) => Promise; }; diff --git a/frontend/src/ts/test/test-screenshot.ts b/frontend/src/ts/test/test-screenshot.ts index c80e511c045e..acdf7fdf4f0c 100644 --- a/frontend/src/ts/test/test-screenshot.ts +++ b/frontend/src/ts/test/test-screenshot.ts @@ -1,6 +1,5 @@ import { showLoaderBar, hideLoaderBar } from "../states/loader-bar"; import * as Replay from "./replay"; -import * as Misc from "../utils/misc"; import { getActivePage, isAuthenticated, @@ -21,7 +20,6 @@ import { qs, qsa } from "../utils/dom"; import { getTheme } from "../states/theme"; let revealReplay = false; -let revertCookie = false; function revert(): void { setIsScreenshotting(false); @@ -36,7 +34,6 @@ function revert(): void { qs("#result")?.removeClass("noBalloons"); qs(".wordInputHighlight")?.show(); qsa(".highlightContainer")?.show(); - if (revertCookie) qs("#cookiesModal")?.show(); if (revealReplay) qs("#resultReplay")?.show(); if (!isAuthenticated()) { qs(".pageTest .loginTip")?.show(); @@ -62,12 +59,6 @@ async function generateCanvas(): Promise { revealReplay = true; Replay.pauseReplay(); } - if ( - Misc.isElementVisible("#cookiesModal") || - document.contains(document.querySelector("#cookiesModal")) - ) { - revertCookie = true; - } // --- UI Preparation --- const dateNow = new Date(Date.now()); @@ -98,7 +89,6 @@ async function generateCanvas(): Promise { qs("#result")?.addClass("noBalloons"); qs(".wordInputHighlight")?.hide(); qsa(".highlightContainer")?.hide(); - if (revertCookie) qs("#cookiesModal")?.hide(); for (const fb of getActiveFunboxesWithFunction("clearGlobal")) { fb.functions.clearGlobal(); diff --git a/frontend/src/ts/utils/file-storage.ts b/frontend/src/ts/utils/file-storage.ts index bb8915109e57..09c0a006a7ba 100644 --- a/frontend/src/ts/utils/file-storage.ts +++ b/frontend/src/ts/utils/file-storage.ts @@ -1,4 +1,5 @@ import { openDB, DBSchema, IDBPDatabase } from "idb"; +import { createSignal } from "solid-js"; type FileDB = DBSchema & { files: { @@ -11,6 +12,10 @@ type Filename = "LocalBackgroundFile" | "LocalFontFamilyFile"; class FileStorage { private dbPromise: Promise>; + private signals = new Map< + Filename, + [get: () => number, set: (v: number | ((prev: number) => number)) => void] + >(); constructor(dbName = "file-storage-db") { this.dbPromise = openDB(dbName, 1, { @@ -22,9 +27,36 @@ class FileStorage { }); } + private getSignal( + filename: Filename, + ): [ + get: () => number, + set: (v: number | ((prev: number) => number)) => void, + ] { + let signal = this.signals.get(filename); + if (!signal) { + signal = createSignal(0); + this.signals.set(filename, signal); + } + return signal; + } + + private notify(filename: Filename): void { + const signal = this.signals.get(filename); + if (signal) { + signal[1]((v) => v + 1); + } + } + + /** Subscribe to changes for a filename. Call within a reactive context. Returns a version number. */ + track(filename: Filename): number { + return this.getSignal(filename)[0](); + } + async storeFile(filename: Filename, dataUrl: string): Promise { const db = await this.dbPromise; await db.put("files", dataUrl, filename); + this.notify(filename); } async getFile(filename: Filename): Promise { @@ -35,6 +67,7 @@ class FileStorage { async deleteFile(filename: Filename): Promise { const db = await this.dbPromise; await db.delete("files", filename); + this.notify(filename); } async listFilenames(): Promise { diff --git a/frontend/src/ts/utils/strings.ts b/frontend/src/ts/utils/strings.ts index e0cc500323ca..e766eb29aac3 100644 --- a/frontend/src/ts/utils/strings.ts +++ b/frontend/src/ts/utils/strings.ts @@ -22,6 +22,21 @@ export function camelCaseToWords(str: string): string { .toLowerCase(); } +/** + * Converts a string with words separated by spaces to camelCase. + * @param str The input string with words separated by spaces. + * @returns The camelCase version of the input string. + */ +export function wordsToCamelCase(str: string): string { + return str + .toLowerCase() + .split(/ +/) + .map((word, index) => + index === 0 ? word : word.charAt(0).toUpperCase() + word.slice(1), + ) + .join(""); +} + /** * Returns the last character of a string. * @param word The input string. diff --git a/frontend/src/ts/utils/zod.ts b/frontend/src/ts/utils/zod.ts new file mode 100644 index 000000000000..325d715f1079 --- /dev/null +++ b/frontend/src/ts/utils/zod.ts @@ -0,0 +1,18 @@ +import { z, ZodSchema } from "zod"; + +export function getOptions( + schema: T, +): undefined | z.infer[] { + if (schema instanceof z.ZodLiteral) { + return [schema.value] as z.infer[]; + } else if (schema instanceof z.ZodEnum) { + return schema.options as z.infer[]; + } else if (schema instanceof z.ZodBoolean) { + return [false, true] as z.infer[]; + } else if (schema instanceof z.ZodUnion) { + return (schema.options as ZodSchema[]) + .flatMap(getOptions) + .filter((it) => it !== undefined) as z.infer[]; + } + return undefined; +} diff --git a/packages/oxlint-config/plugins/monkeytype-rules.js b/packages/oxlint-config/plugins/monkeytype-rules.js index 0da96cb2f685..ca66820dfe43 100644 --- a/packages/oxlint-config/plugins/monkeytype-rules.js +++ b/packages/oxlint-config/plugins/monkeytype-rules.js @@ -111,7 +111,10 @@ const plugin = { return { MemberExpression(node) { - if (node.object?.name === "__nonReactive") { + if ( + node.property?.name === "__nonReactive" || + node.object?.name === "__nonReactive" + ) { const componentName = getComponentAncestor(node); if (componentName) { context.report({ diff --git a/packages/oxlint-config/rules/jsx.jsonc b/packages/oxlint-config/rules/jsx.jsonc index 3886eda75063..67eea1849580 100644 --- a/packages/oxlint-config/rules/jsx.jsonc +++ b/packages/oxlint-config/rules/jsx.jsonc @@ -29,6 +29,8 @@ "innerHTML", "onScrollEnd", "router-link", + "on:click", + "for", ], }, ], diff --git a/packages/schemas/src/presets.ts b/packages/schemas/src/presets.ts index 91de2dad4b14..163bf913f3cd 100644 --- a/packages/schemas/src/presets.ts +++ b/packages/schemas/src/presets.ts @@ -6,7 +6,7 @@ import { PartialConfigSchema, } from "./configs"; -export const PresetNameSchema = nameWithSeparators().max(16); +export const PresetNameSchema = nameWithSeparators().max(16).min(1); export type PresetName = z.infer; export const PresetTypeSchema = z.enum(["full", "partial"]);