From 5415c0a7b30b5f2487c87850ab5b1fb8c81024d5 Mon Sep 17 00:00:00 2001 From: "Rong \"Mantle\" Bao" Date: Mon, 10 Jul 2023 16:17:27 +0800 Subject: [PATCH 1/4] feat: add user-friendly error reporting with TResult --- src/common/TResult.ts | 18 +++++++ src/common/index.ts | 3 +- src/components/FileFormControl.tsx | 13 ++++-- src/components/NumberFormControl.tsx | 14 ++++-- src/pages/settings.tsx | 70 +++++++++------------------- 5 files changed, 61 insertions(+), 57 deletions(-) create mode 100644 src/common/TResult.ts diff --git a/src/common/TResult.ts b/src/common/TResult.ts new file mode 100644 index 0000000..5155bea --- /dev/null +++ b/src/common/TResult.ts @@ -0,0 +1,18 @@ +/* + * Copyright (C) 2021-present Rong "Mantle" Bao + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see https://www.gnu.org/licenses/ . + */ + +export type TResult = { ok: true; value?: T } | { ok: false; err?: E } diff --git a/src/common/index.ts b/src/common/index.ts index 900c5f0..06f7300 100644 --- a/src/common/index.ts +++ b/src/common/index.ts @@ -39,6 +39,7 @@ import { waitForEvent } from "./waitForEvent" import type { ISize } from "./ISize" import type { TPosition } from "./TPosition" +import type { TResult } from "./TResult" export { AutoplaySentinel, @@ -61,4 +62,4 @@ export { rearrange, waitForEvent, } -export type { ISize, TPosition } +export type { ISize, TPosition, TResult } diff --git a/src/components/FileFormControl.tsx b/src/components/FileFormControl.tsx index 8c643ca..faf84f8 100644 --- a/src/components/FileFormControl.tsx +++ b/src/components/FileFormControl.tsx @@ -26,7 +26,7 @@ import TextField from "@mui/material/TextField" import Tooltip from "@mui/material/Tooltip" import styled from "@mui/system/styled" -import { isNil } from "../common" +import type { TResult } from "../common" const HiddenInput = styled("input")({ display: "none", @@ -79,13 +79,14 @@ interface IFileFormControlProps { readonly helperText: string readonly readOnly?: boolean readonly disabled?: boolean - readonly onFileChange: (newContent: string) => boolean | void + readonly onFileChange: (newContent: string) => TResult readonly contentPreprocessor?: (content: string) => string } export const FileFormControl = (props: IFileFormControlProps) => { const [fileContent, setFileContent] = React.useState(props.initialFileContent) const [error, setError] = React.useState(false) + const [helperText, setHelperText] = React.useState(props.helperText) return ( @@ -95,7 +96,7 @@ export const FileFormControl = (props: IFileFormControlProps) => { fullWidth value={fileContent} label={props.label} - helperText={props.helperText} + helperText={helperText} disabled={props.disabled ?? false} error={error} InputProps={{ @@ -113,9 +114,11 @@ export const FileFormControl = (props: IFileFormControlProps) => { let content = (await head(event.target.files)?.text()) ?? "" content = props.contentPreprocessor?.(content) ?? content const result = props.onFileChange(content) - const isSuccessful = isNil(result) || result setFileContent(content) - setError(!isSuccessful) + setError(!result.ok) + setHelperText( + result.ok ? props.helperText : result.err ?? props.helperText + ) }} /> diff --git a/src/components/NumberFormControl.tsx b/src/components/NumberFormControl.tsx index 25aa4d9..36f3067 100644 --- a/src/components/NumberFormControl.tsx +++ b/src/components/NumberFormControl.tsx @@ -21,6 +21,8 @@ import TextField from "@mui/material/TextField" import { isNil } from "../common" +import type { TResult } from "../common" + interface INumberFormControlProps { readonly id: string readonly initialContent: string @@ -34,13 +36,14 @@ interface INumberFormControlProps { readonly min?: number readonly max?: number readonly disabled?: boolean - readonly onChange: (newContent: string) => boolean | void + readonly onChange: (newContent: string) => TResult readonly contentPreprocessor?: (content: string) => string } export const NumberFormControl = (props: INumberFormControlProps) => { const [content, setContent] = React.useState(props.initialContent) const [error, setError] = React.useState(false) + const [helperText, setHelperText] = React.useState(props.helperText) return ( { value={content} label={props.label} type="number" - helperText={props.helperText} + helperText={helperText} InputProps={{ ...props.adornments }} inputProps={{ + inputMode: "numeric", step: props.step, min: props.min, max: props.max, @@ -62,9 +66,11 @@ export const NumberFormControl = (props: INumberFormControlProps) => { ? event.target.value : props.contentPreprocessor(event.target.value) const result = props.onChange(content) - const isSuccessful = isNil(result) || result setContent(content) - setError(!isSuccessful) + setError(!result.ok) + setHelperText( + result.ok ? props.helperText : result.err ?? props.helperText + ) }} /> ) diff --git a/src/pages/settings.tsx b/src/pages/settings.tsx index 4e1b46f..0fa330d 100644 --- a/src/pages/settings.tsx +++ b/src/pages/settings.tsx @@ -46,9 +46,12 @@ import { queryPath } from "../common" import { CommonHead, FileFormControl, NumberFormControl } from "../components" import { customizationFacade } from "../customization" +const INT_REGEX = /^[0-9]+$/ + const tryParseInt = (s: string): readonly [boolean, number] => { - const v = parseInt(s, 10) - return [!isNaN(v), v] + const isInt = INT_REGEX.test(s) + const v = isInt ? parseInt(s, 10) : Number.NaN + return [isInt, v] } const App = ({ data }: PageProps) => { @@ -172,12 +175,9 @@ const App = ({ data }: PageProps) => { const [isValid, value] = tryParseInt(newContent) if (isValid) { customizationFacade.settings.gridLineThickness = value - return true + return { ok: true } } else { - enqueueSnackbar(t("msg_int_expected"), { - variant: "error", - }) - return false + return { ok: false, err: t("msg_int_expected") } } }} /> @@ -210,12 +210,9 @@ const App = ({ data }: PageProps) => { const obj = JSON.parse(newContent) if (validateColorScheme(obj)) { customizationFacade.settings.colorScheme = obj - return true + return { ok: true } } else { - enqueueSnackbar(t("msg_color_scheme_invalid"), { - variant: "error", - }) - return false + return { ok: false, err: t("msg_color_scheme_invalid") } } }} contentPreprocessor={jsonMinifyPreprocessor} @@ -245,12 +242,9 @@ const App = ({ data }: PageProps) => { const obj = JSON.parse(newContent) if (validateMap(obj)) { customizationFacade.settings.gameMap = obj - return true + return { ok: true } } else { - enqueueSnackbar(t("msg_game_map_invalid"), { - variant: "error", - }) - return false + return { ok: false, err: t("msg_game_map_invalid") } } }} contentPreprocessor={jsonMinifyPreprocessor} @@ -270,12 +264,9 @@ const App = ({ data }: PageProps) => { if (isValid) { customizationFacade.settings.gameUpdateIntervalMilliseconds = value - return true + return { ok: true } } else { - enqueueSnackbar(t("msg_int_expected"), { - variant: "error", - }) - return false + return { ok: false, err: t("msg_int_expected") } } }} /> @@ -304,12 +295,9 @@ const App = ({ data }: PageProps) => { const [isValid, value] = tryParseInt(newContent) if (isValid) { customizationFacade.settings.swipeThreshold = value - return true + return { ok: true } } else { - enqueueSnackbar(t("msg_int_expected"), { - variant: "error", - }) - return false + return { ok: false, err: t("msg_int_expected") } } }} /> @@ -327,12 +315,9 @@ const App = ({ data }: PageProps) => { const [isValid, value] = tryParseInt(newContent) if (isValid) { customizationFacade.settings.swipeDeltaX = value - return true + return { ok: true } } else { - enqueueSnackbar(t("msg_int_expected"), { - variant: "error", - }) - return false + return { ok: false, err: t("msg_int_expected") } } }} /> @@ -350,12 +335,9 @@ const App = ({ data }: PageProps) => { const [isValid, value] = tryParseInt(newContent) if (isValid) { customizationFacade.settings.swipeDeltaY = value - return true + return { ok: true } } else { - enqueueSnackbar(t("msg_int_expected"), { - variant: "error", - }) - return false + return { ok: false, err: t("msg_int_expected") } } }} /> @@ -373,12 +355,9 @@ const App = ({ data }: PageProps) => { const [isValid, value] = tryParseInt(newContent) if (isValid) { customizationFacade.settings.pressThreshold = value - return true + return { ok: true } } else { - enqueueSnackbar(t("msg_int_expected"), { - variant: "error", - }) - return false + return { ok: false, err: t("msg_int_expected") } } }} /> @@ -404,12 +383,9 @@ const App = ({ data }: PageProps) => { const [isValid, value] = tryParseInt(newContent) if (isValid) { customizationFacade.settings.concurrency = value - return true + return { ok: true } } else { - enqueueSnackbar(t("msg_int_expected"), { - variant: "error", - }) - return false + return { ok: false, err: t("msg_int_expected") } } }} /> From cac2c5c1f2fdcbd282b8721346c5771c3757d630 Mon Sep 17 00:00:00 2001 From: "Rong \"Mantle\" Bao" Date: Mon, 10 Jul 2023 16:33:57 +0800 Subject: [PATCH 2/4] feat: add localized language name --- src/common/index.ts | 2 ++ src/common/localize.ts | 28 ++++++++++++++++++++++++++++ src/i18n/index.ts | 4 ++-- src/pages/settings.tsx | 4 ++-- 4 files changed, 34 insertions(+), 4 deletions(-) create mode 100644 src/common/localize.ts diff --git a/src/common/index.ts b/src/common/index.ts index 06f7300..812b983 100644 --- a/src/common/index.ts +++ b/src/common/index.ts @@ -33,6 +33,7 @@ import { import { flushed } from "./flushed" import { formatDuration } from "./formatDuration" import { isNil } from "./isNil" +import { getLocalizedLangName } from "./localize" import { queryPath } from "./queryPath" import { rearrange } from "./rearrange" import { waitForEvent } from "./waitForEvent" @@ -57,6 +58,7 @@ export { StopwatchUpdateIntervalMilliseconds, flushed, formatDuration, + getLocalizedLangName, isNil, queryPath, rearrange, diff --git a/src/common/localize.ts b/src/common/localize.ts new file mode 100644 index 0000000..c00cbcd --- /dev/null +++ b/src/common/localize.ts @@ -0,0 +1,28 @@ +/* + * Copyright (C) 2021-present Rong "Mantle" Bao + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see https://www.gnu.org/licenses/ . + */ + +export function getLocalizedLangName(lang: string): string { + switch (lang) { + case "zh": + return "中文" + case "en": + return "English" + + default: + throw new RangeError(`getLocalizedLangName: unknown language ${lang}`) + } +} diff --git a/src/i18n/index.ts b/src/i18n/index.ts index c645543..a54ef2e 100644 --- a/src/i18n/index.ts +++ b/src/i18n/index.ts @@ -15,6 +15,6 @@ * along with this program. If not, see https://www.gnu.org/licenses/ . */ -import { langs, defaultLang } from "./lang" +import { defaultLang, langs } from "./lang" -export { langs, defaultLang } +export { defaultLang, langs } diff --git a/src/pages/settings.tsx b/src/pages/settings.tsx index 0fa330d..d746fcd 100644 --- a/src/pages/settings.tsx +++ b/src/pages/settings.tsx @@ -42,7 +42,7 @@ import Tooltip from "@mui/material/Tooltip" import Typography from "@mui/material/Typography" import { PageID } from "../PageID" -import { queryPath } from "../common" +import { getLocalizedLangName, queryPath } from "../common" import { CommonHead, FileFormControl, NumberFormControl } from "../components" import { customizationFacade } from "../customization" @@ -142,7 +142,7 @@ const App = ({ data }: PageProps) => { > {languages.map((lang) => ( - {lang} + {`${lang} - ${getLocalizedLangName(lang)}`} ))} From 6d29055b7ff9418419c00cadd299d40bd4867e70 Mon Sep 17 00:00:00 2001 From: "Rong \"Mantle\" Bao" Date: Mon, 10 Jul 2023 17:14:12 +0800 Subject: [PATCH 3/4] i18n: add translations for game and autoplay --- .../GameControlBackdrop.tsx | 53 +++++++++++-------- src/i18n/locales/en/autoplay.json | 13 +++++ src/i18n/locales/en/game.json | 13 +++++ src/i18n/locales/zh/autoplay.json | 13 +++++ src/i18n/locales/zh/game.json | 13 +++++ src/pages/autoplay.tsx | 2 +- src/pages/game.tsx | 2 +- src/pages/index.tsx | 2 +- 8 files changed, 86 insertions(+), 25 deletions(-) create mode 100644 src/i18n/locales/en/autoplay.json create mode 100644 src/i18n/locales/en/game.json create mode 100644 src/i18n/locales/zh/autoplay.json create mode 100644 src/i18n/locales/zh/game.json diff --git a/src/components/gameControlBackdrop/GameControlBackdrop.tsx b/src/components/gameControlBackdrop/GameControlBackdrop.tsx index c489148..091c08f 100644 --- a/src/components/gameControlBackdrop/GameControlBackdrop.tsx +++ b/src/components/gameControlBackdrop/GameControlBackdrop.tsx @@ -17,6 +17,7 @@ import { navigate } from "gatsby" import React from "react" +import { useI18next } from "gatsby-plugin-react-i18next" import Backdrop from "@mui/material/Backdrop" import Button from "@mui/material/Button" @@ -33,13 +34,15 @@ interface IGameNotStartedContentProps { } const GameNotStartedContent = (props: IGameNotStartedContentProps) => { + const { t } = useI18next() + return ( <> - - Welcome! Your task: complete the Periodic Table. + + {t("typ_h_not_started_intro")} - - A/D/S/Swipe: move by one. W/Tap: rotate. Space/Long press: drop. + + {t("typ_p_not_started_intro")} @@ -64,13 +67,15 @@ const GameNotStartedContent = (props: IGameNotStartedContentProps) => { } const GamePreparingContent = () => { + const { t } = useI18next() + return ( <> - - Good luck! + + {t("typ_h_preparing")} - - Generating new map for you. The game will start in a few seconds. + + {t("typ_p_preparing")} @@ -83,13 +88,15 @@ interface IGameLostContentProps { } const GameLostContent = (props: IGameLostContentProps) => { + const { t } = useI18next() + return ( <> - - Oops... + + {t("typ_h_lost")} - - This does not seem to be right. Ready to give it another shot? + + {t("typ_p_lost")} @@ -119,13 +126,15 @@ interface IGameWonContentProps { } const GameWonContent = (props: IGameWonContentProps) => { + const { t } = useI18next() + return ( <> - - Congrats! + + {t("typ_h_won")} - - You finished the game! Don't hesitate to brag about it. + + {t("typ_p_won")} diff --git a/src/i18n/locales/en/autoplay.json b/src/i18n/locales/en/autoplay.json new file mode 100644 index 0000000..a8c7ff6 --- /dev/null +++ b/src/i18n/locales/en/autoplay.json @@ -0,0 +1,13 @@ +{ + "typ_h_not_started_intro": "Welcome to Periotris.js AUTOPLAY", + "typ_p_not_started_intro": "In AUTOPLAY, the system takes over game input, and all user key presses and gestures are ignored. This session will not be recorded in play history. Apart from those, it is identical to Normal Mode and observes the same user settings, including map, color scheme and game speed.", + "cap_home": "HOME", + "cap_start": "START", + "cap_retry": "RETRY", + "typ_h_preparing": "Please wait", + "typ_p_preparing": "Generating new unique patterns. The demonstration will start in a few seconds.", + "typ_h_lost": "Ouch...", + "typ_p_lost": "This should not happen. Please report this to the developer.", + "typ_h_won": "Demo complete", + "typ_p_won": "This AUTOPLAY session is over. Feel free to start a new one." +} diff --git a/src/i18n/locales/en/game.json b/src/i18n/locales/en/game.json new file mode 100644 index 0000000..3c41114 --- /dev/null +++ b/src/i18n/locales/en/game.json @@ -0,0 +1,13 @@ +{ + "typ_h_not_started_intro": "Welcome to Periotris.js! Your task: complete the Periodic Table", + "typ_p_not_started_intro": "A/D/S/Swipe: move by one; W/Tap: rotate; Space/Long press: drop", + "cap_home": "HOME", + "cap_start": "START", + "cap_retry": "RETRY", + "typ_h_preparing": "Good luck!", + "typ_p_preparing": "Generating new unique patterns for you. The game will start in a few seconds.", + "typ_h_lost": "Oops...", + "typ_p_lost": "This does not seem to be right. Ready to give it another shot?", + "typ_h_won": "Congrats!", + "typ_p_won": "You finished the game! Don't hesitate to brag about it." +} diff --git a/src/i18n/locales/zh/autoplay.json b/src/i18n/locales/zh/autoplay.json new file mode 100644 index 0000000..3736211 --- /dev/null +++ b/src/i18n/locales/zh/autoplay.json @@ -0,0 +1,13 @@ +{ + "typ_h_not_started_intro": "欢迎来到 Periotris.js 自动演示模式", + "typ_p_not_started_intro": "在自动演示模式中,系统将接管游戏输入,用户发出的按键与触屏手势无效。本次游玩将不计入历史。除了以上几点,自动演示模式与常规模式相同,并采用相同的用户自定义设置,包括地图、色彩主题和游戏速度。", + "cap_home": "首页", + "cap_start": "开始", + "cap_retry": "重试", + "typ_h_preparing": "请稍候", + "typ_p_preparing": "正在生成新的分割排列。自动演示将马上开始。", + "typ_h_lost": "不好……", + "typ_p_lost": "这不应该发生。请向开发者报告该事件。", + "typ_h_won": "演示完成", + "typ_p_won": "本次自动演示已结束。你可以开始一次新的自动演示。" +} diff --git a/src/i18n/locales/zh/game.json b/src/i18n/locales/zh/game.json new file mode 100644 index 0000000..56c0665 --- /dev/null +++ b/src/i18n/locales/zh/game.json @@ -0,0 +1,13 @@ +{ + "typ_h_not_started_intro": "欢迎来到 Periotris.js !你的任务:拼出化学元素周期表", + "typ_p_not_started_intro": "A/D/S/触屏滑动:移动一格;W/触屏短按:旋转;Space/触屏长按:立即下落", + "cap_home": "首页", + "cap_start": "开始", + "cap_retry": "重试", + "typ_h_preparing": "祝你好运!", + "typ_p_preparing": "正在生成全新的分割排列。游戏将马上开始。", + "typ_h_lost": "糟糕……", + "typ_p_lost": "这种排列方式不正确。再试一次?", + "typ_h_won": "祝贺!", + "typ_p_won": "你成功完成了这次挑战,请尽情欣赏你的杰作吧!" +} diff --git a/src/pages/autoplay.tsx b/src/pages/autoplay.tsx index 376d76c..b96bb89 100644 --- a/src/pages/autoplay.tsx +++ b/src/pages/autoplay.tsx @@ -89,7 +89,7 @@ export const query = graphql` } locales: allLocale( - filter: { ns: { in: ["index"] }, language: { eq: $language } } + filter: { ns: { in: ["autoplay"] }, language: { eq: $language } } ) { edges { node { diff --git a/src/pages/game.tsx b/src/pages/game.tsx index 7790f27..00c4a61 100644 --- a/src/pages/game.tsx +++ b/src/pages/game.tsx @@ -127,7 +127,7 @@ export const query = graphql` } locales: allLocale( - filter: { ns: { in: ["index"] }, language: { eq: $language } } + filter: { ns: { in: ["game"] }, language: { eq: $language } } ) { edges { node { diff --git a/src/pages/index.tsx b/src/pages/index.tsx index 005602d..55a9074 100644 --- a/src/pages/index.tsx +++ b/src/pages/index.tsx @@ -73,7 +73,7 @@ const App = ({ data }: PageProps) => { - +