diff --git a/.eslintrc.json b/.eslintrc.json index b192418..fca6087 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -49,6 +49,7 @@ "eslint:recommended", "plugin:@typescript-eslint/recommended", "prettier/@typescript-eslint", - "plugin:react/recommended" + "plugin:react/recommended", + "plugin:react-hooks/recommended" ] } diff --git a/.vscode/settings.json b/.vscode/settings.json index 9c6e107..f07e6cf 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -7,6 +7,7 @@ "fontello": "src/fontello", }, "cSpell.words": [ + "Lngs", "Transpiler", "Unfocus", "apos", @@ -17,6 +18,7 @@ "focusable", "formik", "keepalive", + "languagedetector", "mixins", "motd", "multiline", diff --git a/README.md b/README.md index 2d37df8..6606dd7 100644 --- a/README.md +++ b/README.md @@ -26,6 +26,28 @@ [Conventional Commits](https://www.conventionalcommits.org/en/v1.0.0/) as a standard for commit messages. +### Contributing translations + +Translation files are currently created manually in the +[i18next JSON format](https://www.i18next.com/misc/json-format). You can find +existing translations in [`public/locales`](public/locales). The +[English (GB) translation](public/locales/en/translation.json) is used as the +fallback. + +Language files are named with +[IETF language tags](https://en.wikipedia.org/wiki/IETF_language_tag). Short +tags (e.g. `en` instead of `en-GB`) are preferred. The library will +automatically detect the language from your browser to use, but for the sake of +testing, you can override it by running the following command in the developer +console (Ctrl+Shift+I): + +```js +localStorage.i18nextLng = "en"; +``` + +If you need any help with specific i18next features (e.g. handling plurals), +don't hesitate to contact Lemmmy. + ### Providing host attribution To provide hosting credits in the sidebar footer, create the file @@ -46,4 +68,4 @@ ### License -This project is licensed under the GPL v3 license. See LICENSE.txt for more. \ No newline at end of file +This project is licensed under the GPL v3 license. See LICENSE.txt for more. diff --git a/package-lock.json b/package-lock.json index 45e74c7..fafd483 100644 --- a/package-lock.json +++ b/package-lock.json @@ -7028,6 +7028,14 @@ } } }, + "html-parse-stringify2": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/html-parse-stringify2/-/html-parse-stringify2-2.0.1.tgz", + "integrity": "sha1-3FZwtyksoVi3vJFsmmc1rIhyg0o=", + "requires": { + "void-elements": "^2.0.1" + } + }, "html-webpack-plugin": { "version": "4.0.0-beta.11", "resolved": "https://registry.npmjs.org/html-webpack-plugin/-/html-webpack-plugin-4.0.0-beta.11.tgz", @@ -7132,6 +7140,30 @@ "resolved": "https://registry.npmjs.org/https-browserify/-/https-browserify-1.0.0.tgz", "integrity": "sha1-7AbBDgo0wPL68Zn3/X/Hj//QPHM=" }, + "i18next": { + "version": "19.7.0", + "resolved": "https://registry.npmjs.org/i18next/-/i18next-19.7.0.tgz", + "integrity": "sha512-sxZhj6u7HbEYOMx81oGwq5MiXISRBVg2wRY3n6YIbe+HtU8ydzlGzv6ErHdrRKYxATBFssVXYbc3lNZoyB4vfA==", + "requires": { + "@babel/runtime": "^7.10.1" + } + }, + "i18next-browser-languagedetector": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/i18next-browser-languagedetector/-/i18next-browser-languagedetector-6.0.1.tgz", + "integrity": "sha512-3H+OsNQn3FciomUU0d4zPFHsvJv4X66lBelXk9hnIDYDsveIgT7dWZ3/VvcSlpKk9lvCK770blRZ/CwHMXZqWw==", + "requires": { + "@babel/runtime": "^7.5.5" + } + }, + "i18next-http-backend": { + "version": "1.0.20", + "resolved": "https://registry.npmjs.org/i18next-http-backend/-/i18next-http-backend-1.0.20.tgz", + "integrity": "sha512-2GXbO/J+Mip8k4UymghGv1Xoq+FDNg/15ZCjTWHn7xazKRjV0jFkR0BMCoVQTWkweULmSJb+Rr+sF1y3jGgWxw==", + "requires": { + "node-fetch": "2.6.1" + } + }, "iconv-lite": { "version": "0.4.24", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", @@ -9258,6 +9290,11 @@ "tslib": "^1.10.0" } }, + "node-fetch": { + "version": "2.6.1", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.6.1.tgz", + "integrity": "sha512-V4aYg89jEoVRxRb2fJdAg8FHvI7cEyYdVAh94HH0UIK8oJxUfkjlDQN9RbMx+bEjP7+ggMiFRprSti032Oipxw==" + }, "node-forge": { "version": "0.9.0", "resolved": "https://registry.npmjs.org/node-forge/-/node-forge-0.9.0.tgz", @@ -11661,6 +11698,15 @@ "resolved": "https://registry.npmjs.org/react-fast-compare/-/react-fast-compare-2.0.4.tgz", "integrity": "sha512-suNP+J1VU1MWFKcyt7RtjiSWUjvidmQSlqu+eHslq+342xCbGTYmC0mEhPCOHxlW0CywylOC1u2DFAT+bv4dBw==" }, + "react-i18next": { + "version": "11.7.2", + "resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-11.7.2.tgz", + "integrity": "sha512-Djj3K3hh5Tecla2CI9rLO3TZBYGMFrGilm0JY4cLofAQONCi5TK6nVmUPKoB59n1ZffgjfgJt6zlbE9aGF6Q0Q==", + "requires": { + "@babel/runtime": "^7.3.1", + "html-parse-stringify2": "2.0.1" + } + }, "react-is": { "version": "16.13.1", "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", @@ -14232,6 +14278,11 @@ "resolved": "https://registry.npmjs.org/vm-browserify/-/vm-browserify-1.1.2.tgz", "integrity": "sha512-2ham8XPWTONajOR0ohOKOHXkm3+gaBmGut3SRuu75xLd/RRaY6vqgh8NBYYk7+RW3u5AtzPQZG8F10LHkl0lAQ==" }, + "void-elements": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/void-elements/-/void-elements-2.0.1.tgz", + "integrity": "sha1-wGavtYK7HLQSjWDqkjkulNXp2+w=" + }, "w3c-hr-time": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/w3c-hr-time/-/w3c-hr-time-1.0.2.tgz", diff --git a/package.json b/package.json index d973470..320dc11 100644 --- a/package.json +++ b/package.json @@ -19,9 +19,13 @@ "bootstrap": "^4.5.2", "debug": "^4.1.1", "formik": "^2.1.5", + "i18next": "^19.7.0", + "i18next-browser-languagedetector": "^6.0.1", + "i18next-http-backend": "^1.0.20", "react": "^16.13.1", "react-bootstrap": "^1.3.0", "react-dom": "^16.13.1", + "react-i18next": "^11.7.2", "react-redux": "^7.2.1", "react-router-bootstrap": "^0.25.0", "react-router-dom": "^5.2.0", @@ -36,7 +40,6 @@ "scripts": { "start": "craco start", "build": "craco build", - "test": "craco test", "storybook": "start-storybook -p 9009 -s public", "build-storybook": "build-storybook -s public", "font-install": "fontello-cli install --config src/fontello/config.json --css src/fontello/css --font src/fontello/font", @@ -59,9 +62,6 @@ ] }, "devDependencies": { - "@testing-library/jest-dom": "^4.2.4", - "@testing-library/react": "^9.5.0", - "@testing-library/user-event": "^7.2.1", "@types/debug": "^4.1.5", "@types/jest": "^24.9.1", "@types/node": "^12.12.55", diff --git a/public/locales/en/translation.json b/public/locales/en/translation.json new file mode 100644 index 0000000..bdeccd1 --- /dev/null +++ b/public/locales/en/translation.json @@ -0,0 +1,90 @@ +{ + "app": { + "name": "KristWeb" + }, + + "nav": { + "connection": { + "online": "Online", + "offline": "Offline", + "connecting": "Connecting" + }, + + "search": "Search the Krist network", + + "send": "Send", + "request": "Request" + }, + + "sidebar": { + "totalBalance": "Total Balance", + "guestIndicator": "Browsing as guest", + "dashboard": "Dashboard", + "myWallets": "My Wallets", + "addressBook": "Address Book", + "transactions": "Transactions", + "names": "Names", + "mining": "Mining", + "network": "Network", + "blocks": "Blocks", + "statistics": "Statistics", + "madeBy": "Made by <1>{{authorName}}", + "hostedBy": "Hosted by <1>{{host}}", + "github": "GitHub", + "credits": "Credits" + }, + + "dialog": { + "close": "Close" + }, + + "pagination": { + "justPage": "Page {{page}}", + "pageWithTotal": "Page {{page}} of {{total}}" + }, + + "loading": "Loading...", + + "masterPassword": { + "dialogTitle": "Master password", + "passwordPlaceholder": "Master password", + "browseAsGuest": "Browse as guest", + "createPassword": "Create password", + "logIn": "Log in", + "forgotPassword": "Forgot password?", + "intro": "Enter a master password to encrypt your wallets, or browse KristWeb as a guest <1>.", + "dontForgetPassword": "Never forget this password. If you forget it, you will have to create a new password and add your wallets all over again.", + "loginIntro": "Enter a master password to access your wallets, or browse KristWeb as a guest.", + "learnMore": "learn more", + "errorPasswordRequired": "Password is required.", + "errorPasswordUnset": "Master password has not been set up.", + "errorPasswordIncorrect": "Incorrect password.", + "errorUnknown": "Unknown error.", + "helpWalletStorageTitle": "Help: Wallet storage", + "helpWalletStorage": "When you add a wallet to KristWeb, the private key for the wallet is saved to your browser's local storage and encrypted with your master password.\nEvery wallet you save is encrypted using the same master password, and you will need to enter it every time you open KristWeb. Your actual Krist wallet is not modified in any way.\nWhen browsing KristWeb as a guest, you do not need to enter a master password, but it also means that you will not be able to add or use any wallets. You will still be able to explore the Krist network." + }, + + "myWallets": { + "title": "{{count}} wallet", + "title_plural": "{{count}} wallets", + "titleLoading": "Loading wallets...", + "manageBackups": "Manage backups", + "createWallet": "Create wallet", + "addExistingWallet": "Add existing wallet", + "searchPlaceholder": "Search wallets...", + "categoryDropdownAll": "All categories", + "columnLabel": "Label", + "columnAddress": "Address", + "columnBalance": "Balance", + "columnNames": "Names", + "columnCategory": "Category", + "columnFirstSeen": "First Seen" + }, + + "credits": { + "madeBy": "Made by <1>{{authorName}}", + "supportersTitle": "Supporters", + "supportersDescription": "This project was made possible by the following amazing supporters:", + "supportButton": "Support KristWeb" + } +} diff --git a/public/locales/ja/translation.json b/public/locales/ja/translation.json new file mode 100644 index 0000000..e714ab1 --- /dev/null +++ b/public/locales/ja/translation.json @@ -0,0 +1,61 @@ +{ + "nav": { + "connection": { + "online": "オンライン", + "offline": "オフライン", + "connecting": "接続中" + }, + + "search": "Kristネットワークを検索", + + "send": "送る", + "request": "受け取る" + }, + + "sidebar": { + "totalBalance": "合計残高", + "guestIndicator": "ゲストです", + "dashboard": "ダッシュボード", + "myWallets": "私の財布", + "addressBook": "住所録", + "transactions": "トランザクション", + "names": "名前", + "mining": "マイニング", + "network": "ネットワーク", + "blocks": "ブロック", + "statistics": "統計", + "madeBy": "作成者:<1>{{authorName}}", + "hostedBy": "ホスト:<1>{{host}}", + "credits": "謝辞" + }, + + "dialog": { + "close": "終了" + }, + + "pagination": { + "justPage": "Page {{page}}", + "pageWithTotal": "Page {{page}} of {{total}}" + }, + + "loading": "読み込み中...", + + "masterPassword": { + "dialogTitle": "マスターパスワード", + "passwordPlaceholder": "マスターパスワード", + "browseAsGuest": "ゲストとして閲覧", + "createPassword": "パスワードの作成", + "logIn": "ログイン", + "forgotPassword": "パスワードをお忘れの場合", + "intro": "新しいマスターパスワードを入力して財布を暗号化する、かゲストとしてKristWebを閲覧することができます<1>。", + "dontForgetPassword": "このパスワードは絶対に忘れないようにしましょう。忘れてしまった場合は、新しいパスワードを作成して、再度すべての財布を追加する必要があります。", + "loginIntro": "マスターパスワードを入力して財布を開けする、かゲストとしてKristWebを閲覧することができます。", + "learnMore": "詳細はこちら", + "errorPasswordRequired": "パスワードが必要です。", + "errorPasswordUnset": "マスターパスワードが設定されていません。", + "errorPasswordIncorrect": "パスワードが間違っています。", + "errorUnknown": "不明なエラーです。", + "helpWalletStorageTitle": "助けて:財布の保管", + "helpWalletStorage": "KristWebにウォレットを追加すると、ウォレットの秘密鍵はブラウザのローカルストレージに保存され、マスターパスワードで暗号化されます。\n保存されたすべてのウォレットは同じマスターパスワードで暗号化され、KristWebを開くたびに入力する必要があります。あなたの実際のウォレットは一切変更されません。\nゲストとしてKristWebを閲覧する場合、マスターパスワードを入力する必要はありませんが、ウォレットを追加したり使用したりすることはできません。あなたはまだKristネットワークを探索することができます。" + } +} diff --git a/src/app/App.tsx b/src/app/App.tsx index 88814f5..7d7828d 100644 --- a/src/app/App.tsx +++ b/src/app/App.tsx @@ -1,4 +1,4 @@ -import React from "react"; +import React, { Suspense } from "react"; import "./App.scss"; import { MainLayout } from "../layouts/main"; @@ -20,8 +20,10 @@ .catch(console.error);*/ export const App: React.FC = () => ( - - - - + {/* TODO */} + + + + + ); diff --git a/src/app/WalletManager.tsx b/src/app/WalletManager.tsx index d51c19d..bf32275 100644 --- a/src/app/WalletManager.tsx +++ b/src/app/WalletManager.tsx @@ -4,9 +4,6 @@ import { AppDispatch } from "./App"; import * as actions from "@actions/WalletManagerActions"; -import Debug from "debug"; -const debug = Debug("kristweb:walletManager"); - export function browseAsGuest(dispatch: AppDispatch): void { dispatch(actions.browseAsGuest()); } @@ -14,8 +11,8 @@ /** Verifies that the given password is correct, and dispatches the login * action to the Redux store. */ export async function login(dispatch: AppDispatch, salt: string | undefined, tester: string | undefined, password: string): Promise { - if (!password) throw new Error("Password is required."); - if (!salt || !tester) throw new Error("Master password has not been set up."); + if (!password) throw new Error("masterPassword.errorPasswordRequired"); + if (!salt || !tester) throw new Error("masterPassword.errorPasswordUnset"); try { // Attempt to decrypt the tester with the given password @@ -46,8 +43,6 @@ // Generate the encryption tester const tester = await aesGcmEncrypt(saltHex, password); - debug("master password salt: %x tester: %s", salt, tester); - // Store them in local storage localStorage.setItem("salt", saltHex); localStorage.setItem("tester", tester); diff --git a/src/index.tsx b/src/index.tsx index c69f121..0f3e91e 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -2,6 +2,7 @@ import React from "react"; import ReactDOM from "react-dom"; + import "./index.scss"; import { App } from "./app/App"; diff --git a/src/layouts/credits/index.tsx b/src/layouts/credits/index.tsx index eb06694..961613b 100644 --- a/src/layouts/credits/index.tsx +++ b/src/layouts/credits/index.tsx @@ -1,5 +1,7 @@ import React, { Component, ReactNode } from "react"; +import { Trans, withTranslation, WithTranslation } from "react-i18next"; + import Container from "react-bootstrap/Container"; import Row from "react-bootstrap/Row"; import Col from "react-bootstrap/Col"; @@ -17,8 +19,8 @@ supporters: Supporter[] | null; }; -export class Credits extends Component { - constructor(props: unknown) { +class CreditsPageComponent extends Component { + constructor(props: WithTranslation) { super(props); this.state = { @@ -41,7 +43,9 @@ } render(): ReactNode { + const { t } = this.props; const { isLoaded, supporters } = this.state; + const authorName = packageJson.author || "Lemmmy"; const authorURL = `https://github.com/${authorName}`; const supportURL = packageJson.supportURL; @@ -50,7 +54,11 @@

KristWeb v2

-

Made by {authorName}

+

+ + Made by {{authorName}} + +


@@ -58,22 +66,22 @@ <> -

Supporters

-

This project was made possible by the following amazing supporters:

+

{t("credits.supportersTitle")}

+

{t("credits.supportersDescription")}

{/* Supporter list */} {isLoaded && supporters !== null ? - : Loading... + : {t("loading")} } {/* Support button */} @@ -83,6 +91,8 @@ } } +export const CreditsPage = withTranslation()(CreditsPageComponent); + interface SupportersProps { supporters: Supporter[]; } diff --git a/src/layouts/dialogs/HelpWalletStorageDialog.tsx b/src/layouts/dialogs/HelpWalletStorageDialog.tsx index 0d0e5bf..7e73edc 100644 --- a/src/layouts/dialogs/HelpWalletStorageDialog.tsx +++ b/src/layouts/dialogs/HelpWalletStorageDialog.tsx @@ -1,8 +1,11 @@ import React, { useState, MouseEvent } from "react"; +import { useTranslation } from "react-i18next"; + import { ModalDialog } from "./ModalDialog"; export const HelpWalletStorageLink: React.FC = () => { + const { t } = useTranslation(); const [show, setShow] = useState(false); // Help dialog show/close state is essentially handled by the link @@ -17,7 +20,7 @@ {/* Add a link to show the dialog */} {/* TODO: make this a {/* Right side */} {hasMasterPassword ? <> {/* They have a master password, show login */} - - + + - : + : ( + + ) } @@ -140,4 +161,4 @@ } } -export const MasterPasswordDialog = connector(MasterPasswordDialogComponent); +export const MasterPasswordDialog = withTranslation()(connector(MasterPasswordDialogComponent)); diff --git a/src/layouts/dialogs/utils/CloseButton.tsx b/src/layouts/dialogs/utils/CloseButton.tsx index 4d1dbae..5393b4e 100644 --- a/src/layouts/dialogs/utils/CloseButton.tsx +++ b/src/layouts/dialogs/utils/CloseButton.tsx @@ -1,5 +1,7 @@ import React from "react"; +import { useTranslation } from "react-i18next"; + import Button from "react-bootstrap/Button"; interface Props { @@ -7,15 +9,19 @@ alignRight?: boolean; } -export const CloseButton: React.FC = ({ handleClose, alignRight }: Props) => ( - -); +export const CloseButton: React.FC = ({ handleClose, alignRight }: Props) => { + const { t } = useTranslation(); + + return ( + + ); +}; diff --git a/src/layouts/main/components/nav/Brand.tsx b/src/layouts/main/components/nav/Brand.tsx index f5ea124..c45c22b 100644 --- a/src/layouts/main/components/nav/Brand.tsx +++ b/src/layouts/main/components/nav/Brand.tsx @@ -1,5 +1,7 @@ import React from "react"; +import { useTranslation } from "react-i18next"; + import semverMajor from "semver/functions/major"; import semverMinor from "semver/functions/minor"; import semverPatch from "semver/functions/patch"; @@ -19,6 +21,8 @@ }; export const Brand = (): JSX.Element => { + const { t } = useTranslation(); + const version = packageJson.version; const major = semverMajor(version); @@ -36,7 +40,7 @@ return (
- KristWeb + {t("app.name")} v{major}.{minor}.{patch} {badge} diff --git a/src/layouts/main/components/nav/ConnectionIndicator.tsx b/src/layouts/main/components/nav/ConnectionIndicator.tsx index 2b996a0..25deef8 100644 --- a/src/layouts/main/components/nav/ConnectionIndicator.tsx +++ b/src/layouts/main/components/nav/ConnectionIndicator.tsx @@ -1,9 +1,15 @@ import React from "react"; +import { useTranslation } from "react-i18next"; + import "./ConnectionIndicator.scss"; -export const ConnectionIndicator = (): JSX.Element => ( -
- Online -
-); +export const ConnectionIndicator = (): JSX.Element => { + const { t } = useTranslation(); + + return ( +
+ {t("nav.connection.online")} +
+ ); +}; diff --git a/src/layouts/main/components/nav/Search.tsx b/src/layouts/main/components/nav/Search.tsx index 6bf7a8f..8d08638 100644 --- a/src/layouts/main/components/nav/Search.tsx +++ b/src/layouts/main/components/nav/Search.tsx @@ -1,12 +1,18 @@ import React from "react"; +import { useTranslation } from "react-i18next"; + import Form from "react-bootstrap/Form"; import FormControl from "react-bootstrap/FormControl"; import "./Search.scss"; -export const Search = (): JSX.Element => ( -