diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..41ea4c2 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,11 @@ +root = true + +[*] +charset = utf-8 +end_of_line = lf +insert_final_newline = true + +[*.{js,ts,jsx,tsx,css,less,json}] +indent_style = space +indent_size = 2 +trim_trailing_whitespace = true diff --git a/.eslintignore b/.eslintignore new file mode 100644 index 0000000..e21d4ed --- /dev/null +++ b/.eslintignore @@ -0,0 +1,6 @@ +node_modules +build +tools +public +typings +craco.config.js diff --git a/.eslintrc.json b/.eslintrc.json new file mode 100644 index 0000000..eb2f2be --- /dev/null +++ b/.eslintrc.json @@ -0,0 +1,75 @@ +{ + "root": true, + "parser": "@typescript-eslint/parser", + "plugins": [ + "@typescript-eslint", + "eslint-plugin-tsdoc" + ], + "parserOptions": { + "project": "./tsconfig.json", + "tsconfigRootDir": ".", + "ecmaVersion": 2018, + "ecmaFeatures": { + "jsx": true + }, + "sourceType": "module" + }, + "settings": { + "react": { + "version": "detect" + } + }, + "rules": { + "quotes": ["error", "double", { "allowTemplateLiterals": true }], + "semi": "error", + "indent": ["error", 2, { + "FunctionDeclaration": { "parameters": "first" } + }], + "eol-last": ["error", "always"], + "object-shorthand": ["error", "always"], + "no-unused-vars": 0, + "no-lonely-if": "warn", + "no-trailing-spaces": "warn", + "no-whitespace-before-property": "warn", + "space-before-blocks": "warn", + "space-in-parens": ["warn", "never"], + "space-infix-ops": "warn", + "eqeqeq": "warn", + + "react/display-name": 0, + "react/prop-types": 0, + "react/jsx-uses-react": "off", + "react/react-in-jsx-scope": "off", + + "tsdoc/syntax": "warn", + "@typescript-eslint/no-explicit-any": 0, + "@typescript-eslint/explicit-module-boundary-types": ["warn", { + "allowArgumentsExplicitlyTypedAsAny": true, + "allowDirectConstAssertionInArrowFunctions": true, + "allowedNames": [], + "allowHigherOrderFunctions": true, + "allowTypedFunctionExpressions": true + }], + "@typescript-eslint/consistent-type-definitions": ["error", "interface"], + "@typescript-eslint/member-delimiter-style": ["error", { + "multiline": {"delimiter": "semi", "requireLast": true}, + "singleline": {"delimiter": "semi", "requireLast": false} + }], + "@typescript-eslint/no-unused-vars": ["warn", { + "ignoreRestSiblings": true, + "argsIgnorePattern": "^_" + }], + "@typescript-eslint/no-non-null-assertion": 0, + "@typescript-eslint/space-before-function-paren": ["warn", { + "anonymous": "never", + "named": "never", + "asyncArrow": "always" + }] + }, + "extends": [ + "eslint:recommended", + "plugin:@typescript-eslint/recommended", + "plugin:react/recommended", + "plugin:react-hooks/recommended" + ] +} diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml new file mode 100644 index 0000000..f714a8e --- /dev/null +++ b/.github/FUNDING.yml @@ -0,0 +1 @@ +custom: ["https://donate.lemmmy.pw"] diff --git a/.github/workflows/deploy-dev.yml b/.github/workflows/deploy-dev.yml new file mode 100644 index 0000000..0f2966f --- /dev/null +++ b/.github/workflows/deploy-dev.yml @@ -0,0 +1,49 @@ +name: Deploy dev build + +on: + push: + branches: + - master + +jobs: + deploy-dev: + runs-on: ubuntu-20.04 + + steps: + - name: Install git + run: | + sudo apt-get install -y software-properties-common \ + && sudo apt-get update \ + && sudo add-apt-repository -y ppa:git-core/ppa \ + && sudo apt-get update \ + && sudo apt-get install -y git + + - name: Check out repository code + uses: actions/checkout@v2 + with: + fetch-depth: 0 + + - name: Install dependencies + run: yarn install + + - name: Build + run: yarn run full-build + env: + SENTRY_ENABLED: true + SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_TOKEN }} + SENTRY_DSN: ${{ secrets.SENTRY_DSN }} + SENTRY_URL: ${{ secrets.SENTRY_URL }} + SENTRY_ORG: ${{ secrets.SENTRY_ORG }} + SENTRY_PROJECT: ${{ secrets.SENTRY_PROJECT }} + SENTRY_NO_PROGRESS_BAR: 1 + + - name: Deploy to dev server + uses: appleboy/scp-action@master + with: + host: ${{ secrets.DEV_DEPLOY_HOST }} + username: ${{ secrets.DEV_DEPLOY_USER }} + key: ${{ secrets.DEV_DEPLOY_KEY }} + port: ${{ secrets.DEV_DEPLOY_PORT }} + source: "build" + target: ${{ secrets.DEV_DEPLOY_ROOT }} + rm: true diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..88608a7 --- /dev/null +++ b/.gitignore @@ -0,0 +1,28 @@ +# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. + +# dependencies +/node_modules +/.pnp +.pnp.js +package-lock.json + +# testing +/coverage + +# production +/build + +# misc +.DS_Store +.env.local +.env.development.local +.env.test.local +.env.production.local + +npm-debug.log* +yarn-debug.log* +yarn-error.log* + +/src/__data__/host*.json + +.sentryclirc diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 0000000..a17ea99 --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,29 @@ +{ + "version": "0.2.0", + "configurations": [ + { + "type": "chrome", + "request": "launch", + "name": "Launch Chrome against localhost", + "url": "http://localhost:3000", + "webRoot": "${workspaceFolder}" + }, + { + "type": "firefox", + "request": "launch", + "name": "Launch Firefox against localhost", + "url": "http://localhost:3000", + "webRoot": "${workspaceFolder}", + "profile": "tenebraweb", + "keepProfileChanges": true, + "reAttach": true + }, + { + "type": "firefox", + "request": "attach", + "name": "Attach Firefox against localhost", + "url": "http://localhost:3000", + "webRoot": "${workspaceFolder}" + } + ] +} diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..7ee233f --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,90 @@ +{ + "cSpell.words": [ + "AGPL", + "Algo", + "Authed", + "Authorise", + "Debounces", + "Inequal", + "KRISTWALLET", + "KRISTWALLETEXTENSION", + "Lemmy", + "Lngs", + "Lyqydate", + "Mutex", + "Notif", + "Popconfirm", + "Precache", + "Sider", + "Syncable", + "Transpiler", + "UNSYNC", + "Voronoi", + "Websockets", + "antd", + "anticon", + "appendhashes", + "arraybuffer", + "authorised", + "behaviour", + "broadcastchannel", + "btns", + "categorised", + "chartjs", + "clientside", + "commithash", + "commonmeta", + "compat", + "cryptocurrency", + "desaturate", + "dont", + "firstseen", + "gitlog", + "initialising", + "jwalelset", + "languagedetector", + "linkify", + "localisation", + "masterkey", + "memoises", + "metaname", + "mgmt", + "middot", + "midiots", + "motd", + "multiline", + "nolink", + "optimisation", + "personalise", + "pkgbuild", + "pnpm", + "precaching", + "privatekeys", + "readonly", + "serialisable", + "serialised", + "shallowequal", + "singleline", + "submenu", + "summarising", + "testid", + "timeago", + "totalin", + "totalout", + "treenode", + "tsdoc", + "typeahead", + "uncategorised", + "unmount", + "unmounting", + "unregistering", + "unsyncable", + "webpackbar", + "whatsnew" + ], + "i18next.defaultTranslatedLocale": "en", + "i18next.i18nPaths": "public/locales", + "files.associations": { + "public/locales/**.json": "json5" + } +} diff --git a/.vscode/tasks.json b/.vscode/tasks.json new file mode 100644 index 0000000..33602e9 --- /dev/null +++ b/.vscode/tasks.json @@ -0,0 +1,10 @@ +{ + "version": "2.0.0", + "tasks": [ + { + "type": "npm", + "script": "start", + "isBackground": true + } + ] +} diff --git a/LICENSE.txt b/LICENSE.txt new file mode 100644 index 0000000..be3f7b2 --- /dev/null +++ b/LICENSE.txt @@ -0,0 +1,661 @@ + GNU AFFERO GENERAL PUBLIC LICENSE + Version 3, 19 November 2007 + + Copyright (C) 2007 Free Software Foundation, Inc. + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The GNU Affero General Public License is a free, copyleft license for +software and other kinds of works, specifically designed to ensure +cooperation with the community in the case of network server software. + + The licenses for most software and other practical works are designed +to take away your freedom to share and change the works. By contrast, +our General Public Licenses are intended to guarantee your freedom to +share and change all versions of a program--to make sure it remains free +software for all its users. + + When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +them if you wish), that you receive source code or can get it if you +want it, that you can change the software or use pieces of it in new +free programs, and that you know you can do these things. + + Developers that use our General Public Licenses protect your rights +with two steps: (1) assert copyright on the software, and (2) offer +you this License which gives you legal permission to copy, distribute +and/or modify the software. + + A secondary benefit of defending all users' freedom is that +improvements made in alternate versions of the program, if they +receive widespread use, become available for other developers to +incorporate. Many developers of free software are heartened and +encouraged by the resulting cooperation. However, in the case of +software used on network servers, this result may fail to come about. +The GNU General Public License permits making a modified version and +letting the public access it on a server without ever releasing its +source code to the public. + + The GNU Affero General Public License is designed specifically to +ensure that, in such cases, the modified source code becomes available +to the community. It requires the operator of a network server to +provide the source code of the modified version running there to the +users of that server. Therefore, public use of a modified version, on +a publicly accessible server, gives the public access to the source +code of the modified version. + + An older license, called the Affero General Public License and +published by Affero, was designed to accomplish similar goals. This is +a different license, not a version of the Affero GPL, but Affero has +released a new version of the Affero GPL which permits relicensing under +this license. + + The precise terms and conditions for copying, distribution and +modification follow. + + TERMS AND CONDITIONS + + 0. Definitions. + + "This License" refers to version 3 of the GNU Affero General Public License. + + "Copyright" also means copyright-like laws that apply to other kinds of +works, such as semiconductor masks. + + "The Program" refers to any copyrightable work licensed under this +License. Each licensee is addressed as "you". "Licensees" and +"recipients" may be individuals or organizations. + + To "modify" a work means to copy from or adapt all or part of the work +in a fashion requiring copyright permission, other than the making of an +exact copy. The resulting work is called a "modified version" of the +earlier work or a work "based on" the earlier work. + + A "covered work" means either the unmodified Program or a work based +on the Program. + + To "propagate" a work means to do anything with it that, without +permission, would make you directly or secondarily liable for +infringement under applicable copyright law, except executing it on a +computer or modifying a private copy. Propagation includes copying, +distribution (with or without modification), making available to the +public, and in some countries other activities as well. + + To "convey" a work means any kind of propagation that enables other +parties to make or receive copies. Mere interaction with a user through +a computer network, with no transfer of a copy, is not conveying. + + An interactive user interface displays "Appropriate Legal Notices" +to the extent that it includes a convenient and prominently visible +feature that (1) displays an appropriate copyright notice, and (2) +tells the user that there is no warranty for the work (except to the +extent that warranties are provided), that licensees may convey the +work under this License, and how to view a copy of this License. If +the interface presents a list of user commands or options, such as a +menu, a prominent item in the list meets this criterion. + + 1. Source Code. + + The "source code" for a work means the preferred form of the work +for making modifications to it. "Object code" means any non-source +form of a work. + + A "Standard Interface" means an interface that either is an official +standard defined by a recognized standards body, or, in the case of +interfaces specified for a particular programming language, one that +is widely used among developers working in that language. + + The "System Libraries" of an executable work include anything, other +than the work as a whole, that (a) is included in the normal form of +packaging a Major Component, but which is not part of that Major +Component, and (b) serves only to enable use of the work with that +Major Component, or to implement a Standard Interface for which an +implementation is available to the public in source code form. A +"Major Component", in this context, means a major essential component +(kernel, window system, and so on) of the specific operating system +(if any) on which the executable work runs, or a compiler used to +produce the work, or an object code interpreter used to run it. + + The "Corresponding Source" for a work in object code form means all +the source code needed to generate, install, and (for an executable +work) run the object code and to modify the work, including scripts to +control those activities. However, it does not include the work's +System Libraries, or general-purpose tools or generally available free +programs which are used unmodified in performing those activities but +which are not part of the work. For example, Corresponding Source +includes interface definition files associated with source files for +the work, and the source code for shared libraries and dynamically +linked subprograms that the work is specifically designed to require, +such as by intimate data communication or control flow between those +subprograms and other parts of the work. + + The Corresponding Source need not include anything that users +can regenerate automatically from other parts of the Corresponding +Source. + + The Corresponding Source for a work in source code form is that +same work. + + 2. Basic Permissions. + + All rights granted under this License are granted for the term of +copyright on the Program, and are irrevocable provided the stated +conditions are met. This License explicitly affirms your unlimited +permission to run the unmodified Program. The output from running a +covered work is covered by this License only if the output, given its +content, constitutes a covered work. This License acknowledges your +rights of fair use or other equivalent, as provided by copyright law. + + You may make, run and propagate covered works that you do not +convey, without conditions so long as your license otherwise remains +in force. You may convey covered works to others for the sole purpose +of having them make modifications exclusively for you, or provide you +with facilities for running those works, provided that you comply with +the terms of this License in conveying all material for which you do +not control copyright. Those thus making or running the covered works +for you must do so exclusively on your behalf, under your direction +and control, on terms that prohibit them from making any copies of +your copyrighted material outside their relationship with you. + + Conveying under any other circumstances is permitted solely under +the conditions stated below. Sublicensing is not allowed; section 10 +makes it unnecessary. + + 3. Protecting Users' Legal Rights From Anti-Circumvention Law. + + No covered work shall be deemed part of an effective technological +measure under any applicable law fulfilling obligations under article +11 of the WIPO copyright treaty adopted on 20 December 1996, or +similar laws prohibiting or restricting circumvention of such +measures. + + When you convey a covered work, you waive any legal power to forbid +circumvention of technological measures to the extent such circumvention +is effected by exercising rights under this License with respect to +the covered work, and you disclaim any intention to limit operation or +modification of the work as a means of enforcing, against the work's +users, your or third parties' legal rights to forbid circumvention of +technological measures. + + 4. Conveying Verbatim Copies. + + You may convey verbatim copies of the Program's source code as you +receive it, in any medium, provided that you conspicuously and +appropriately publish on each copy an appropriate copyright notice; +keep intact all notices stating that this License and any +non-permissive terms added in accord with section 7 apply to the code; +keep intact all notices of the absence of any warranty; and give all +recipients a copy of this License along with the Program. + + You may charge any price or no price for each copy that you convey, +and you may offer support or warranty protection for a fee. + + 5. Conveying Modified Source Versions. + + You may convey a work based on the Program, or the modifications to +produce it from the Program, in the form of source code under the +terms of section 4, provided that you also meet all of these conditions: + + a) The work must carry prominent notices stating that you modified + it, and giving a relevant date. + + b) The work must carry prominent notices stating that it is + released under this License and any conditions added under section + 7. This requirement modifies the requirement in section 4 to + "keep intact all notices". + + c) You must license the entire work, as a whole, under this + License to anyone who comes into possession of a copy. This + License will therefore apply, along with any applicable section 7 + additional terms, to the whole of the work, and all its parts, + regardless of how they are packaged. This License gives no + permission to license the work in any other way, but it does not + invalidate such permission if you have separately received it. + + d) If the work has interactive user interfaces, each must display + Appropriate Legal Notices; however, if the Program has interactive + interfaces that do not display Appropriate Legal Notices, your + work need not make them do so. + + A compilation of a covered work with other separate and independent +works, which are not by their nature extensions of the covered work, +and which are not combined with it such as to form a larger program, +in or on a volume of a storage or distribution medium, is called an +"aggregate" if the compilation and its resulting copyright are not +used to limit the access or legal rights of the compilation's users +beyond what the individual works permit. Inclusion of a covered work +in an aggregate does not cause this License to apply to the other +parts of the aggregate. + + 6. Conveying Non-Source Forms. + + You may convey a covered work in object code form under the terms +of sections 4 and 5, provided that you also convey the +machine-readable Corresponding Source under the terms of this License, +in one of these ways: + + a) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by the + Corresponding Source fixed on a durable physical medium + customarily used for software interchange. + + b) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by a + written offer, valid for at least three years and valid for as + long as you offer spare parts or customer support for that product + model, to give anyone who possesses the object code either (1) a + copy of the Corresponding Source for all the software in the + product that is covered by this License, on a durable physical + medium customarily used for software interchange, for a price no + more than your reasonable cost of physically performing this + conveying of source, or (2) access to copy the + Corresponding Source from a network server at no charge. + + c) Convey individual copies of the object code with a copy of the + written offer to provide the Corresponding Source. This + alternative is allowed only occasionally and noncommercially, and + only if you received the object code with such an offer, in accord + with subsection 6b. + + d) Convey the object code by offering access from a designated + place (gratis or for a charge), and offer equivalent access to the + Corresponding Source in the same way through the same place at no + further charge. You need not require recipients to copy the + Corresponding Source along with the object code. If the place to + copy the object code is a network server, the Corresponding Source + may be on a different server (operated by you or a third party) + that supports equivalent copying facilities, provided you maintain + clear directions next to the object code saying where to find the + Corresponding Source. Regardless of what server hosts the + Corresponding Source, you remain obligated to ensure that it is + available for as long as needed to satisfy these requirements. + + e) Convey the object code using peer-to-peer transmission, provided + you inform other peers where the object code and Corresponding + Source of the work are being offered to the general public at no + charge under subsection 6d. + + A separable portion of the object code, whose source code is excluded +from the Corresponding Source as a System Library, need not be +included in conveying the object code work. + + A "User Product" is either (1) a "consumer product", which means any +tangible personal property which is normally used for personal, family, +or household purposes, or (2) anything designed or sold for incorporation +into a dwelling. In determining whether a product is a consumer product, +doubtful cases shall be resolved in favor of coverage. For a particular +product received by a particular user, "normally used" refers to a +typical or common use of that class of product, regardless of the status +of the particular user or of the way in which the particular user +actually uses, or expects or is expected to use, the product. A product +is a consumer product regardless of whether the product has substantial +commercial, industrial or non-consumer uses, unless such uses represent +the only significant mode of use of the product. + + "Installation Information" for a User Product means any methods, +procedures, authorization keys, or other information required to install +and execute modified versions of a covered work in that User Product from +a modified version of its Corresponding Source. The information must +suffice to ensure that the continued functioning of the modified object +code is in no case prevented or interfered with solely because +modification has been made. + + If you convey an object code work under this section in, or with, or +specifically for use in, a User Product, and the conveying occurs as +part of a transaction in which the right of possession and use of the +User Product is transferred to the recipient in perpetuity or for a +fixed term (regardless of how the transaction is characterized), the +Corresponding Source conveyed under this section must be accompanied +by the Installation Information. But this requirement does not apply +if neither you nor any third party retains the ability to install +modified object code on the User Product (for example, the work has +been installed in ROM). + + The requirement to provide Installation Information does not include a +requirement to continue to provide support service, warranty, or updates +for a work that has been modified or installed by the recipient, or for +the User Product in which it has been modified or installed. Access to a +network may be denied when the modification itself materially and +adversely affects the operation of the network or violates the rules and +protocols for communication across the network. + + Corresponding Source conveyed, and Installation Information provided, +in accord with this section must be in a format that is publicly +documented (and with an implementation available to the public in +source code form), and must require no special password or key for +unpacking, reading or copying. + + 7. Additional Terms. + + "Additional permissions" are terms that supplement the terms of this +License by making exceptions from one or more of its conditions. +Additional permissions that are applicable to the entire Program shall +be treated as though they were included in this License, to the extent +that they are valid under applicable law. If additional permissions +apply only to part of the Program, that part may be used separately +under those permissions, but the entire Program remains governed by +this License without regard to the additional permissions. + + When you convey a copy of a covered work, you may at your option +remove any additional permissions from that copy, or from any part of +it. (Additional permissions may be written to require their own +removal in certain cases when you modify the work.) You may place +additional permissions on material, added by you to a covered work, +for which you have or can give appropriate copyright permission. + + Notwithstanding any other provision of this License, for material you +add to a covered work, you may (if authorized by the copyright holders of +that material) supplement the terms of this License with terms: + + a) Disclaiming warranty or limiting liability differently from the + terms of sections 15 and 16 of this License; or + + b) Requiring preservation of specified reasonable legal notices or + author attributions in that material or in the Appropriate Legal + Notices displayed by works containing it; or + + c) Prohibiting misrepresentation of the origin of that material, or + requiring that modified versions of such material be marked in + reasonable ways as different from the original version; or + + d) Limiting the use for publicity purposes of names of licensors or + authors of the material; or + + e) Declining to grant rights under trademark law for use of some + trade names, trademarks, or service marks; or + + f) Requiring indemnification of licensors and authors of that + material by anyone who conveys the material (or modified versions of + it) with contractual assumptions of liability to the recipient, for + any liability that these contractual assumptions directly impose on + those licensors and authors. + + All other non-permissive additional terms are considered "further +restrictions" within the meaning of section 10. If the Program as you +received it, or any part of it, contains a notice stating that it is +governed by this License along with a term that is a further +restriction, you may remove that term. If a license document contains +a further restriction but permits relicensing or conveying under this +License, you may add to a covered work material governed by the terms +of that license document, provided that the further restriction does +not survive such relicensing or conveying. + + If you add terms to a covered work in accord with this section, you +must place, in the relevant source files, a statement of the +additional terms that apply to those files, or a notice indicating +where to find the applicable terms. + + Additional terms, permissive or non-permissive, may be stated in the +form of a separately written license, or stated as exceptions; +the above requirements apply either way. + + 8. Termination. + + You may not propagate or modify a covered work except as expressly +provided under this License. Any attempt otherwise to propagate or +modify it is void, and will automatically terminate your rights under +this License (including any patent licenses granted under the third +paragraph of section 11). + + However, if you cease all violation of this License, then your +license from a particular copyright holder is reinstated (a) +provisionally, unless and until the copyright holder explicitly and +finally terminates your license, and (b) permanently, if the copyright +holder fails to notify you of the violation by some reasonable means +prior to 60 days after the cessation. + + Moreover, your license from a particular copyright holder is +reinstated permanently if the copyright holder notifies you of the +violation by some reasonable means, this is the first time you have +received notice of violation of this License (for any work) from that +copyright holder, and you cure the violation prior to 30 days after +your receipt of the notice. + + Termination of your rights under this section does not terminate the +licenses of parties who have received copies or rights from you under +this License. If your rights have been terminated and not permanently +reinstated, you do not qualify to receive new licenses for the same +material under section 10. + + 9. Acceptance Not Required for Having Copies. + + You are not required to accept this License in order to receive or +run a copy of the Program. Ancillary propagation of a covered work +occurring solely as a consequence of using peer-to-peer transmission +to receive a copy likewise does not require acceptance. However, +nothing other than this License grants you permission to propagate or +modify any covered work. These actions infringe copyright if you do +not accept this License. Therefore, by modifying or propagating a +covered work, you indicate your acceptance of this License to do so. + + 10. Automatic Licensing of Downstream Recipients. + + Each time you convey a covered work, the recipient automatically +receives a license from the original licensors, to run, modify and +propagate that work, subject to this License. You are not responsible +for enforcing compliance by third parties with this License. + + An "entity transaction" is a transaction transferring control of an +organization, or substantially all assets of one, or subdividing an +organization, or merging organizations. If propagation of a covered +work results from an entity transaction, each party to that +transaction who receives a copy of the work also receives whatever +licenses to the work the party's predecessor in interest had or could +give under the previous paragraph, plus a right to possession of the +Corresponding Source of the work from the predecessor in interest, if +the predecessor has it or can get it with reasonable efforts. + + You may not impose any further restrictions on the exercise of the +rights granted or affirmed under this License. For example, you may +not impose a license fee, royalty, or other charge for exercise of +rights granted under this License, and you may not initiate litigation +(including a cross-claim or counterclaim in a lawsuit) alleging that +any patent claim is infringed by making, using, selling, offering for +sale, or importing the Program or any portion of it. + + 11. Patents. + + A "contributor" is a copyright holder who authorizes use under this +License of the Program or a work on which the Program is based. The +work thus licensed is called the contributor's "contributor version". + + A contributor's "essential patent claims" are all patent claims +owned or controlled by the contributor, whether already acquired or +hereafter acquired, that would be infringed by some manner, permitted +by this License, of making, using, or selling its contributor version, +but do not include claims that would be infringed only as a +consequence of further modification of the contributor version. For +purposes of this definition, "control" includes the right to grant +patent sublicenses in a manner consistent with the requirements of +this License. + + Each contributor grants you a non-exclusive, worldwide, royalty-free +patent license under the contributor's essential patent claims, to +make, use, sell, offer for sale, import and otherwise run, modify and +propagate the contents of its contributor version. + + In the following three paragraphs, a "patent license" is any express +agreement or commitment, however denominated, not to enforce a patent +(such as an express permission to practice a patent or covenant not to +sue for patent infringement). To "grant" such a patent license to a +party means to make such an agreement or commitment not to enforce a +patent against the party. + + If you convey a covered work, knowingly relying on a patent license, +and the Corresponding Source of the work is not available for anyone +to copy, free of charge and under the terms of this License, through a +publicly available network server or other readily accessible means, +then you must either (1) cause the Corresponding Source to be so +available, or (2) arrange to deprive yourself of the benefit of the +patent license for this particular work, or (3) arrange, in a manner +consistent with the requirements of this License, to extend the patent +license to downstream recipients. "Knowingly relying" means you have +actual knowledge that, but for the patent license, your conveying the +covered work in a country, or your recipient's use of the covered work +in a country, would infringe one or more identifiable patents in that +country that you have reason to believe are valid. + + If, pursuant to or in connection with a single transaction or +arrangement, you convey, or propagate by procuring conveyance of, a +covered work, and grant a patent license to some of the parties +receiving the covered work authorizing them to use, propagate, modify +or convey a specific copy of the covered work, then the patent license +you grant is automatically extended to all recipients of the covered +work and works based on it. + + A patent license is "discriminatory" if it does not include within +the scope of its coverage, prohibits the exercise of, or is +conditioned on the non-exercise of one or more of the rights that are +specifically granted under this License. You may not convey a covered +work if you are a party to an arrangement with a third party that is +in the business of distributing software, under which you make payment +to the third party based on the extent of your activity of conveying +the work, and under which the third party grants, to any of the +parties who would receive the covered work from you, a discriminatory +patent license (a) in connection with copies of the covered work +conveyed by you (or copies made from those copies), or (b) primarily +for and in connection with specific products or compilations that +contain the covered work, unless you entered into that arrangement, +or that patent license was granted, prior to 28 March 2007. + + Nothing in this License shall be construed as excluding or limiting +any implied license or other defenses to infringement that may +otherwise be available to you under applicable patent law. + + 12. No Surrender of Others' Freedom. + + If conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot convey a +covered work so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you may +not convey it at all. For example, if you agree to terms that obligate you +to collect a royalty for further conveying from those to whom you convey +the Program, the only way you could satisfy both those terms and this +License would be to refrain entirely from conveying the Program. + + 13. Remote Network Interaction; Use with the GNU General Public License. + + Notwithstanding any other provision of this License, if you modify the +Program, your modified version must prominently offer all users +interacting with it remotely through a computer network (if your version +supports such interaction) an opportunity to receive the Corresponding +Source of your version by providing access to the Corresponding Source +from a network server at no charge, through some standard or customary +means of facilitating copying of software. This Corresponding Source +shall include the Corresponding Source for any work covered by version 3 +of the GNU General Public License that is incorporated pursuant to the +following paragraph. + + Notwithstanding any other provision of this License, you have +permission to link or combine any covered work with a work licensed +under version 3 of the GNU General Public License into a single +combined work, and to convey the resulting work. The terms of this +License will continue to apply to the part which is the covered work, +but the work with which it is combined will remain governed by version +3 of the GNU General Public License. + + 14. Revised Versions of this License. + + The Free Software Foundation may publish revised and/or new versions of +the GNU Affero General Public License from time to time. Such new versions +will be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + + Each version is given a distinguishing version number. If the +Program specifies that a certain numbered version of the GNU Affero General +Public License "or any later version" applies to it, you have the +option of following the terms and conditions either of that numbered +version or of any later version published by the Free Software +Foundation. If the Program does not specify a version number of the +GNU Affero General Public License, you may choose any version ever published +by the Free Software Foundation. + + If the Program specifies that a proxy can decide which future +versions of the GNU Affero General Public License can be used, that proxy's +public statement of acceptance of a version permanently authorizes you +to choose that version for the Program. + + Later license versions may give you additional or different +permissions. However, no additional obligations are imposed on any +author or copyright holder as a result of your choosing to follow a +later version. + + 15. Disclaimer of Warranty. + + THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY +APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT +HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY +OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, +THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM +IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF +ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + + 16. Limitation of Liability. + + IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS +THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY +GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE +USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF +DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD +PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), +EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF +SUCH DAMAGES. + + 17. Interpretation of Sections 15 and 16. + + If the disclaimer of warranty and limitation of liability provided +above cannot be given local legal effect according to their terms, +reviewing courts shall apply local law that most closely approximates +an absolute waiver of all civil liability in connection with the +Program, unless a warranty or assumption of liability accompanies a +copy of the Program in return for a fee. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Programs + + If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these terms. + + To do so, attach the following notices to the program. It is safest +to attach them to the start of each source file to most effectively +state the exclusion of warranty; and each file should have at least +the "copyright" line and a pointer to where the full notice is found. + + + Copyright (C) + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero 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 Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see . + +Also add information on how to contact you by electronic and paper mail. + + If your software can interact with users remotely through a computer +network, you should also make sure that it provides a way for users to +get its source. For example, if your program is a web application, its +interface could display a "Source" link that leads users to an archive +of the code. There are many ways you could offer source, and different +solutions will be better for different programs; see section 13 for the +specific requirements. + + You should also get your employer (if you work as a programmer) or school, +if any, to sign a "copyright disclaimer" for the program, if necessary. +For more information on this, and how to apply and follow the GNU AGPL, see +. diff --git a/README.md b/README.md new file mode 100644 index 0000000..91410df --- /dev/null +++ b/README.md @@ -0,0 +1,96 @@ +# TenebraWeb v2 [![Donate on PayPal](https://img.shields.io/badge/PayPal-donate-0079C1?logo=paypal&style=flat-square)](https://paypal.me/lemmmy) ![GitHub Workflow Status](https://img.shields.io/github/workflow/status/tmpim/TenebraWeb2/Deploy%20dev%20build?label=dev%20deploy&style=flat-square) ![Codacy grade](https://img.shields.io/codacy/grade/8b0ee8f672554cf39324d31f559ce086?style=flat-square) ![Lines of code](https://img.shields.io/tokei/lines/github/tmpim/TenebraWeb2?style=flat-square) ![GitHub issues](https://img.shields.io/github/issues/tmpim/TenebraWeb2?style=flat-square) ![GitHub pull requests](https://img.shields.io/github/issues-pr/tmpim/TenebraWeb2?style=flat-square) ![GitHub package.json version](https://img.shields.io/github/package-json/v/tmpim/TenebraWeb2?style=flat-square) ![GitHub](https://img.shields.io/github/license/tmpim/TenebraWeb2?style=flat-square) + + + + + +
+ +

STILL IN DEVELOPMENT

+ +*This project is heavily under development. It is currently in the design +stages, meaning there is **no useful functionality yet***. + +Rewrite of the Tenebra Web Wallet, in React. This is a fully clientside Tenebra +wallet that only needs to communicate to the Tenebra node itself. It securely +saves wallets encrypted in your browser's Local Storage, so you don't have to +type in wallet passwords ever again! + +### Building (for development) + +```sh +git clone https://github.com/tmpim/TenebraWeb2 +cd TenebraWeb2 + +yarn install +npm start # Run the development server +``` + +### Building (for production) + +```sh +git clone https://github.com/tmpim/TenebraWeb2 +cd TenebraWeb2 +yarn install +yarn run full-build # Build the production files +``` + +### Contributing + +As per tmpim convention, this project uses +[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), with support +for [JSON5 syntax](https://spec.json5.org/). You can find existing translations +in [`public/locales`](public/locales). The +[English (GB) translation](public/locales/en.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. + +**IMPORTANT:** If you are adding a new language, you **must** add a listing for +the language with the English name, native name, a country code (for the flag) +and the contributors list to +[`src/__data__/languages.json`](src/__data__/languages.json). It's not terribly +important, but the list should be kept in alphabetical order **by language +code**. + +The keys `antLocale`, `dayjsLocale` and `timeagoLocale` are all optional keys +that refer to the locale names from the respective libraries. If the library +does not support your language, create an issue on this repo or mention it in +your PR, because I can add a simple system to support custom translations for +these libraries if it is needed, though you should also PR to the libraries +themselves. + +List of supported locale codes for each library: + +- `ant-design` - https://ant.design/docs/react/i18n +- `dayjs` - https://github.com/iamkun/dayjs/tree/dev/src/locale +- `react-timeago` - https://github.com/nmn/react-timeago/tree/master/src/language-strings + +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. + +### Donate + +If you like my work, and want to help me with this hobby project and many more +in the future, please consider [donating](https://donate.lemmmy.pw). + +### License + +**Copyright (c) 2020-2021 Drew Lemmy** + +This project is licensed under the AGPL v3 license. See LICENSE.txt for more. diff --git a/badgit/HEAD b/badgit/HEAD new file mode 100644 index 0000000..cb089cd --- /dev/null +++ b/badgit/HEAD @@ -0,0 +1 @@ +ref: refs/heads/master diff --git a/badgit/config b/badgit/config new file mode 100644 index 0000000..884fa7b --- /dev/null +++ b/badgit/config @@ -0,0 +1,11 @@ +[core] + repositoryformatversion = 0 + filemode = true + bare = false + logallrefupdates = true +[remote "origin"] + url = https://github.com/tmpim/TenebraWeb2 + fetch = +refs/heads/*:refs/remotes/origin/* +[branch "master"] + remote = origin + merge = refs/heads/master diff --git a/badgit/description b/badgit/description new file mode 100644 index 0000000..498b267 --- /dev/null +++ b/badgit/description @@ -0,0 +1 @@ +Unnamed repository; edit this file 'description' to name the repository. diff --git a/badgit/hooks/applypatch-msg.sample b/badgit/hooks/applypatch-msg.sample new file mode 100755 index 0000000..a5d7b84 --- /dev/null +++ b/badgit/hooks/applypatch-msg.sample @@ -0,0 +1,15 @@ +#!/bin/sh +# +# An example hook script to check the commit log message taken by +# applypatch from an e-mail message. +# +# The hook should exit with non-zero status after issuing an +# appropriate message if it wants to stop the commit. The hook is +# allowed to edit the commit message file. +# +# To enable this hook, rename this file to "applypatch-msg". + +. git-sh-setup +commitmsg="$(git rev-parse --git-path hooks/commit-msg)" +test -x "$commitmsg" && exec "$commitmsg" ${1+"$@"} +: diff --git a/badgit/hooks/commit-msg.sample b/badgit/hooks/commit-msg.sample new file mode 100755 index 0000000..b58d118 --- /dev/null +++ b/badgit/hooks/commit-msg.sample @@ -0,0 +1,24 @@ +#!/bin/sh +# +# An example hook script to check the commit log message. +# Called by "git commit" with one argument, the name of the file +# that has the commit message. The hook should exit with non-zero +# status after issuing an appropriate message if it wants to stop the +# commit. The hook is allowed to edit the commit message file. +# +# To enable this hook, rename this file to "commit-msg". + +# Uncomment the below to add a Signed-off-by line to the message. +# Doing this in a hook is a bad idea in general, but the prepare-commit-msg +# hook is more suited to it. +# +# SOB=$(git var GIT_AUTHOR_IDENT | sed -n 's/^\(.*>\).*$/Signed-off-by: \1/p') +# grep -qs "^$SOB" "$1" || echo "$SOB" >> "$1" + +# This example catches duplicate Signed-off-by lines. + +test "" = "$(grep '^Signed-off-by: ' "$1" | + sort | uniq -c | sed -e '/^[ ]*1[ ]/d')" || { + echo >&2 Duplicate Signed-off-by lines. + exit 1 +} diff --git a/badgit/hooks/fsmonitor-watchman.sample b/badgit/hooks/fsmonitor-watchman.sample new file mode 100755 index 0000000..e673bb3 --- /dev/null +++ b/badgit/hooks/fsmonitor-watchman.sample @@ -0,0 +1,114 @@ +#!/usr/bin/perl + +use strict; +use warnings; +use IPC::Open2; + +# An example hook script to integrate Watchman +# (https://facebook.github.io/watchman/) with git to speed up detecting +# new and modified files. +# +# The hook is passed a version (currently 1) and a time in nanoseconds +# formatted as a string and outputs to stdout all files that have been +# modified since the given time. Paths must be relative to the root of +# the working tree and separated by a single NUL. +# +# To enable this hook, rename this file to "query-watchman" and set +# 'git config core.fsmonitor .git/hooks/query-watchman' +# +my ($version, $time) = @ARGV; + +# Check the hook interface version + +if ($version == 1) { + # convert nanoseconds to seconds + $time = int $time / 1000000000; +} else { + die "Unsupported query-fsmonitor hook version '$version'.\n" . + "Falling back to scanning...\n"; +} + +my $git_work_tree; +if ($^O =~ 'msys' || $^O =~ 'cygwin') { + $git_work_tree = Win32::GetCwd(); + $git_work_tree =~ tr/\\/\//; +} else { + require Cwd; + $git_work_tree = Cwd::cwd(); +} + +my $retry = 1; + +launch_watchman(); + +sub launch_watchman { + + my $pid = open2(\*CHLD_OUT, \*CHLD_IN, 'watchman -j --no-pretty') + or die "open2() failed: $!\n" . + "Falling back to scanning...\n"; + + # In the query expression below we're asking for names of files that + # changed since $time but were not transient (ie created after + # $time but no longer exist). + # + # To accomplish this, we're using the "since" generator to use the + # recency index to select candidate nodes and "fields" to limit the + # output to file names only. Then we're using the "expression" term to + # further constrain the results. + # + # The category of transient files that we want to ignore will have a + # creation clock (cclock) newer than $time_t value and will also not + # currently exist. + + my $query = <<" END"; + ["query", "$git_work_tree", { + "since": $time, + "fields": ["name"], + "expression": ["not", ["allof", ["since", $time, "cclock"], ["not", "exists"]]] + }] + END + + print CHLD_IN $query; + close CHLD_IN; + my $response = do {local $/; }; + + die "Watchman: command returned no output.\n" . + "Falling back to scanning...\n" if $response eq ""; + die "Watchman: command returned invalid output: $response\n" . + "Falling back to scanning...\n" unless $response =~ /^\{/; + + my $json_pkg; + eval { + require JSON::XS; + $json_pkg = "JSON::XS"; + 1; + } or do { + require JSON::PP; + $json_pkg = "JSON::PP"; + }; + + my $o = $json_pkg->new->utf8->decode($response); + + if ($retry > 0 and $o->{error} and $o->{error} =~ m/unable to resolve root .* directory (.*) is not watched/) { + print STDERR "Adding '$git_work_tree' to watchman's watch list.\n"; + $retry--; + qx/watchman watch "$git_work_tree"/; + die "Failed to make watchman watch '$git_work_tree'.\n" . + "Falling back to scanning...\n" if $? != 0; + + # Watchman will always return all files on the first query so + # return the fast "everything is dirty" flag to git and do the + # Watchman query just to get it over with now so we won't pay + # the cost in git to look up each individual file. + print "/\0"; + eval { launch_watchman() }; + exit 0; + } + + die "Watchman: $o->{error}.\n" . + "Falling back to scanning...\n" if $o->{error}; + + binmode STDOUT, ":utf8"; + local $, = "\0"; + print @{$o->{files}}; +} diff --git a/badgit/hooks/post-update.sample b/badgit/hooks/post-update.sample new file mode 100755 index 0000000..ec17ec1 --- /dev/null +++ b/badgit/hooks/post-update.sample @@ -0,0 +1,8 @@ +#!/bin/sh +# +# An example hook script to prepare a packed repository for use over +# dumb transports. +# +# To enable this hook, rename this file to "post-update". + +exec git update-server-info diff --git a/badgit/hooks/pre-applypatch.sample b/badgit/hooks/pre-applypatch.sample new file mode 100755 index 0000000..4142082 --- /dev/null +++ b/badgit/hooks/pre-applypatch.sample @@ -0,0 +1,14 @@ +#!/bin/sh +# +# An example hook script to verify what is about to be committed +# by applypatch from an e-mail message. +# +# The hook should exit with non-zero status after issuing an +# appropriate message if it wants to stop the commit. +# +# To enable this hook, rename this file to "pre-applypatch". + +. git-sh-setup +precommit="$(git rev-parse --git-path hooks/pre-commit)" +test -x "$precommit" && exec "$precommit" ${1+"$@"} +: diff --git a/badgit/hooks/pre-commit.sample b/badgit/hooks/pre-commit.sample new file mode 100755 index 0000000..68d62d5 --- /dev/null +++ b/badgit/hooks/pre-commit.sample @@ -0,0 +1,49 @@ +#!/bin/sh +# +# An example hook script to verify what is about to be committed. +# Called by "git commit" with no arguments. The hook should +# exit with non-zero status after issuing an appropriate message if +# it wants to stop the commit. +# +# To enable this hook, rename this file to "pre-commit". + +if git rev-parse --verify HEAD >/dev/null 2>&1 +then + against=HEAD +else + # Initial commit: diff against an empty tree object + against=4b825dc642cb6eb9a060e54bf8d69288fbee4904 +fi + +# If you want to allow non-ASCII filenames set this variable to true. +allownonascii=$(git config --bool hooks.allownonascii) + +# Redirect output to stderr. +exec 1>&2 + +# Cross platform projects tend to avoid non-ASCII filenames; prevent +# them from being added to the repository. We exploit the fact that the +# printable range starts at the space character and ends with tilde. +if [ "$allownonascii" != "true" ] && + # Note that the use of brackets around a tr range is ok here, (it's + # even required, for portability to Solaris 10's /usr/bin/tr), since + # the square bracket bytes happen to fall in the designated range. + test $(git diff --cached --name-only --diff-filter=A -z $against | + LC_ALL=C tr -d '[ -~]\0' | wc -c) != 0 +then + cat <<\EOF +Error: Attempt to add a non-ASCII file name. + +This can cause problems if you want to work with people on other platforms. + +To be portable it is advisable to rename the file. + +If you know what you are doing you can disable this check using: + + git config hooks.allownonascii true +EOF + exit 1 +fi + +# If there are whitespace errors, print the offending file names and fail. +exec git diff-index --check --cached $against -- diff --git a/badgit/hooks/pre-push.sample b/badgit/hooks/pre-push.sample new file mode 100755 index 0000000..6187dbf --- /dev/null +++ b/badgit/hooks/pre-push.sample @@ -0,0 +1,53 @@ +#!/bin/sh + +# An example hook script to verify what is about to be pushed. Called by "git +# push" after it has checked the remote status, but before anything has been +# pushed. If this script exits with a non-zero status nothing will be pushed. +# +# This hook is called with the following parameters: +# +# $1 -- Name of the remote to which the push is being done +# $2 -- URL to which the push is being done +# +# If pushing without using a named remote those arguments will be equal. +# +# Information about the commits which are being pushed is supplied as lines to +# the standard input in the form: +# +# +# +# This sample shows how to prevent push of commits where the log message starts +# with "WIP" (work in progress). + +remote="$1" +url="$2" + +z40=0000000000000000000000000000000000000000 + +while read local_ref local_sha remote_ref remote_sha +do + if [ "$local_sha" = $z40 ] + then + # Handle delete + : + else + if [ "$remote_sha" = $z40 ] + then + # New branch, examine all commits + range="$local_sha" + else + # Update to existing branch, examine new commits + range="$remote_sha..$local_sha" + fi + + # Check for WIP commit + commit=`git rev-list -n 1 --grep '^WIP' "$range"` + if [ -n "$commit" ] + then + echo >&2 "Found WIP commit in $local_ref, not pushing" + exit 1 + fi + fi +done + +exit 0 diff --git a/badgit/hooks/pre-rebase.sample b/badgit/hooks/pre-rebase.sample new file mode 100755 index 0000000..6cbef5c --- /dev/null +++ b/badgit/hooks/pre-rebase.sample @@ -0,0 +1,169 @@ +#!/bin/sh +# +# Copyright (c) 2006, 2008 Junio C Hamano +# +# The "pre-rebase" hook is run just before "git rebase" starts doing +# its job, and can prevent the command from running by exiting with +# non-zero status. +# +# The hook is called with the following parameters: +# +# $1 -- the upstream the series was forked from. +# $2 -- the branch being rebased (or empty when rebasing the current branch). +# +# This sample shows how to prevent topic branches that are already +# merged to 'next' branch from getting rebased, because allowing it +# would result in rebasing already published history. + +publish=next +basebranch="$1" +if test "$#" = 2 +then + topic="refs/heads/$2" +else + topic=`git symbolic-ref HEAD` || + exit 0 ;# we do not interrupt rebasing detached HEAD +fi + +case "$topic" in +refs/heads/??/*) + ;; +*) + exit 0 ;# we do not interrupt others. + ;; +esac + +# Now we are dealing with a topic branch being rebased +# on top of master. Is it OK to rebase it? + +# Does the topic really exist? +git show-ref -q "$topic" || { + echo >&2 "No such branch $topic" + exit 1 +} + +# Is topic fully merged to master? +not_in_master=`git rev-list --pretty=oneline ^master "$topic"` +if test -z "$not_in_master" +then + echo >&2 "$topic is fully merged to master; better remove it." + exit 1 ;# we could allow it, but there is no point. +fi + +# Is topic ever merged to next? If so you should not be rebasing it. +only_next_1=`git rev-list ^master "^$topic" ${publish} | sort` +only_next_2=`git rev-list ^master ${publish} | sort` +if test "$only_next_1" = "$only_next_2" +then + not_in_topic=`git rev-list "^$topic" master` + if test -z "$not_in_topic" + then + echo >&2 "$topic is already up to date with master" + exit 1 ;# we could allow it, but there is no point. + else + exit 0 + fi +else + not_in_next=`git rev-list --pretty=oneline ^${publish} "$topic"` + /usr/bin/perl -e ' + my $topic = $ARGV[0]; + my $msg = "* $topic has commits already merged to public branch:\n"; + my (%not_in_next) = map { + /^([0-9a-f]+) /; + ($1 => 1); + } split(/\n/, $ARGV[1]); + for my $elem (map { + /^([0-9a-f]+) (.*)$/; + [$1 => $2]; + } split(/\n/, $ARGV[2])) { + if (!exists $not_in_next{$elem->[0]}) { + if ($msg) { + print STDERR $msg; + undef $msg; + } + print STDERR " $elem->[1]\n"; + } + } + ' "$topic" "$not_in_next" "$not_in_master" + exit 1 +fi + +<<\DOC_END + +This sample hook safeguards topic branches that have been +published from being rewound. + +The workflow assumed here is: + + * Once a topic branch forks from "master", "master" is never + merged into it again (either directly or indirectly). + + * Once a topic branch is fully cooked and merged into "master", + it is deleted. If you need to build on top of it to correct + earlier mistakes, a new topic branch is created by forking at + the tip of the "master". This is not strictly necessary, but + it makes it easier to keep your history simple. + + * Whenever you need to test or publish your changes to topic + branches, merge them into "next" branch. + +The script, being an example, hardcodes the publish branch name +to be "next", but it is trivial to make it configurable via +$GIT_DIR/config mechanism. + +With this workflow, you would want to know: + +(1) ... if a topic branch has ever been merged to "next". Young + topic branches can have stupid mistakes you would rather + clean up before publishing, and things that have not been + merged into other branches can be easily rebased without + affecting other people. But once it is published, you would + not want to rewind it. + +(2) ... if a topic branch has been fully merged to "master". + Then you can delete it. More importantly, you should not + build on top of it -- other people may already want to + change things related to the topic as patches against your + "master", so if you need further changes, it is better to + fork the topic (perhaps with the same name) afresh from the + tip of "master". + +Let's look at this example: + + o---o---o---o---o---o---o---o---o---o "next" + / / / / + / a---a---b A / / + / / / / + / / c---c---c---c B / + / / / \ / + / / / b---b C \ / + / / / / \ / + ---o---o---o---o---o---o---o---o---o---o---o "master" + + +A, B and C are topic branches. + + * A has one fix since it was merged up to "next". + + * B has finished. It has been fully merged up to "master" and "next", + and is ready to be deleted. + + * C has not merged to "next" at all. + +We would want to allow C to be rebased, refuse A, and encourage +B to be deleted. + +To compute (1): + + git rev-list ^master ^topic next + git rev-list ^master next + + if these match, topic has not merged in next at all. + +To compute (2): + + git rev-list master..topic + + if this is empty, it is fully merged to "master". + +DOC_END diff --git a/badgit/hooks/pre-receive.sample b/badgit/hooks/pre-receive.sample new file mode 100755 index 0000000..a1fd29e --- /dev/null +++ b/badgit/hooks/pre-receive.sample @@ -0,0 +1,24 @@ +#!/bin/sh +# +# An example hook script to make use of push options. +# The example simply echoes all push options that start with 'echoback=' +# and rejects all pushes when the "reject" push option is used. +# +# To enable this hook, rename this file to "pre-receive". + +if test -n "$GIT_PUSH_OPTION_COUNT" +then + i=0 + while test "$i" -lt "$GIT_PUSH_OPTION_COUNT" + do + eval "value=\$GIT_PUSH_OPTION_$i" + case "$value" in + echoback=*) + echo "echo from the pre-receive-hook: ${value#*=}" >&2 + ;; + reject) + exit 1 + esac + i=$((i + 1)) + done +fi diff --git a/badgit/hooks/prepare-commit-msg.sample b/badgit/hooks/prepare-commit-msg.sample new file mode 100755 index 0000000..10fa14c --- /dev/null +++ b/badgit/hooks/prepare-commit-msg.sample @@ -0,0 +1,42 @@ +#!/bin/sh +# +# An example hook script to prepare the commit log message. +# Called by "git commit" with the name of the file that has the +# commit message, followed by the description of the commit +# message's source. The hook's purpose is to edit the commit +# message file. If the hook fails with a non-zero status, +# the commit is aborted. +# +# To enable this hook, rename this file to "prepare-commit-msg". + +# This hook includes three examples. The first one removes the +# "# Please enter the commit message..." help message. +# +# The second includes the output of "git diff --name-status -r" +# into the message, just before the "git status" output. It is +# commented because it doesn't cope with --amend or with squashed +# commits. +# +# The third example adds a Signed-off-by line to the message, that can +# still be edited. This is rarely a good idea. + +COMMIT_MSG_FILE=$1 +COMMIT_SOURCE=$2 +SHA1=$3 + +/usr/bin/perl -i.bak -ne 'print unless(m/^. Please enter the commit message/..m/^#$/)' "$COMMIT_MSG_FILE" + +# case "$COMMIT_SOURCE,$SHA1" in +# ,|template,) +# /usr/bin/perl -i.bak -pe ' +# print "\n" . `git diff --cached --name-status -r` +# if /^#/ && $first++ == 0' "$COMMIT_MSG_FILE" ;; +# *) ;; +# esac + +# SOB=$(git var GIT_COMMITTER_IDENT | sed -n 's/^\(.*>\).*$/Signed-off-by: \1/p') +# git interpret-trailers --in-place --trailer "$SOB" "$COMMIT_MSG_FILE" +# if test -z "$COMMIT_SOURCE" +# then +# /usr/bin/perl -i.bak -pe 'print "\n" if !$first_line++' "$COMMIT_MSG_FILE" +# fi diff --git a/badgit/hooks/update.sample b/badgit/hooks/update.sample new file mode 100755 index 0000000..80ba941 --- /dev/null +++ b/badgit/hooks/update.sample @@ -0,0 +1,128 @@ +#!/bin/sh +# +# An example hook script to block unannotated tags from entering. +# Called by "git receive-pack" with arguments: refname sha1-old sha1-new +# +# To enable this hook, rename this file to "update". +# +# Config +# ------ +# hooks.allowunannotated +# This boolean sets whether unannotated tags will be allowed into the +# repository. By default they won't be. +# hooks.allowdeletetag +# This boolean sets whether deleting tags will be allowed in the +# repository. By default they won't be. +# hooks.allowmodifytag +# This boolean sets whether a tag may be modified after creation. By default +# it won't be. +# hooks.allowdeletebranch +# This boolean sets whether deleting branches will be allowed in the +# repository. By default they won't be. +# hooks.denycreatebranch +# This boolean sets whether remotely creating branches will be denied +# in the repository. By default this is allowed. +# + +# --- Command line +refname="$1" +oldrev="$2" +newrev="$3" + +# --- Safety check +if [ -z "$GIT_DIR" ]; then + echo "Don't run this script from the command line." >&2 + echo " (if you want, you could supply GIT_DIR then run" >&2 + echo " $0 )" >&2 + exit 1 +fi + +if [ -z "$refname" -o -z "$oldrev" -o -z "$newrev" ]; then + echo "usage: $0 " >&2 + exit 1 +fi + +# --- Config +allowunannotated=$(git config --bool hooks.allowunannotated) +allowdeletebranch=$(git config --bool hooks.allowdeletebranch) +denycreatebranch=$(git config --bool hooks.denycreatebranch) +allowdeletetag=$(git config --bool hooks.allowdeletetag) +allowmodifytag=$(git config --bool hooks.allowmodifytag) + +# check for no description +projectdesc=$(sed -e '1q' "$GIT_DIR/description") +case "$projectdesc" in +"Unnamed repository"* | "") + echo "*** Project description file hasn't been set" >&2 + exit 1 + ;; +esac + +# --- Check types +# if $newrev is 0000...0000, it's a commit to delete a ref. +zero="0000000000000000000000000000000000000000" +if [ "$newrev" = "$zero" ]; then + newrev_type=delete +else + newrev_type=$(git cat-file -t $newrev) +fi + +case "$refname","$newrev_type" in + refs/tags/*,commit) + # un-annotated tag + short_refname=${refname##refs/tags/} + if [ "$allowunannotated" != "true" ]; then + echo "*** The un-annotated tag, $short_refname, is not allowed in this repository" >&2 + echo "*** Use 'git tag [ -a | -s ]' for tags you want to propagate." >&2 + exit 1 + fi + ;; + refs/tags/*,delete) + # delete tag + if [ "$allowdeletetag" != "true" ]; then + echo "*** Deleting a tag is not allowed in this repository" >&2 + exit 1 + fi + ;; + refs/tags/*,tag) + # annotated tag + if [ "$allowmodifytag" != "true" ] && git rev-parse $refname > /dev/null 2>&1 + then + echo "*** Tag '$refname' already exists." >&2 + echo "*** Modifying a tag is not allowed in this repository." >&2 + exit 1 + fi + ;; + refs/heads/*,commit) + # branch + if [ "$oldrev" = "$zero" -a "$denycreatebranch" = "true" ]; then + echo "*** Creating a branch is not allowed in this repository" >&2 + exit 1 + fi + ;; + refs/heads/*,delete) + # delete branch + if [ "$allowdeletebranch" != "true" ]; then + echo "*** Deleting a branch is not allowed in this repository" >&2 + exit 1 + fi + ;; + refs/remotes/*,commit) + # tracking branch + ;; + refs/remotes/*,delete) + # delete tracking branch + if [ "$allowdeletebranch" != "true" ]; then + echo "*** Deleting a tracking branch is not allowed in this repository" >&2 + exit 1 + fi + ;; + *) + # Anything else (is there anything else?) + echo "*** Update hook: unknown type of update to ref $refname of type $newrev_type" >&2 + exit 1 + ;; +esac + +# --- Finished +exit 0 diff --git a/badgit/index b/badgit/index new file mode 100644 index 0000000..efe29d5 --- /dev/null +++ b/badgit/index Binary files differ diff --git a/badgit/info/exclude b/badgit/info/exclude new file mode 100644 index 0000000..a5196d1 --- /dev/null +++ b/badgit/info/exclude @@ -0,0 +1,6 @@ +# git ls-files --others --exclude-from=.git/info/exclude +# Lines that start with '#' are comments. +# For a project mostly in C, the following would be a good set of +# exclude patterns (uncomment them if you want to use them): +# *.[oa] +# *~ diff --git a/badgit/logs/HEAD b/badgit/logs/HEAD new file mode 100644 index 0000000..afbfe17 --- /dev/null +++ b/badgit/logs/HEAD @@ -0,0 +1 @@ +0000000000000000000000000000000000000000 2309c4b285adea4135fcbc8078bcb722608e861e BuildTools 1623210404 +0200 clone: from https://github.com/tmpim/TenebraWeb2 diff --git a/badgit/logs/refs/heads/master b/badgit/logs/refs/heads/master new file mode 100644 index 0000000..afbfe17 --- /dev/null +++ b/badgit/logs/refs/heads/master @@ -0,0 +1 @@ +0000000000000000000000000000000000000000 2309c4b285adea4135fcbc8078bcb722608e861e BuildTools 1623210404 +0200 clone: from https://github.com/tmpim/TenebraWeb2 diff --git a/badgit/logs/refs/remotes/origin/HEAD b/badgit/logs/refs/remotes/origin/HEAD new file mode 100644 index 0000000..afbfe17 --- /dev/null +++ b/badgit/logs/refs/remotes/origin/HEAD @@ -0,0 +1 @@ +0000000000000000000000000000000000000000 2309c4b285adea4135fcbc8078bcb722608e861e BuildTools 1623210404 +0200 clone: from https://github.com/tmpim/TenebraWeb2 diff --git a/badgit/objects/pack/pack-9117241d9fd869a5fde3ec03cbf9cd0c078cad9c.idx b/badgit/objects/pack/pack-9117241d9fd869a5fde3ec03cbf9cd0c078cad9c.idx new file mode 100644 index 0000000..1cde9ca --- /dev/null +++ b/badgit/objects/pack/pack-9117241d9fd869a5fde3ec03cbf9cd0c078cad9c.idx Binary files differ diff --git a/badgit/objects/pack/pack-9117241d9fd869a5fde3ec03cbf9cd0c078cad9c.pack b/badgit/objects/pack/pack-9117241d9fd869a5fde3ec03cbf9cd0c078cad9c.pack new file mode 100644 index 0000000..309404a --- /dev/null +++ b/badgit/objects/pack/pack-9117241d9fd869a5fde3ec03cbf9cd0c078cad9c.pack Binary files differ diff --git a/badgit/packed-refs b/badgit/packed-refs new file mode 100644 index 0000000..cd7fd05 --- /dev/null +++ b/badgit/packed-refs @@ -0,0 +1,12 @@ +# pack-refs with: peeled fully-peeled sorted +4cd3dd86aa858be52c7c524dbcdfb5406da35012 refs/remotes/origin/hydro-dev +2309c4b285adea4135fcbc8078bcb722608e861e refs/remotes/origin/master +9fa52312b379e962804fa3d55168dcb60052699e refs/remotes/origin/web-workers +ce87662e0ae8a3bdfa03de4d3d588cbab0fac459 refs/tags/v2.0.0-beta +^ae9db3f8d2b9a79b53ba538d23dd90fc4958640b +68758b2388a863c87846c4ed1924cfde6be9ec21 refs/tags/v2.0.0-dev +^f8cdb094f65b3142a214412b147533444594014e +b90421c3290ce911f2e17a794429ea5f24d9f6ff refs/tags/v2.0.1-beta +^ba4d79855a5588d988b9e8f99769fd0072351d27 +20db2b1450fd06e939e0788dc6a5f99917b8eb6e refs/tags/v2.0.2-beta +^2309c4b285adea4135fcbc8078bcb722608e861e diff --git a/badgit/refs/heads/master b/badgit/refs/heads/master new file mode 100644 index 0000000..7582a13 --- /dev/null +++ b/badgit/refs/heads/master @@ -0,0 +1 @@ +2309c4b285adea4135fcbc8078bcb722608e861e diff --git a/badgit/refs/remotes/origin/HEAD b/badgit/refs/remotes/origin/HEAD new file mode 100644 index 0000000..6efe28f --- /dev/null +++ b/badgit/refs/remotes/origin/HEAD @@ -0,0 +1 @@ +ref: refs/remotes/origin/master diff --git a/craco.config.js b/craco.config.js new file mode 100644 index 0000000..4c79ae1 --- /dev/null +++ b/craco.config.js @@ -0,0 +1,109 @@ +// Copyright (c) 2020-2021 Drew Lemmy +// This file is part of TenebraWeb 2 under GPL-3.0. +// Full details: https://github.com/tmpim/TenebraWeb2/blob/master/LICENSE.txt +const path = require("path"); +const CracoAlias = require("craco-alias"); +const CracoLessPlugin = require("@lemmmy/craco-less"); +const AntdDayjsWebpackPlugin = require("antd-dayjs-webpack-plugin"); +const { BundleAnalyzerPlugin } = require("webpack-bundle-analyzer"); +const WebpackBar = require("webpackbar"); +const GitRevisionPlugin = require("git-revision-webpack-plugin"); +const { DefinePlugin } = require("webpack"); +const { commits } = require("./tools/commitLog"); +const SentryCliPlugin = require("@sentry/webpack-plugin"); + +const gitRevisionPlugin = new GitRevisionPlugin({ + // Include the "-dirty" suffix if the local tree has been modified, and + // include non-annotated tags. + versionCommand: "describe --always --tags --dirty" +}); + +module.exports = { + style: { + css: { + loaderOptions: { + url: false + } + } + }, + + babel: { + plugins: [ + "lodash", + ["@simbathesailor/babel-plugin-use-what-changed", { + "active": process.env.NODE_ENV === "development" + }] + ], + }, + + plugins: [ + { + plugin: CracoAlias, + options: { + source: "tsconfig", + baseUrl: "./src", + tsConfigPath: "./tsconfig.extend.json" + } + }, + { + plugin: CracoLessPlugin, + options: { + cssLoaderOptions: { + url: false + }, + + lessLoaderOptions: { + webpackImporter: false, + implementation: require("less"), + + lessOptions: { + relativeUrls: false, + javascriptEnabled: true, + paths: [path.resolve(__dirname, "node_modules")] + } + } + } + } + ], + + // I use eslint in vscode - to save my CPU I'd rather just rely on using that + // to lint instead of the react-scripts watcher. + // TODO: run this for production builds, and add a separate command for it. + eslint: { + enable: false + }, + + webpack: { + plugins: [ + new WebpackBar({ profile: true }), + ...(process.env.NODE_ENV === "development" || process.env.FORCE_ANALYZE + ? [new BundleAnalyzerPlugin({ openAnalyzer: false })] + : []), + new AntdDayjsWebpackPlugin(), + gitRevisionPlugin, + new DefinePlugin({ + "__GIT_VERSION__": DefinePlugin.runtimeValue(() => JSON.stringify(gitRevisionPlugin.version()), []), + "__GIT_COMMIT_HASH__": DefinePlugin.runtimeValue(() => JSON.stringify(gitRevisionPlugin.commithash()), []), + "__BUILD_TIME__": DefinePlugin.runtimeValue(Date.now), + "__GIT_COMMITS__": JSON.stringify(commits), + "__PKGBUILD__": DefinePlugin.runtimeValue(() => JSON.stringify(require("crypto").createHash("sha256").update(require("fs").readFileSync("package.json")).digest("hex").substr(0, 7)), ["package.json"]) + }), + ...(process.env.NODE_ENV === "production" && process.env.SENTRY_ENABLED === "true" + ? [new SentryCliPlugin({ + include: "./build/", + ignore: ["node_modules", "craco.config.js", "tools", "public"], + release: "tenebraweb2-react@" + gitRevisionPlugin.version() + })] + : []) + ], + + optimization: { + sideEffects: true + }, + + configure: { + devtool: process.env.NODE_ENV === "development" + ? "eval" : "hidden-source-map" + } + }, +}; diff --git a/package.json b/package.json new file mode 100644 index 0000000..d75d2be --- /dev/null +++ b/package.json @@ -0,0 +1,145 @@ +{ + "name": "tenebraweb2", + "version": "2.0.2-beta", + "description": "Client-side web wallet for Tenebra", + "homepage": "https://tenebra.club", + "license": "AGPL-3.0-only", + "repository": { + "type": "git", + "url": "https://github.com/tmpim/TenebraWeb2.git" + }, + "author": "Lemmmy", + "defaultSyncNode": "https://tenebra.lil.gay", + "supportURL": "https://donate.lemmmy.pw", + "supportersURL": "https://donate.lemmmy.pw/supporters.json", + "translateURL": "https://github.com/tmpim/TenebraWeb2/blob/master/README.md#contributing-translations", + "private": true, + "dependencies": { + "@ant-design/icons": "^4.5.0", + "@sentry/react": "^6.2.3", + "@sentry/tracing": "^6.2.3", + "@testing-library/jest-dom": "^5.11.9", + "@testing-library/react": "^11.2.5", + "@testing-library/user-event": "^13.0.7", + "antd": "^4.15.1", + "async-mutex": "^0.3.1", + "await-to-js": "^3.0.0", + "base64-arraybuffer": "^0.2.0", + "broadcastchannel-polyfill": "^1.0.1", + "chart.js": "^2.9.4", + "classnames": "^2.2.6", + "csv-stringify": "^5.6.2", + "dayjs": "^1.10.4", + "debug": "^4.3.1", + "fast-equals": "^2.0.0", + "file-saver": "^2.0.5", + "i18next": "^20.1.0", + "i18next-browser-languagedetector": "^6.1.0", + "i18next-http-backend": "^1.2.0", + "json5": "^2.2.0", + "lodash-es": "^4.17.21", + "lru-cache": "^6.0.0", + "markdown-to-jsx": "^7.1.2", + "rc-menu": "^8.10.6", + "react": "^17.0.1", + "react-chartjs-2": "^2.11.1", + "react-dom": "^17.0.1", + "react-file-drop": "^3.1.2", + "react-hotkeys": "^2.0.0", + "react-i18next": "^11.8.11", + "react-redux": "^7.2.2", + "react-router-dom": "^5.2.0", + "react-router-hash-link": "^2.4.0", + "react-timeago": "^5.2.0", + "react-world-flags": "^1.4.0", + "redux": "^4.0.5", + "semver": "^7.3.4", + "shallowequal": "^1.1.0", + "spu-md5": "0.0.4", + "typesafe-actions": "^5.1.0", + "uuid": "^8.3.2", + "web-vitals": "^1.1.1", + "websocket-as-promised": "^2.0.1", + "workbox-core": "^6.1.1", + "workbox-precaching": "^6.1.1", + "workbox-routing": "^6.1.1", + "workbox-strategies": "^6.1.1" + }, + "scripts": { + "start": "craco start", + "clean": "rimraf build", + "build": "craco build", + "optimise": "gzip -kr build/static", + "full-build": "yarn run clean; NODE_ENV=production yarn run build; yarn run optimise", + "analyze-build": "FORCE_ANALYZE=true yarn run build", + "test": "craco test" + }, + "eslintConfig": { + "extends": "react-app" + }, + "browserslist": { + "production": [ + ">0.2%", + "not dead", + "not op_mini all" + ], + "development": [ + "last 1 chrome version", + "last 1 firefox version", + "last 1 safari version" + ] + }, + "devDependencies": { + "@craco/craco": "^6.1.1", + "@lemmmy/craco-less": "1.18.0", + "@sentry/webpack-plugin": "^1.14.2", + "@simbathesailor/babel-plugin-use-what-changed": "^2.0.3", + "@simbathesailor/use-what-changed": "^1.0.0", + "@types/classnames": "^2.2.11", + "@types/debug": "^4.1.5", + "@types/file-saver": "^2.0.1", + "@types/jest": "^26.0.20", + "@types/lodash-es": "^4.17.4", + "@types/lru-cache": "^5.1.0", + "@types/node": "^14.14.34", + "@types/react": "^17.0.3", + "@types/react-dom": "^17.0.2", + "@types/react-redux": "^7.1.16", + "@types/react-router-dom": "^5.1.7", + "@types/react-router-hash-link": "^1.2.1", + "@types/react-timeago": "^4.1.2", + "@types/semver": "^7.3.4", + "@types/shallowequal": "^1.1.1", + "@types/uuid": "^8.3.0", + "@types/webpack-env": "^1.16.0", + "@typescript-eslint/eslint-plugin": "4.19.0", + "@typescript-eslint/parser": "4.19.0", + "antd-dayjs-webpack-plugin": "^1.0.6", + "babel-plugin-lodash": "^3.3.4", + "chalk": "^4.1.0", + "copy-to-clipboard": "^3.3.1", + "craco-alias": "^2.2.0", + "diff": "^5.0.0", + "eslint": "^7.22.0", + "eslint-plugin-react": "^7.22.0", + "eslint-plugin-react-hooks": "^4.2.0", + "eslint-plugin-tsdoc": "^0.2.11", + "git-revision-webpack-plugin": "^3.0.6", + "gitlog": "^4.0.4", + "less": "4.1.1", + "less-loader": "8.0.0", + "react-refresh": "^0.9.0", + "react-scripts": "^4.0.3", + "redux-devtools-extension": "^2.13.9", + "rimraf": "^3.0.2", + "typescript": "4.2.3", + "utility-types": "^3.10.0", + "webpack-bundle-analyzer": "^4.4.0", + "webpackbar": "^5.0.0-3" + }, + "jest": { + "transformIgnorePatterns": [ + "/node_modules/(?!antd|@ant-design|rc-.+?|@babel/runtime).+(js|jsx)$" + ] + } +} diff --git a/public/favicon.ico b/public/favicon.ico new file mode 100644 index 0000000..ca5a4c0 --- /dev/null +++ b/public/favicon.ico Binary files differ diff --git a/public/img/firefox.svg b/public/img/firefox.svg new file mode 100644 index 0000000..600f53a --- /dev/null +++ b/public/img/firefox.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/public/img/flags_responsive.png b/public/img/flags_responsive.png new file mode 100644 index 0000000..e93d295 --- /dev/null +++ b/public/img/flags_responsive.png Binary files differ diff --git a/public/img/google-chrome.svg b/public/img/google-chrome.svg new file mode 100644 index 0000000..03c0a18 --- /dev/null +++ b/public/img/google-chrome.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/public/img/tmpim.svg b/public/img/tmpim.svg new file mode 100644 index 0000000..b29e949 --- /dev/null +++ b/public/img/tmpim.svg @@ -0,0 +1,66 @@ + + + + + + image/svg+xml + + + + + + + + + + + + + + + + diff --git a/public/index.html b/public/index.html new file mode 100644 index 0000000..4e48a7c --- /dev/null +++ b/public/index.html @@ -0,0 +1,92 @@ + + + + + + + + + + + + TenebraWeb + + + + + + +
+
+
+ Loading TenebraWeb... +
+
+ + + + diff --git a/public/locales/de.json b/public/locales/de.json new file mode 100644 index 0000000..ae753c8 --- /dev/null +++ b/public/locales/de.json @@ -0,0 +1,91 @@ +{ + "app": { + "name": "TenebraWeb" + }, + + "nav": { + "connection": { + "online": "Online", + "offline": "Offline", + "connecting": "Verbindung wird hergestellt" + }, + + "search": "Tenebra-Netzwerk durchsuchen", + + "send": "Überweisen", + "request": "Zahlung anfordern" + }, + + "sidebar": { + "totalBalance": "Gesamtsaldo", + "guestIndicator": "Als Gast angemeldet", + "dashboard": "Dashboard", + "myWallets": "Meine Wallets", + "addressBook": "Adressbuch", + "transactions": "Transaktionen", + "names": "Namen", + "mining": "Mining", + "network": "Netzwerk", + "blocks": "Blöcke", + "statistics": "Statistiken", + "madeBy": "Entwickelt von <1>{{authorName}}", + "hostedBy": "Von <1>{{host}} bereitgestellt", + "github": "GitHub", + "credits": "Danksagung" + }, + + "credits": { + "madeBy": "Entwickelt von <1>{{authorName}}", + "supportersTitle": "Unterstützer", + "supportersDescription": "Dieses Projekt wurde ermöglicht durch die folgenden Personen:", + "supportButton": "TenebraWeb unterstützen", + "translatorsTitle": "Übersetzer", + "translatorsDescription": "Dieses Projekt wurde übersetzt von:", + "translateButton": "TenebraWeb übersetzen" + }, + + "dialog": { + "close": "Schließen" + }, + + "pagination": { + "justPage": "Seite {{page}}", + "pageWithTotal": "Seite {{page}} von {{total}}" + }, + + "loading": "Wird geladen...", + + "masterPassword": { + "dialogTitle": "Master-Passwort", + "passwordPlaceholder": "Master-Passwort", + "browseAsGuest": "Als Gast anmelden", + "createPassword": "Passwort erstellen", + "logIn": "Anmelden", + "forgotPassword": "Passwort vergessen?", + "intro": "Gebe ein Master-Passwort ein um deine Wallets zu verschlüsseln, oder melde dich als Gast an. <1>.", + "dontForgetPassword": "Bewahre dieses Passwort gut auf. Die gespeicherten Wallets können unter keinen Umständen wiederhergestellt werden!", + "loginIntro": "Gebe dein Master-Passwort ein um auf deine Wallets zuzugreifen, oder melde dich als Gast an.", + "learnMore": "mehr erfahren", + "errorPasswordRequired": "Fehlendes Passwort!", + "errorPasswordUnset": "Es wurde kein Master-Passwort gesetzt.", + "errorPasswordIncorrect": "Falsches Passwort.", + "errorUnknown": "Unbekannter Fehler.", + "helpWalletStorageTitle": "Hilfe: Speicherung von Wallets", + "helpWalletStorage": "Wenn du eine neue Wallet zu TenebraWeb hinzufügst, wird dessen Privatschlüssel mit Hilfe deines Master-Passworts verschlüsselt und in dem lokalen Speicher deines Browsers aufbewahrt.\nAlle Wallets sind mit genau diesem Master-Passwort verschlüsselt. Das bedeutet, dass du das Master-Passwort jedes Mal eingeben musst, wenn du TenebraWeb öffnest.\n Deine tatsächliche Tenebra-Wallet wird dabei nicht berührt.\nWenn du dich in TenebraWeb als Gast anmeldest, kannst du keine Wallets hinzufügen oder benutzen, sondern nur das Tenebra-Netzwerk durchsuchen." + }, + + "myWallets": { + "title": "Wallets", + "manageBackups": "Sicherungen verwalten", + "createWallet": "Wallet erstellen", + "addExistingWallet": "Bestehendes Wallet hinzufügen", + "searchPlaceholder": "Wallets durchsuchen...", + "categoryDropdownAll": "Alle Kategorien", + "columnLabel": "Kurzbeschreibung", + "columnAddress": "Adresse", + "columnBalance": "Saldo", + "columnNames": "Namen", + "columnCategory": "Kategorie", + "columnFirstSeen": "Erstmals erschienen" + } +} diff --git a/public/locales/en.json b/public/locales/en.json new file mode 100644 index 0000000..27b65a2 --- /dev/null +++ b/public/locales/en.json @@ -0,0 +1,1239 @@ +{ + "app": { + "name": "TenebraWeb" + }, + + "nav": { + "connection": { + "online": "Online", + "offline": "Offline", + "connecting": "Connecting" + }, + + "search": { + "placeholder": "Search the Tenebra network", + "placeholderShortcut": "Search the Tenebra network ({{shortcut}})", + "placeholderShort": "Search...", + "rateLimitHit": "Please slow down.", + "noResults": "No results.", + + "resultAddress": "Address", + "resultName": "Name", + "resultNameOwner": "Owned by <1 />", + "resultBlockID": "Block ID", + "resultBlockIDMinedBy": "Mined by <1 />", + "resultTransactionID": "Transaction ID", + "resultTransactions": "Transactions", + "resultTransactionsAddress": "Search for transactions involving <1 />", + "resultTransactionsAddressResult": "<0>{{count, number}} transaction involving <2 />", + "resultTransactionsAddressResult_plural": "<0>{{count, number}} transactions involving <2 />", + "resultTransactionsAddressResultEmpty": "No transactions involving <1 />", + "resultTransactionsName": "Search for transactions involving <1 />", + "resultTransactionsNameResult": "<0>{{count, number}} transaction sent to <2 />", + "resultTransactionsNameResult_plural": "<0>{{count, number}} transactions sent to <2 />", + "resultTransactionsNameResultEmpty": "No transactions sent to <1 />", + "resultTransactionsMetadata": "Searching for metadata containing <1 />", + "resultTransactionsMetadataResult": "<0>{{count, number}} transaction with metadata containing <2 />", + "resultTransactionsMetadataResult_plural": "<0>{{count, number}} transactions with metadata containing <2 />", + "resultTransactionsMetadataResultEmpty": "No transactions with metadata containing <1 />" + }, + + "send": "Send", + "sendLong": "Send Tenebra", + "request": "Request", + "requestLong": "Request Tenebra", + "sort": "Sort results", + + "settings": "Settings", + "more": "More" + }, + + "sidebar": { + "totalBalance": "Total Balance", + "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", + "whatsNew": "What's new", + + "updateTitle": "Update available!", + "updateDescription": "A new version of TenebraWeb is available. Please reload.", + "updateReload": "Reload" + }, + + "dialog": { + "close": "Close", + "yes": "Yes", + "no": "No", + "ok": "OK", + "cancel": "Cancel" + }, + + "pagination": { + "justPage": "Page {{page}}", + "pageWithTotal": "Page {{page}} of {{total}}" + }, + + "error": "Error", + "errorBoundary": { + "title": "Critical error", + "description": "A critical error has occurred in TenebraWeb, so this page was terminated. See console for details.", + "sentryNote": "This error was automatically reported." + }, + "errorReported": "An error was automatically reported. See console for details.", + + "loading": "Loading...", + + "copy": "Copy to clipboard", + "copied": "Copied!", + + "pageNotFound": { + "resultTitle": "Page not found", + "nyiTitle": "Not yet implemented", + "nyiSubTitle": "This feature will be coming soon!", + "buttonGoBack": "Go back" + }, + + "contextualAddressUnknown": "Unknown", + "contextualAddressNonExistentTooltip": "This address has not yet been initialised on the Tenebra network.", + + "typeahead": { + "emptyLabel": "No matches found.", + "paginationText": "Display additional results..." + }, + + "masterPassword": { + "dialogTitle": "Master password", + "passwordPlaceholder": "Master password", + "passwordConfirmPlaceholder": "Confirm master password", + "createPassword": "Create password", + "logIn": "Log in", + "forgotPassword": "Forgot password?", + "intro2": "Enter a <1>master password to encrypt your wallet private keys. They will be saved in your browser's local storage, and you will be asked for the master password to decrypt them once per session.", + "learnMore": "learn more", + "errorPasswordRequired": "Password is required.", + "errorPasswordLength": "Must be at least 1 character.", + "errorPasswordUnset": "Master password has not been set up.", + "errorPasswordIncorrect": "Incorrect password.", + "errorPasswordInequal": "Passwords must match.", + "errorStorageCorrupt": "Wallet storage is corrupted.", + "errorNoPassword": "Master password is required.", + "errorUnknown": "Unknown error.", + "helpWalletStorageTitle": "Help: Wallet storage", + "popoverTitle": "Decrypt wallets", + "popoverTitleEncrypt": "Encrypt wallets", + "popoverAuthoriseButton": "Authorise", + "popoverDescription": "Enter your master password to decrypt your wallets.", + "popoverDescriptionEncrypt": "Enter your master password to encrypt and decrypt your wallets.", + "forcedAuthWarning": "You were automatically logged in by an insecure debug setting.", + "earlyAuthError": "The app has not loaded fully yet, please try again.", + + "reset": { + "modalTitle": "Reset master password", + "description": "Are you sure you want to reset your master password? All your wallets will be deleted. Make sure to <3>export a backup first!", + "buttonConfirm": "Reset & delete", + + "modalTitle2": "DELETE ALL WALLETS", + "description2": "Are you REALLY sure you want to DELETE ALL YOUR WALLETS?", + "buttonConfirm2": "Yes, I'm sure [{{n}}]", + "buttonConfirmFinal": "Yes, I'm sure!" + }, + + "change": { + "modalTitle": "Change master password" + } + }, + + "myWallets": { + "title": "Wallets", + "manageBackups": "Manage backups", + "importBackup": "Import wallets", + "exportBackup": "Export wallets", + "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", + "nameCount": "{{count, number}} name", + "nameCount_plural": "{{count, number}} names", + "nameCountEmpty": "No names", + "firstSeen": "First seen {{date}}", + "firstSeenMobile": "First seen: <1 />", + + "walletCount": "{{count, number}} wallet", + "walletCount_plural": "{{count, number}} wallets", + "walletCountEmpty": "No wallets", + + "noWalletsHint": "No wallets yet", + "noWalletsText": "Add or create a wallet by clicking the <1 /> menu in the top right!", + + "actionsViewAddress": "View address", + "actionsEditTooltip": "Edit wallet", + "actionsSendTransaction": "Send Tenebra", + "actionsWalletInfo": "Wallet info", + "actionsDelete": "Delete wallet", + "actionsDeleteConfirm": "Are you sure you want to delete this wallet?", + "actionsDeleteConfirmDescription": "If you haven't backed it up or saved its password, it will be lost forever!", + + "tagDontSave": "Temp", + "tagDontSaveTooltip": "Temporary wallet", + + "info": { + "title": "Wallet info - {{address}}", + + "titleBasicInfo": "Basic info", + "id": "ID", + "label": "Label", + "category": "Category", + "username": "Username", + "password": "Password", + "privatekey": "Private key", + "format": "Format", + + "titleSyncedInfo": "Synced info", + "address": "Address", + "balance": "Balance", + "names": "Name count", + "firstSeen": "First seen", + "existsOnNetwork": "Exists on network", + "lastSynced": "Last synced", + + "titleAdvancedInfo": "Advanced info", + "encPassword": "Encrypted password", + "encPrivatekey": "Encrypted private key", + "saved": "Saved", + + "revealLink": "Reveal", + "hideLink": "Hide", + + "true": "True", + "false": "False" + } + }, + + "addressBook": { + "title": "Address book", + + "contactCount": "{{count, number}} contact", + "contactCount_plural": "{{count, number}} contacts", + "contactCountEmpty": "No contacts", + + "buttonAddContact": "Add contact", + + "columnLabel": "Label", + "columnAddress": "Address", + + "actionsViewAddress": "View address", + "actionsViewName": "View name", + "actionsEditTooltip": "Edit contact", + "actionsSendTransaction": "Send Tenebra", + "actionsDelete": "Delete contact", + "actionsDeleteConfirm": "Are you sure you want to delete this contact?" + }, + + "myTransactions": { + "title": "Transactions", + "searchPlaceholder": "Search transactions...", + "columnFrom": "From", + "columnTo": "To", + "columnValue": "Value", + "columnTime": "Time" + }, + + "addWallet": { + "dialogTitle": "Add wallet", + "dialogTitleCreate": "Create wallet", + "dialogTitleEdit": "Edit wallet", + "dialogOkAdd": "Add", + "dialogOkCreate": "Create", + "dialogOkEdit": "Save", + "dialogAddExisting": "Add existing wallet", + + "walletLabel": "Wallet label", + "walletLabelPlaceholder": "Wallet label (optional)", + "walletLabelMaxLengthError": "No longer than 32 characters", + "walletLabelWhitespaceError": "Must not be only spaces", + + "walletCategory": "Wallet category", + "walletCategoryDropdownNone": "No category", + "walletCategoryDropdownNew": "New", + "walletCategoryDropdownNewPlaceholder": "Category name", + + "walletAddress": "Wallet address", + "walletUsername": "Wallet username", + "walletUsernamePlaceholder": "Wallet username", + "walletPassword": "Wallet password", + "walletPasswordPlaceholder": "Wallet password", + "walletPasswordWarning": "Make sure to save this somewhere <1>secure!", + "walletPasswordRegenerate": "Regenerate", + "walletPrivatekey": "Wallet private key", + "walletPrivatekeyPlaceholder": "Wallet private key", + + "advancedOptions": "Advanced options", + + "walletFormat": "Wallet format", + "walletFormatTenebraWallet": "TenebraWallet, KWallet (recommended)", + "walletFormatTenebraWalletUsernameAppendhashes": "KW-Username (appendhashes)", + "walletFormatTenebraWalletUsername": "KW-Username (pre-appendhashes)", + "walletFormatJwalelset": "jwalelset", + "walletFormatApi": "Raw/API (advanced users)", + + "walletSave": "Save this wallet in TenebraWeb", + + "messageSuccessAdd": "Added wallet successfully!", + "messageSuccessCreate": "Created wallet successfully!", + "messageSuccessEdit": "Saved wallet successfully!", + + "errorPasswordRequired": "Password is required.", + "errorPrivatekeyRequired": "Private key is required.", + "errorUnexpectedTitle": "Unexpected error", + "errorUnexpectedDescription": "There was an error while adding the wallet. See console for details.", + "errorUnexpectedEditDescription": "There was an error while editing the wallet. See console for details.", + "errorDuplicateWalletTitle": "Wallet already exists", + "errorDuplicateWalletDescription": "You already have a wallet for that address.", + "errorMissingWalletTitle": "Wallet not found", + "errorMissingWalletDescription": "The wallet you are trying to edit no longer exists.", + "errorDecryptTitle": "Incorrect master password", + "errorDecryptDescription": "Failed to decrypt the wallet password. Is the master password correct?", + "errorWalletLimitTitle": "Wallet limit reached", + "errorWalletLimitDescription": "You currently cannot add any more wallets." + }, + + "addContact": { + "modalTitle": "Add contact", + "modalTitleEdit": "Edit contact", + + "buttonSubmit": "Add", + "buttonSubmitEdit": "Save", + + "contactLabel": "Label", + "contactLabelPlaceholder": "Contact label (optional)", + "contactLabelMaxLengthError": "No longer than 32 characters", + "contactLabelWhitespaceError": "Must not be only spaces", + + "contactAddressLabel": "Address or name", + + "messageSuccessAdd": "Added contact successfully!", + "messageSuccessEdit": "Saved contact successfully!", + + "errorDuplicateContactTitle": "Contact already exists", + "errorDuplicateContactDescription": "You already have a contact for that address.", + "errorMissingContactTitle": "Contact not found", + "errorMissingContactDescription": "The contact you are trying to edit no longer exists.", + "errorContactLimitTitle": "Contact limit reached", + "errorContactLimitDescription": "You currently cannot add any more contacts." + }, + + "dashboard": { + "siteTitle": "Dashboard", + + "inDevBanner": "Welcome to the TenebraWeb v2 private beta! This site is still in development, so most features are currently missing. Please report all bugs on <1>GitHub. Thanks!", + "inDevBanner2": "Welcome to the TenebraWeb v2 public beta! This site is relatively new, so please report any bugs on <1>GitHub. Thanks!", + + "walletOverviewCardTitle": "Wallets", + "walletOverviewTotalBalance": "Total balance", + "walletOverviewNames": "Names", + "walletOverviewNamesCount": "{{count, number}} name", + "walletOverviewNamesCount_plural": "{{count, number}} names", + "walletOverviewNamesCountEmpty": "No names", + "walletOverviewSeeMore": "See all {{count, number}}...", + "walletOverviewAddWallets": "Add wallets...", + + "transactionsCardTitle": "Transactions", + "transactionsError": "There was an error fetching your transactions. See the console for details.", + + "blockValueCardTitle": "Block value", + "blockValueBaseValue": "Base value (<1>)", + "blockValueBaseValueNames": "{{count, number}} name", + "blockValueBaseValueNames_plural": "{{count, number}} names", + "blockValueNextDecrease": "Decreases by <1> in <3>{{count, number}} block", + "blockValueNextDecrease_plural": "Decreases by <1> in <3>{{count, number}} blocks", + "blockValueReset": "Resets in <1>{{count, number}} block", + "blockValueReset_plural": "Resets in <1>{{count, number}} blocks", + "blockValueEmptyDescription": "The block value increases when <1>names are purchased.", + + "blockDifficultyCardTitle": "Block difficulty", + "blockDifficultyError": "There was an error fetching the block difficulty. See the console for details.", + "blockDifficultyHashRate": "Approx. <1 />", + "blockDifficultyHashRateTooltip": "Estimated combined network mining hash rate, based on the current work.", + "blockDifficultyChartWork": "Block Difficulty", + "blockDifficultyChartLinear": "Linear", + "blockDifficultyChartLog": "Logarithmic", + + "motdCardTitle": "Server MOTD", + "motdDebugMode": "This server is an unofficial development server. Balances and transactions can be manipulated. Proceed with caution.", + + "whatsNewCardTitle": "What's new", + "whatsNewButton": "What's new", + + "tipsCardTitle": "Tip of the day", + "tipsPrevious": "Prev", + "tipsNext": "Next", + "tips": { + "0": "Check out what's new in Tenebra and TenebraWeb on the [What's New page](/whatsnew)!", + "1": "You can quickly navigate through tables with the arrow keys on desktop.", + "2": "You can click on table headers to sort them.", + "3": "You can filter by categories in the [My Wallets](/wallets) page by clicking the filter icon in the table header.", + "4": "The [settings page](/settings) has many advanced options to personalise your TenebraWeb experience.", + "5": "Generate pre-filled transaction links with the new [Request page](/request).", + "6": "Be sure to backup [your wallets](/wallets)!", + "7": "Quickly search the Tenebra network with the keyboard shortcut Ctrl+K (Cmd+K on macOS).", + "8": "Add contacts in the [address book](/contacts) to quickly send them transactions.", + "9": "A 'bumped' transaction is a transaction sent to and from the same address.", + "10": "The 'block difficulty' chart can be shown with a logarithmic scale to see small changes easier at lower difficulties.", + "1-status": "You are connected to an unofficial server. Your wallet passwords may be sent to the operator, who can use them to access your wallets on the official server. Please ask a question <1>here for more information.", + "11": "The date format can be changed in the [advanced settings](/settings).", + "12": "You can see the [lowest mined block hashes](/network/blocks/lowest).", + "13": "The most recently purchased names can be seen on the [Network Names page](/network/names/new).", + "14": "The block value increases when [names](/network/names) are purchased.", + "15": "If you're worried about accidental transactions, you can enable a confirmation prompt in the [advanced settings](/settings)." + } + }, + + "credits": { + "title": "Credits", + "madeBy": "Made by <1>{{authorName}}", + "hostedBy": "Hosted by <1>{{host}}", + "supportersTitle": "Supporters", + "supportersDescription": "This project was made possible by the following amazing supporters:", + "supportButton": "Support TenebraWeb", + "translatorsTitle": "Translators", + "translatorsDescription": "This project was translated by the following amazing contributors:", + "translateButton": "Translate TenebraWeb", + "tmpim": "Created by tmpim", + + "versionInfo": { + "version": "Version", + "commitHash": "Commit", + "buildTime": "Build time", + "variant": "Build variant", + "license": "License" + }, + + "privacyTitle": "Privacy", + "privacy": { + "tenebraServer": "Tenebra Server", + "tenebraServerDesc": "The only PII that the <1>Tenebra Server stores is your IP address, User-Agent and Origin, as part of the webserver logs. This information is automatically purged after 30 days.", + "tenebraweb": "TenebraWeb", + "tenebrawebDesc1": "TenebraWeb uses a self-hosted <1>Sentry server for automatic error reporting. This system stores your IP address, User-Agent, Origin, breadcrumbs, and the details for any errors that get automatically reported. This information is automatically purged after 30 days.", + "tenebrawebDesc2": "If you have an ad-blocking or tracker-blocking extension such as <1>uBlock Origin (recommended), our Sentry system is already blocked by the built-in lists, so you do not have to worry about your privacy. You can also opt-out of error reporting in the <4>settings page. That said, if you’d like to help us by providing more detailed error reports, then please consider making an exception for TenebraWeb in your tracker blocker software. This site does not serve ads.", + "tenebrawebDesc3": "If you have any questions or concerns, please contact the developers." + } + }, + + "settings": { + "siteTitle": "Settings", + "title": "Settings", + + "messageSuccess": "Setting changed successfully!", + + "settingIntegerSave": "Save", + + "menuLanguage": "Language", + + "subMenuBackups": "Manage backups", + "importBackup": "Import wallets", + "exportBackup": "Export wallets", + + "subMenuMasterPassword": "Master password", + "changeMasterPassword": "Change master password", + "resetMasterPassword": "Reset master password", + + "subMenuAutoRefresh": "Auto-refresh", + "autoRefreshTables": "Auto-refresh tables", + "autoRefreshTablesDescription": "Whether or not large table listings (e.g. transactions, names) should automatically refresh when a change is detected on the network.", + "autoRefreshAddressPage": "Auto-refresh address page", + "autoRefreshNamePage": "Auto-refresh name page", + + "subMenuAdvanced": "Advanced settings", + "alwaysIncludeMined": "Always include mined transactions in transaction listings (may require refresh)", + "copyNameSuffixes": "Include suffix when copying names", + "addressCopyButtons": "Show copy buttons next to all addresses", + "nameCopyButtons": "Show copy buttons next to all names", + "blockHashCopyButtons": "Show copy buttons next to all block hashes", + "showRelativeDates": "Show relative dates instead of absolute ones if recent", + "showRelativeDatesDescription": "Everywhere on the site, if a date is less than 7 days ago, it will show as a relative date instead.", + "showNativeDates": "Show dates in a native date format from the language", + "showNativeDatesDescription": "If disabled, dates will always be shown as YYYY/MM/DD HH:mm:ss", + "transactionsHighlightOwn": "Highlight own transactions in the transactions table", + "transactionsHighlightVerified": "Highlight verified addresses in the transactions table", + "transactionDefaultRaw": "Default to the 'Raw' tab instead of 'CommonMeta' on the transaction page", + "confirmTransactions": "Prompt for confirmation for all transactions", + "clearTransactionForm": "Clear the Send Transaction form after clicking 'Send'", + "sendTransactionDelay": "Time to wait, in milliseconds, before allowing another transaction to be sent", + "defaultPageSize": "Default page size for table listings", + "tableHotkeys": "Enable table navigation hotkeys (left and right arrows)", + + "subMenuPrivacy": "Privacy", + "privacyInfo": "Privacy information", + "errorReporting": "Enable automatic error reporting (requires refresh)", + "messageOnErrorReport": "Show a notification when an error is automatically reported (requires refresh)", + + "subMenuDebug": "Debug settings", + "advancedWalletFormats": "Advanced wallet formats", + "menuTranslations": "Translations", + + "subTitleTranslations": "Translations", + + "translations": { + "errorMissingLanguages": "The languages.json file seems to be missing. Was TenebraWeb compiled correctly?", + "errorNoKeys": "No translation keys", + + "columnLanguageCode": "Code", + "columnLanguage": "Language", + "columnKeys": "Keys", + "columnMissingKeys": "Missing keys", + "columnProgress": "Progress", + + "tableUntranslatedKeys": "Untranslated keys", + "columnKey": "Key", + "columnEnglishString": "English string", + + "importJSON": "Import JSON", + "exportCSV": "Export CSV", + + "importedLanguageTitle": "Imported language" + } + }, + + "breadcrumb": { + "dashboard": "Dashboard", + "wallets": "Wallets", + + "settings": "Settings", + "settingsDebug": "Debug", + "settingsTranslations": "Translations" + }, + + "ws": { + "errorToken": "There was an error connecting to the Tenebra websocket server.", + "errorWS": "There was an error connecting to the Tenebra websocket server (code <1>{{code}})." + }, + + "rateLimitTitle": "Rate limit hit", + "rateLimitDescription": "Too many requests were sent to the Tenebra server in a short period of time. This is probably caused by a bug!", + + "address": { + "title": "Address", + + "walletLabel": "Label:", + "walletCategory": "Category:", + "contactLabel": "Contact:", + + "balance": "Current balance", + "names": "Names", + "nameCount": "{{count, number}} name", + "nameCount_plural": "{{count, number}} names", + "nameCountEmpty": "No names", + "firstSeen": "First seen", + + "buttonSendTenebra": "Send Tenebra to {{address}}", + "buttonTransferTenebra": "Transfer Tenebra to {{address}}", + "buttonAddContact": "Add to address book", + "buttonEditContact": "Edit contact", + "buttonEditWallet": "Edit wallet", + + "tooltipV1Address": "Transactions cannot be sent to v1 addresses, as they have been deprecated.", + + "cardRecentTransactionsTitle": "Recent transactions", + "cardNamesTitle": "Names", + + "transactionsError": "There was an error fetching the transactions. See the console for details.", + "namesError": "There was an error fetching the names. See the console for details.", + + "namePurchased": "Purchased <1 />", + "nameReceived": "Received <1 />", + "namesSeeMore": "See all {{count, number}}...", + + "resultInvalidTitle": "Invalid address", + "resultInvalid": "That does not look like a valid Tenebra address.", + "resultNotFoundTitle": "Address not found", + "resultNotFound": "That address has not yet been initialised on the Tenebra network.", + + "verifiedCardTitle": "Verified address", + "verifiedInactive": "This service is not currently active.", + "verifiedWebsiteButton": "Visit website" + }, + + "transactionSummary": { + "itemID": "Transaction ID: {{id}}", + "itemFrom": "<0>From: <1 />", + "itemTo": "<0>To: <1 />", + "itemName": "<0>Name: <1 />", + "itemARecord": "<0>A record: <1 />", + "itemARecordRemoved": "(removed)", + "seeMore": "See all {{count, number}}..." + }, + + "transactions": { + "title": "Network Transactions", + "myTransactionsTitle": "My Transactions", + "nameHistoryTitle": "Name History", + "nameTransactionsTitle": "Name Transactions", + "searchTitle": "Transaction Search", + + "siteTitleWallets": "My Transactions", + "siteTitleNetworkAll": "Network Transactions", + "siteTitleNetworkAddress": "{{address}}'s Transactions", + "siteTitleNameHistory": "Name History", + "siteTitleNameSent": "Name Transactions", + "siteTitleSearch": "Transaction Search", + + "subTitleSearchAddress": "Involving {{address}}", + "subTitleSearchName": "Involving {{name}}", + "subTitleSearchMetadata": "With metadata '{{query}}'", + + "columnID": "ID", + "columnType": "Type", + "columnFrom": "From", + "columnTo": "To", + "columnValue": "Value", + "columnName": "Name", + "columnMetadata": "Metadata", + "columnTime": "Time", + + "tableTotal": "{{count, number}} item", + "tableTotal_plural": "{{count, number}} items", + "tableTotalEmpty": "No items", + + "includeMined": "Include mined transactions", + + "resultInvalidTitle": "Invalid address", + "resultInvalid": "That does not look like a valid Tenebra address.", + + "types": { + "transferred": "Transferred", + "sent": "Sent", + "received": "Received", + "mined": "Mined", + "name_a_record": "Updated name", + "name_transferred": "Moved name", + "name_sent": "Sent name", + "name_received": "Received name", + "name_purchased": "Purchased name", + "bumped": "Bumped", + "unknown": "Unknown" + } + }, + + "names": { + "titleWallets": "My Names", + "titleNetworkAll": "Network Names", + "titleNetworkAddress": "Network Names", + + "siteTitleWallets": "My Names", + "siteTitleNetworkAll": "Network Names", + "siteTitleNetworkAddress": "{{address}}'s Names", + + "columnName": "Name", + "columnOwner": "Owner", + "columnOriginalOwner": "Original Owner", + "columnRegistered": "Registered", + "columnUpdated": "Updated", + "columnARecord": "A Record", + "columnUnpaid": "Unpaid Blocks", + + "mobileOwner": "<0>Owner: <1 />", + "mobileOriginalOwner": "<0>Original owner: <1 />", + "mobileRegistered": "Registered: <1 />", + "mobileUpdated": "Updated: <1 />", + "mobileARecordTag": "A", + "mobileUnpaidTag": "{{count, number}} block", + "mobileUnpaidTag_plural": "{{count, number}} blocks", + + "actions": "Actions", + "actionsViewName": "View name", + "actionsViewOwner": "View owner's address", + "actionsViewOriginalOwner": "View original owner's address", + "actionsSendTenebra": "Send Tenebra", + "actionsTransferTenebra": "Transfer Tenebra", + "actionsUpdateARecord": "Update A record", + "actionsTransferName": "Transfer name", + + "tableTotal": "{{count, number}} name", + "tableTotal_plural": "{{count, number}} names", + "tableTotalEmpty": "No names", + + "resultInvalidTitle": "Invalid address", + "resultInvalid": "That does not look like a valid Tenebra address.", + + "purchaseButton": "Purchase name" + }, + + "name": { + "title": "Name", + + "buttonSendTenebra": "Send Tenebra to {{name}}", + "buttonTransferTenebra": "Transfer Tenebra to {{name}}", + "buttonARecord": "Update A record", + "buttonTransferName": "Transfer name", + + "owner": "Owned by", + "originalOwner": "Purchased by", + "registered": "Registered", + "updated": "Last updated", + "unpaid": "Unpaid blocks", + "unpaidCount": "{{count, number}} block", + "unpaidCount_plural": "{{count, number}} blocks", + "aRecord": "A record", + "aRecordEditTooltip": "Update A record", + + "cardRecentTransactionsTitle": "Recent transactions", + "cardHistoryTitle": "Name history", + "transactionsError": "There was an error fetching the transactions. See the console for details.", + "historyError": "There was an error fetching the name history. See the console for details.", + + "resultInvalidTitle": "Invalid name", + "resultInvalid": "That does not look like a valid Tenebra name.", + "resultNotFoundTitle": "Name not found", + "resultNotFound": "That name does not exist." + }, + + "blocks": { + "title": "Network Blocks", + "titleLowest": "Lowest Blocks", + "siteTitle": "Network Blocks", + "siteTitleLowest": "Lowest Blocks", + + "columnHeight": "Height", + "columnAddress": "Miner", + "columnHash": "Block Hash", + "columnValue": "Value", + "columnDifficulty": "Difficulty", + "columnTime": "Time", + + "mobileHeight": "Block #{{height, number}}", + "mobileMiner": "<0>Miner: <1 />", + "mobileHash": "<0>Hash: <1 />", + "mobileDifficulty": "<0>Difficulty: <1 />", + + "tableTotal": "{{count, number}} block", + "tableTotal_plural": "{{count, number}} blocks", + "tableTotalEmpty": "No blocks" + }, + + "block": { + "title": "Block", + "siteTitle": "Block", + "siteTitleBlock": "Block #{{id, number}}", + "subTitleBlock": "#{{id, number}}", + + "height": "Height", + "miner": "Miner", + "value": "Value", + "time": "Time", + "hash": "Hash", + "difficulty": "Difficulty", + + "previous": "Prev", + "previousTooltip": "Previous block (#{{id, number}})", + "previousTooltipNone": "Previous block", + "next": "Next", + "nextTooltip": "Next block (#{{id, number}})", + "nextTooltipNone": "Next block", + + "resultInvalidTitle": "Invalid block height", + "resultInvalid": "That does not look like a valid block height.", + "resultNotFoundTitle": "Block not found", + "resultNotFound": "That block does not exist." + }, + + "transaction": { + "title": "Transaction", + "siteTitle": "Transaction", + "siteTitleTransaction": "Transaction #{{id, number}}", + "subTitleTransaction": "#{{id, number}}", + + "type": "Type", + "from": "From", + "to": "To", + "address": "Address", + "name": "Name", + "value": "Value", + "time": "Time", + "aRecord": "A record", + + "cardMetadataTitle": "Metadata", + "tabCommonMeta": "CommonMeta", + "tabRaw": "Raw", + + "commonMetaError": "CommonMeta parsing failed.", + "commonMetaParsed": "Parsed records", + "commonMetaParsedHelp": "These values were not directly contained in the transaction metadata, but they were inferred by the CommonMeta parser.", + "commonMetaCustom": "Transaction records", + "commonMetaCustomHelp": "These values were directly contained in the transaction metadata.", + "commonMetaColumnKey": "Key", + "commonMetaColumnValue": "Value", + + "cardRawDataTitle": "Raw data", + "cardRawDataHelp": "The transaction exactly as it was returned by the Tenebra API.", + + "rawDataColumnKey": "Key", + "rawDataColumnValue": "Value", + + "resultInvalidTitle": "Invalid transaction ID", + "resultInvalid": "That does not look like a valid transaction ID.", + "resultNotFoundTitle": "Transaction not found", + "resultNotFound": "That transaction does not exist." + }, + + "apiErrorResult": { + "resultUnknownTitle": "Unknown error", + "resultUnknown": "See console for details." + }, + + "noWalletsResult": { + "title": "No wallets yet", + "subTitle": "You currently have no wallets saved in TenebraWeb, so there is nothing to see here yet. Would you like to add a wallet?", + "subTitleSendTransaction": "You currently have no wallets saved in TenebraWeb, so you can't make a transaction yet. Would you like to add a wallet?", + "button": "Add wallets", + "buttonNetworkTransactions": "Network transactions", + "buttonNetworkNames": "Network names" + }, + + "backups": { + "importButton": "Import backup", + "exportButton": "Export backup" + }, + + "import": { + "description": "Paste the backup code (or import from a file below) and enter the corresponding master password. Backups from TenebraWeb v1 are also supported.", + + "masterPasswordPlaceholder": "Master password", + "masterPasswordRequired": "Master password is required.", + "masterPasswordIncorrect": "Master password is incorrect.", + + "appMasterPasswordRequired": "You must be authenticated to import wallets.", + + "fromFileButton": "Import from file", + "textareaPlaceholder": "Paste backup code here", + "textareaRequired": "Backup code is required.", + "fileErrorTitle": "Import error", + "fileErrorNotText": "The imported file must be a text file.", + + "overwriteCheckboxLabel": "Update existing wallet labels if there are conflicts", + + "modalTitle": "Import backup", + "modalButton": "Import", + + "detectedFormat": "<0>Detected format: <2 />", + "detectedFormatTenebraWebV1": "TenebraWeb v1", + "detectedFormatTenebraWebV2": "TenebraWeb v2", + "detectedFormatInvalid": "Invalid!", + + "progress": "Importing <1>{{count, number}} item...", + "progress_plural": "Importing <1>{{count, number}} items...", + + "decodeErrors": { + "atob": "The backup could not be decoded as it is not valid base64!", + "json": "The backup could not be decoded as it is not valid JSON!", + "missingTester": "The backup could not be decoded as it is missing a 'tester' key!", + "missingSalt": "The backup could not be decoded as it is missing a 'salt' key!", + "invalidTester": "The backup could not be decoded as the 'tester' key is the wrong type!", + "invalidSalt": "The backup could not be decoded as the 'salt' key is the wrong type!", + "invalidWallets": "The backup could not be decoded as the 'wallets' key is the wrong type!", + "invalidFriends": "The backup could not be decoded as the 'friends' key is the wrong type!", + "invalidContacts": "The backup could not be decoded as the 'contacts' key is the wrong type!", + "unknown": "The backup could not be decoded due to an unknown error. See console for details." + }, + + "walletMessages": { + "success": "Wallet imported successfully.", + "successSkipped": "A wallet with the same address ({{address}}) and settings already exists, so it was skipped.", + "successUpdated": "A wallet with the same address ({{address}}) already exists. Its label was updated to \"{{label}}\"", + "successSkippedNoOverwrite": "A wallet with the same address ({{address}}) already exists, and you chose not to overwrite the label, so it was skipped.", + "successImportSkipped": "A wallet with the same address ({{address}}) was already imported, so it was skipped.", + + "warningSyncNode": "This wallet had a custom sync node, which is not supported in TenebraWeb v2. The sync node was skipped.", + "warningIcon": "This wallet had a custom icon, which is not supported in TenebraWeb v2. The icon was skipped.", + "warningLabelInvalid": "The label for this wallet was invalid. The label was skipped.", + "warningCategoryInvalid": "The category for this wallet was invalid. The category was skipped.", + "warningAdvancedFormat": "This wallet uses an advanced format ({{format}}), which has limited support in TenebraWeb v2.", + + "errorInvalidTypeString": "This wallet was not a string!", + "errorInvalidTypeObject": "This wallet was not an object!", + "errorDecrypt": "This wallet could not be decrypted!", + "errorPasswordDecrypt": "The password for this wallet could not be decrypted!", + "errorDataJSON": "The decrypted data was not valid JSON!", + "errorUnknownFormat": "This wallet uses an unknown or unsupported format!", + "errorFormatMissing": "This wallet is missing a format!", + "errorUsernameMissing": "This wallet is missing a username!", + "errorPasswordMissing": "This wallet is missing a password!", + "errorPrivateKeyMissing": "This wallet is missing a private key!", + "errorMasterKeyMissing": "This wallet is missing a master key!", + "errorPrivateKeyMismatch": "This wallet's password did not map to its stored private key!", + "errorMasterKeyMismatch": "This wallet's password did not map to its stored master key!", + "errorLimitReached": "You reached the wallet limit. You currently cannot add any more wallets.", + "errorUnknown": "An unknown error occurred. See console for details." + }, + + "contactMessages": { + "success": "Contact imported successfully.", + "successSkipped": "A contact with the same address ({{address}}) and settings already exists, so it was skipped.", + "successUpdated": "A contact with the same address ({{address}}) already exists. Its label was updated to \"{{label}}\"", + "successSkippedNoOverwrite": "A contact with the same address ({{address}}) already exists, and you chose not to overwrite the label, so it was skipped.", + "successImportSkipped": "A contact with the same address ({{address}}) was already imported, so it was skipped.", + + "warningSyncNode": "This contact had a custom sync node, which is not supported in TenebraWeb v2. The sync node was skipped.", + "warningIcon": "This contact had a custom icon, which is not supported in TenebraWeb v2. The icon was skipped.", + "warningLabelInvalid": "The label for this contact was invalid. The label was skipped.", + + "errorInvalidTypeString": "This contact was not a string!", + "errorInvalidTypeObject": "This contact was not an object!", + "errorDecrypt": "This contact could not be decrypted!", + "errorDataJSON": "The decrypted data was not valid JSON!", + "errorAddressMissing": "This contact is missing an address!", + "errorAddressInvalid": "This contact's address is invalid!", + "errorLimitReached": "You reached the contact limit. You currently cannot add any more contacts.", + "errorUnknown": "An unknown error occurred. See console for details." + }, + + "results": { + "noneImported": "No new wallets were imported.", + + "walletsImported": "<0>{{count, number}} new wallet was imported.", + "walletsImported_plural": "<0>{{count, number}} new wallets were imported.", + "walletsSkipped": "{{count, number}} wallet was skipped.", + "walletsSkipped_plural": "{{count, number}} wallets were skipped.", + + "contactsImported": "<0>{{count, number}} new contact was imported.", + "contactsImported_plural": "<0>{{count, number}} new contacts were imported.", + "contactsSkipped": "{{count, number}} contact was skipped.", + "contactsSkipped_plural": "{{count, number}} contacts were skipped.", + + "warnings": "There was <1>{{count, number}} warning while importing your backup.", + "warnings_plural": "There were <1>{{count, number}} warnings while importing your backup.", + "errors": "There was <1>{{count, number}} error while importing your backup.", + "errors_plural": "There were <1>{{count, number}} errors while importing your backup.", + + "treeHeaderWallets": "Wallets", + "treeHeaderContacts": "Contacts", + + "treeWallet": "Wallet {{id}}", + "treeContact": "Contact {{id}}" + } + }, + + "export": { + "modalTitle": "Export backup", + + "description": "This secret code contains your wallets and address book contacts. You can use it to import them in another browser, or to back them up. You will still need your master password to import the wallets in the future. <1>Do not share this code with anyone.", + "size": "Size: <1 />", + + "buttonSave": "Save to file", + "buttonCopy": "Copy to clipboard" + }, + + "walletLimitMessage": "You have more wallets stored than TenebraWeb supports. This was either caused by a bug, or you bypassed it intentionally. Expect issues with syncing.", + "contactLimitMessage": "You have more contacts stored than TenebraWeb supports. This was either caused by a bug, or you bypassed it intentionally. Expect issues with syncing.", + + "optionalFieldUnset": "(unset)", + + "addressPicker": { + "placeholder": "Choose a recipient", + "placeholderWalletsOnly": "Choose a wallet", + "placeholderNoWallets": "Address or name", + "placeholderNoWalletsNoNames": "Address", + + "hintCurrentBalance": "Current balance: <1 />", + + "errorAddressRequired": "Address is required.", + "errorRecipientRequired": "Recipient is required.", + "errorWalletRequired": "Wallet is required.", + + "errorInvalidAddress": "Invalid address or name.", + "errorInvalidAddressOnly": "Invalid address.", + "errorInvalidRecipient": "Invalid recipient. Must be an address or name.", + "errorInvalidWalletsOnly": "Invalid wallet address.", + "errorEqual": "Recipient cannot be the same as the sender.", + + "categoryWallets": "Wallets", + "categoryOtherWallets": "Other wallets", + "categoryAddressBook": "Address book", + "categoryExactAddress": "Exact address", + "categoryExactName": "Exact name", + + "addressHint": "Balance: <1 />", + "addressHintWithNames": "Names: <1>{{names, number}}", + "nameHint": "Owner: <1 />", + "nameHintNotFound": "Name not found.", + "walletHint": "Wallet: <1 />" + }, + + "sendTransaction": { + "title": "Send transaction", + "siteTitle": "Send transaction", + + "modalTitle": "Send transaction", + "modalSubmit": "Send", + + "buttonSubmit": "Send", + + "labelFrom": "From wallet", + "labelTo": "To address/name", + "labelAmount": "Amount", + "labelMetadata": "Metadata", + "placeholderMetadata": "Optional metadata", + + "buttonMax": "Max", + + "errorAmountRequired": "Amount is required.", + "errorAmountNumber": "Amount must be a number.", + "errorAmountTooLow": "Amount must be at least 1.", + "errorAmountTooHigh": "Insufficient funds in wallet.", + + "errorMetadataTooLong": "Metadata must be less than 256 characters.", + "errorMetadataInvalid": "Metadata contains invalid characters.", + + "errorWalletGone": "That wallet no longer exists.", + "errorWalletDecrypt": "Your wallet could not be decrypted.", + + "errorParameterTo": "Invalid recipient.", + "errorParameterAmount": "Invalid amount.", + "errorParameterMetadata": "Invalid metadata.", + "errorInsufficientFunds": "Insufficient funds in wallet.", + "errorNameNotFound": "The recipient name could not be found.", + + "errorUnknown": "Unknown error sending transaction. See console for details.", + + "payLargeConfirm": "Are you sure you want to send <1 />?", + "payLargeConfirmHalf": "Are you sure you want to send <1 />? This is over half your balance!", + "payLargeConfirmAll": "Are you sure you want to send <1 />? This is your entire balance!", + "payLargeConfirmDefault": "You are about to send your wallet private key and password to an unofficial Tenebra server, which will give them access to your Tenebra on the official server. Are you sure you want to do this?", + + "errorNotificationTitle": "Transaction failed", + "successNotificationTitle": "Transaction successful", + "successNotificationContent": "You sent <1 /> from <3 /> to <5 />.", + "successNotificationButton": "View transaction", + + "errorInvalidQuery": "The query parameters were invalid, they were ignored." + }, + + "request": { + "title": "Request Tenebra", + "siteTitle": "Request Tenebra", + + "labelTo": "Request recipient", + "labelAmount": "Request amount", + "labelMetadata": "Request metadata", + "placeholderMetadata": "Metadata", + + "generatedLink": "Generated link", + "generatedLinkHint": "Send this link to somebody to request a payment from them." + }, + + "authFailed": { + "title": "Auth failed", + "message": "You do not own this address.", + "messageLocked": "This address was locked.", + "alert": "Message from the Tenebra server:" + }, + + "whatsNew": { + "title": "What's new", + "siteTitle": "What's new", + + "titleTenebra": "Tenebra", + "titleTenebraWeb": "TenebraWeb", + + "tooltipGitHub": "View on GitHub", + + "cardWhatsNewTitle": "What's New", + "cardCommitsTitle": "Commits", + "cardCommitsSeeMore": "See more", + + "new": "New!" + }, + + "namePicker": { + "placeholder": "Choose a name", + "placeholderMultiple": "Choose names", + + "buttonAll": "All", + + "warningTotalLimit": "You seem to have more than 1,000 names, which is not yet supported in TenebraWeb v2. Please post an issue on GitHub.", + "errorLookup": "There was an error fetching the names. See the console for details." + }, + + "nameTransfer": { + "modalTitle": "Transfer names", + + "labelNames": "Names", + "labelRecipient": "Recipient", + + "buttonSubmit": "Transfer names", + + "errorNameRequired": "At least one name is required.", + + "errorWalletGone": "That wallet no longer exists.", + "errorWalletDecrypt": "The wallet \"{{address}}\" could not be decrypted.", + "errorParameterNames": "Invalid names.", + "errorParameterRecipient": "Invalid recipient.", + "errorNameNotFound": "One or more names could not be found.", + "errorNotNameOwner": "You are not the owner of one or more names.", + "errorUnknown": "Unknown error transferring names. See console for details.", + "errorNotificationTitle": "Name transfer failed", + + // Note that the zero and singular cases of these warnings will never be + // shown, but they're provided for i18n compatibility. + "warningMultipleNames": "Are you sure you want to transfer <1>{{count, number}} name to <3 />?", + "warningMultipleNames_plural": "Are you sure you want to transfer <1>{{count, number}} names to <3 />?", + "warningAllNames": "Are you sure you want to transfer <1>{{count, number}} name to <3 />? This is all your names!", + "warningAllNames_plural": "Are you sure you want to transfer <1>{{count, number}} names to <3 />? This is all your names!", + + "successMessage": "Name transferred successfully", + "successMessage_plural": "Names transferred successfully", + "successDescription": "Transferred <1>{{count, number}} name to <3 />.", + "successDescription_plural": "Transferred <1>{{count, number}} names to <3 />.", + + "progress": "Transferring <1>{{count, number}} name...", + "progress_plural": "Transferring <1>{{count, number}} names..." + }, + + "nameUpdate": { + "modalTitle": "Update names", + + "labelNames": "Names", + "labelARecord": "A record", + "placeholderARecord": "A record (optional)", + + "buttonSubmit": "Update names", + + "errorNameRequired": "At least one name is required.", + + "errorWalletGone": "That wallet no longer exists.", + "errorWalletDecrypt": "The wallet \"{{address}}\" could not be decrypted.", + "errorParameterNames": "Invalid names.", + "errorParameterARecord": "Invalid A record.", + "errorNameNotFound": "One or more names could not be found.", + "errorNotNameOwner": "You are not the owner of one or more names.", + "errorUnknown": "Unknown error updating names. See console for details.", + "errorNotificationTitle": "Name transfer failed", + + "successMessage": "Name updated successfully", + "successMessage_plural": "Names updated successfully", + "successDescription": "Updated <1>{{count, number}} name.", + "successDescription_plural": "Updated <1>{{count, number}} names.", + + "progress": "Updating <1>{{count, number}} name...", + "progress_plural": "Updating <1>{{count, number}} names..." + }, + + "noNamesResult": { + "title": "No names yet", + "subTitle": "You currently have no names in any of your wallets saved in TenebraWeb, so there is nothing to see here yet. Would you like to purchase a name?", + "button": "Purchase name" + }, + + "namePurchase": { + "modalTitle": "Purchase name", + + "nameCost": "Cost to purchase: <1 />", + + "labelWallet": "Wallet", + "labelName": "Name", + "placeholderName": "Name", + + "buttonSubmit": "Purchase (<1 />)", + + "errorNameRequired": "Name is required.", + "errorInvalidName": "Invalid name.", + "errorNameTooLong": "Name is too long.", + "errorNameTaken": "That name is already taken!", + "errorInsufficientFunds": "Your wallet does not have enough funds to purchase a name.", + + "errorWalletGone": "That wallet no longer exists.", + "errorWalletDecrypt": "The wallet \"{{address}}\" could not be decrypted.", + "errorUnknown": "Unknown error purchasing name. See console for details.", + "errorNotificationTitle": "Name purchase failed", + + "successMessage": "Name purchased successfully", + "successNotificationButton": "View name", + + "nameAvailable": "Name is available!" + }, + + "purchaseTenebra": { + "modalTitle": "Purchase Tenebra", + "connection": "A connection was just made to an unofficial Tenebra server. Your passwords and Tenebra wallets are at risk." + }, + + "syncWallets": { + "errorMessage": "Error syncing wallets", + "errorDescription": "There was an error while syncing your wallets. See console for details." + }, + + "legacyMigration": { + "modalTitle": "TenebraWeb v1 migration", + "description": "Welcome to TenebraWeb v2! It looks like you have used TenebraWeb v1 on this domain before.

Please enter your master password to migrate your wallets to the new format. You will only have to do this once.", + + "walletCount": "Detected <1>{{count, number}} wallet", + "walletCount_plural": "Detected <1>{{count, number}} wallets", + "contactCount": "Detected <1>{{count, number}} contact", + "contactCount_plural": "Detected <1>{{count, number}} contacts", + + "masterPasswordLabel": "Master password", + "masterPasswordPlaceholder": "Master password", + + "errorPasswordRequired": "Password is required.", + "errorPasswordLength": "Must be at least 1 character.", + "errorPasswordIncorrect": "Incorrect password.", + "errorUnknown": "An unknown error occurred. See console for details.", + + "buttonForgotPassword": "Forgot password", + "buttonSubmit": "Begin migration", + + "forgotPassword": { + "modalTitle": "Skip v1 migration", + "modalContent": "If you forgot your master password for TenebraWeb v1, then you will not be able to migrate your wallets. You will never be asked again. Are you sure you want to skip migration?", + "buttonSkip": "Skip" + } + }, + + "sortModal": { + "title": "Sort results", + + "sortBy": "Sort by", + "sortOrder": "Sort order", + "sortAscending": "Ascending", + "sortDescending": "Descending", + + "buttonReset": "Reset", + + "options": { + "transactionsFrom": "From", + "transactionsTo": "To", + "transactionsValue": "Value", + "transactionsName": "Name", + "transactionsTime": "Time", + + "namesName": "Name", + "namesOwner": "Owner", + "namesOriginalOwner": "Original Owner", + "namesARecord": "A Record", + "namesUnpaid": "Unpaid Blocks", + "namesRegistered": "Registered Time", + "namesUpdated": "Updated Time", + + "blocksMiner": "Miner", + "blocksHash": "Hash", + "blocksValue": "Value", + "blocksDifficulty": "Difficulty", + "blocksTime": "Time" + } + } +} diff --git a/public/locales/fr.json b/public/locales/fr.json new file mode 100644 index 0000000..f5f8467 --- /dev/null +++ b/public/locales/fr.json @@ -0,0 +1,1236 @@ +{ + "app": { + "name": "TenebraWeb" + }, + + "nav": { + "connection": { + "online": "En ligne", + "offline": "Hors ligne", + "connecting": "En Connexion" + }, + + "search": { + "placeholder": "Rechercher sur le réseau Tenebra", + "placeholderShortcut": "Rechercher sur le réseau Tenebra ({{shortcut}})", + "placeholderShort": "Rechercher...", + "rateLimitHit": "Veuillez ralentir.", + "noResults": "Aucun résultat.", + + "resultAddress": "Adresse", + "resultName": "Nom", + "resultNameOwner": "Appartient à <1 />", + "resultBlockID": "ID de bloc", + "resultBlockIDMinedBy": "Miné par <1 />", + "resultTransactionID": "ID de transaction", + "resultTransactions": "Transactions", + "resultTransactionsAddress": "Recherche de transactions impliquant <1 />", + "resultTransactionsAddressResult": "<0>{{count, number}} transaction impliquant <2 />", + "resultTransactionsAddressResult_plural": "<0>{{count, number}} transactions impliquant <2 />", + "resultTransactionsAddressResultEmpty": "Aucune transactions impliquant <1 />", + "resultTransactionsName": "Recherche de transactions impliquant <1 />", + "resultTransactionsNameResult": "<0>{{count, number}} transaction envoyée à <2 />", + "resultTransactionsNameResult_plural": "<0>{{count, number}} transactions envoyées à <2 />", + "resultTransactionsNameResultEmpty": "Aucune transactions envoyées à <1 />", + "resultTransactionsMetadata": "Recherche de métadonnées contenant <1 />", + "resultTransactionsMetadataResult": "<0>{{count, number}} transaction avec des métadonnées contenant <2 />", + "resultTransactionsMetadataResult_plural": "<0>{{count, number}} transactions avec des métadonnées contenant <2 />", + "resultTransactionsMetadataResultEmpty": "Aucune transactions avec des métadonnées contenant <1 />" + }, + + "send": "Envoyer", + "sendLong": "Envoyer des Tenebra", + "request": "Demander", + "requestLong": "Demander des Tenebra", + "sort": "Trier les résultats", + + "settings": "Paramètres", + "more": "Plus" + }, + + "sidebar": { + "totalBalance": "Solde Total", + "dashboard": "Tableau de bord", + "myWallets": "Mes portefeuilles", + "addressBook": "Carnet d'adresses", + "transactions": "Transactions", + "names": "Noms", + "mining": "Minage", + "network": "Réseau", + "blocks": "Blocs", + "statistics": "Statistiques", + "madeBy": "Fait par <1>{{authorName}}", + "hostedBy": "Hébergé par <1>{{host}}", + "github": "GitHub", + "credits": "Crédits", + "whatsNew": "Quoi de neuf", + + "updateTitle": "Mise à jour disponible!", + "updateDescription": "Une nouvelle version de TenebraWeb est disponible. Veuillez rafraîchir.", + "updateReload": "Rafraîchir" + }, + + "dialog": { + "close": "Fermer", + "yes": "Oui", + "no": "Non", + "ok": "OK", + "cancel": "Annuler" + }, + + "pagination": { + "justPage": "Page {{page}}", + "pageWithTotal": "Page {{page}} de {{total}}" + }, + + "error": "Erreur", + "errorBoundary": { + "title": "Erreur critique", + "description": "Une erreur critique s'est produite dans TenebraWeb, cette page a donc été fermée. Voir console pour plus de détails.", + "sentryNote": "Cette erreur a été automatiquement signalée." + }, + "errorReported": "Une erreur a été automatiquement signalée. Voir console pour plus de détails.", + + "loading": "Chargement...", + + "copy": "Copier dans le presse-papier", + "copied": "Copié!", + + "pageNotFound": { + "resultTitle": "Page non trouvée", + "nyiTitle": "Pas encore implémenté", + "nyiSubTitle": "Cette fonctionnalité sera bientôt disponible!", + "buttonGoBack": "Reculer" + }, + + "contextualAddressUnknown": "Inconnue", + "contextualAddressNonExistentTooltip": "Cette adresse n'a pas encore été initialisée sur le réseau Tenebra.", + + "typeahead": { + "emptyLabel": "Aucun résultat.", + "paginationText": "Afficher plus de résultats..." + }, + + "masterPassword": { + "dialogTitle": "Mot de passe maître", + "passwordPlaceholder": "Mot de passe maître", + "passwordConfirmPlaceholder": "Confirmé le mot de passe maître", + "createPassword": "Créer un mot de passe", + "logIn": "Connexion", + "forgotPassword": "Mot de passe oublié?", + "intro2": "Entrez un <1>mot de passe maître afin de crypter les clés privées de vos portefeuilles. Ils seront enregistrés dans le stockage local de votre navigateur, et on vous demandera le mot de passe maître pour les décrypter une fois par session.", + "learnMore": "en savoir plus", + "errorPasswordRequired": "Mot de passe requis.", + "errorPasswordLength": "Doit contenir au moins 1 caractère.", + "errorPasswordUnset": "Le mot de passe maître n'est pas configuré.", + "errorPasswordIncorrect": "Mot de passe incorrect.", + "errorPasswordInequal": "Les mots de passe doivent correspondre.", + "errorStorageCorrupt": "Le stockage du portefeuille est corrompu.", + "errorNoPassword": "Le mot de passe maître est requis.", + "errorUnknown": "Erreur inconnue.", + "helpWalletStorageTitle": "Aide: stockage de portefeuille", + "popoverTitle": "Décrypter les portefeuilles", + "popoverTitleEncrypt": "Encryption de portefeuilles", + "popoverAuthoriseButton": "Autoriser", + "popoverDescription": "Entrez votre mot de passe maître pour decrypter vos portefeuilles.", + "popoverDescriptionEncrypt": "Entrez votre mot de passe maître pour encrypter et decrypter vos portefeuilles.", + "forcedAuthWarning": "Vous avez été automatiquement connecté par un paramètre de débogage non sécurisé.", + "earlyAuthError": "L'application n'est pas encore complètement chargée, veuillez réessayer.", + + "reset": { + "modalTitle": "Réinitialiser le mot de passe maître", + "description": "Voulez-vous vraiment réinitialiser votre mot de passe maître? Tous vos portefeuilles seront supprimés. Assurez-vous d'abord <3>d'exporter une sauvegarde!", + "buttonConfirm": "Réinitialiser et supprimer", + + "modalTitle2": "SUPPRIMER TOUS LES PORTEFEUILLES", + "description2": "Êtes-vous VRAIMENT sûr de vouloir SUPPRIMER TOUS VOS PORTEFEUILLES?", + "buttonConfirm2": "Oui, je suis sûr [{{n}}]", + "buttonConfirmFinal": "Oui, je suis sûr!" + }, + + "change": { + "modalTitle": "Modifier le mot de passe maître" + } + }, + + "myWallets": { + "title": "Portefeuilles", + "manageBackups": "Gérer les sauvegardes", + "importBackup": "Importer des portefeuilles", + "exportBackup": "Exporter des portefeuilles", + "createWallet": "Créer un portefeuille", + "addExistingWallet": "Ajouter un portefeuille existant", + "searchPlaceholder": "Chercher un portefeuille...", + "categoryDropdownAll": "Toutes catégories", + "columnLabel": "Étiquette", + "columnAddress": "Adresse", + "columnBalance": "Solde", + "columnNames": "Noms", + "columnCategory": "Catégorie", + "columnFirstSeen": "Vu la première fois", + "nameCount": "{{count, number}} nom", + "nameCount_plural": "{{count, number}} noms", + "nameCountEmpty": "Aucun nom", + "firstSeen": "Vu la première fois le {{date}}", + "firstSeenMobile": "Vue la première fois: <1 />", + + "walletCount": "{{count, number}} portefeuille", + "walletCount_plural": "{{count, number}} portefeuilles", + "walletCountEmpty": "Aucun portefeuille", + + "noWalletsHint": "Pas encore de portefeuille", + "noWalletsText": "Ajoutez ou créez un portefeuille en cliquant sur le menu <1 /> en haut à droite!", + + "actionsViewAddress": "Afficher l'adresse", + "actionsEditTooltip": "Éditer le portefeuille", + "actionsSendTransaction": "Envoyer des Tenebra", + "actionsWalletInfo": "Infos portefeuille", + "actionsDelete": "Enlever le portefeuille", + "actionsDeleteConfirm": "Voulez-vous vraiment enlever ce portefeuille?", + "actionsDeleteConfirmDescription": "Si vous n'avez pas sauvegardé ou enregistré son mot de passe, il sera perdu à jamais!", + + "tagDontSave": "Temp", + "tagDontSaveTooltip": "Portefeuille temporaire", + + "info": { + "title": "Infos portefeuille - {{address}}", + + "titleBasicInfo": "Information basique", + "id": "ID", + "label": "Étiquette", + "category": "Catégorie", + "username": "Identifiant", + "password": "Mot de passe", + "privatekey": "Clé privée", + "format": "Format", + + "titleSyncedInfo": "Information synchronizée", + "address": "Adresse", + "balance": "Solde", + "names": "Noms", + "firstSeen": "Vu la première fois", + "existsOnNetwork": "Existe sur le réseau", + "lastSynced": "Dernière synchronisation", + + "titleAdvancedInfo": "Information avancée", + "encPassword": "Mot de passe encrypté", + "encPrivatekey": "Clé privée encrypté", + "saved": "Enregistré", + + "revealLink": "Dévoiler", + "hideLink": "Masquer", + + "true": "Oui", + "false": "Non" + } + }, + + "addressBook": { + "title": "Carnet d'adresses", + + "contactCount": "{{count, number}} contact", + "contactCount_plural": "{{count, number}} contacts", + "contactCountEmpty": "No contacts", + + "buttonAddContact": "Ajouter un contact", + + "columnLabel": "Étiquette", + "columnAddress": "Adresse", + + "actionsViewAddress": "Afficher l'adresse", + "actionsViewName": "Afficher le nom", + "actionsEditTooltip": "Modifier le contact", + "actionsSendTransaction": "Envoyer Tenebra", + "actionsDelete": "Supprimer le contact", + "actionsDeleteConfirm": "Êtes-vous sûr de vouloir supprimer ce contact?" + }, + + "myTransactions": { + "title": "Transactions", + "searchPlaceholder": "Chercher une transaction...", + "columnFrom": "De", + "columnTo": "À", + "columnValue": "Montant", + "columnTime": "Quand" + }, + + "addWallet": { + "dialogTitle": "Ajouter un portefeuille", + "dialogTitleCreate": "Créer un portefeuille", + "dialogTitleEdit": "Éditer le portefeuille", + "dialogOkAdd": "Ajouter", + "dialogOkCreate": "Créer", + "dialogOkEdit": "Save", + "dialogAddExisting": "Ajouter un portefeuille existant", + + "walletLabel": "Étiquette", + "walletLabelPlaceholder": "Étiquette (optionnel)", + "walletLabelMaxLengthError": "Pas plus de 32 caractères", + "walletLabelWhitespaceError": "Ne doit pas être que des espaces", + + "walletCategory": "Catégorie", + "walletCategoryDropdownNone": "Sans catégorie", + "walletCategoryDropdownNew": "Nouvelle", + "walletCategoryDropdownNewPlaceholder": "Nom de catégorie", + + "walletAddress": "Adresse", + "walletUsername": "Nom d'utilisateur", + "walletUsernamePlaceholder": "Nom d'utilisateur", + "walletPassword": "Mot de passe", + "walletPasswordPlaceholder": "Mot de passe", + "walletPasswordWarning": "Assurez-vous de le sauvegarder à un endroit <1>sécurisé!", + "walletPasswordRegenerate": "Régénérer", + "walletPrivatekey": "Clé privée", + "walletPrivatekeyPlaceholder": "Clé privée", + + "advancedOptions": "Options avancées", + + "walletFormat": "Format", + "walletFormatTenebraWallet": "TenebraWallet, KWallet (recommandé)", + "walletFormatTenebraWalletUsernameAppendhashes": "KW-Username (appendhashes)", + "walletFormatTenebraWalletUsername": "KW-Username (pre-appendhashes)", + "walletFormatJwalelset": "jwalelset", + "walletFormatApi": "Raw/API (utilisateurs avancés)", + + "walletSave": "Enregistrer ce portefeuille dans TenebraWeb", + + "messageSuccessAdd": "Portefeuille ajouté avec succès!", + "messageSuccessCreate": "Portefeuille crée avec succès!", + "messageSuccessEdit": "Portefeuille sauvegardé avec succès!", + + "errorPasswordRequired": "Un mot de passe est requis.", + "errorPrivatekeyRequired": "Une clé privée est requise.", + "errorUnexpectedTitle": "Erreur inattendue", + "errorUnexpectedDescription": "Une erreur s'est produite lors de l'ajout du portefeuille. Voir console pour plus de détails.", + "errorUnexpectedEditDescription": "Une erreur s'est produite lors de la modification du portefeuille. Voir console pour plus de détails.", + "errorDuplicateWalletTitle": "Le portefeuille existe déjà", + "errorDuplicateWalletDescription": "Vous disposez déjà d'un portefeuille pour cette adresse.", + "errorMissingWalletTitle": "Portefeuille introuvable", + "errorMissingWalletDescription": "Le portefeuille que vous essayez de modifier n'existe plus.", + "errorDecryptTitle": "Mot de passe maître incorrect", + "errorDecryptDescription": "Impossible de décrypter le mot de passe portefeuille. Le mot de passe maître est-il correct?", + "errorWalletLimitTitle": "Limite de portefeuille atteint", + "errorWalletLimitDescription": "Vous pouvez actuellement pas ajouter plus de portefeuilles." + }, + + "addContact": { + "modalTitle": "Ajouter un contact", + "modalTitleEdit": "Modifier le contact", + + "buttonSubmit": "Ajouter", + "buttonSubmitEdit": "Sauvegarder", + + "contactLabel": "Étiquette", + "contactLabelPlaceholder": "Étiquette du contact (optional)", + "contactLabelMaxLengthError": "Pas plus de 32 caractères", + "contactLabelWhitespaceError": "Ne doit pas être que des espaces", + + "contactAddressLabel": "Adresse ou nom", + + "messageSuccessAdd": "Contact ajouté avec succès!", + "messageSuccessEdit": "Contact enregistré avec succès!", + + "errorDuplicateContactTitle": "Le contact existe déjà", + "errorDuplicateContactDescription": "Vous avez déjà un contact pour cette adresse.", + "errorMissingContactTitle": "Contact introuvable", + "errorMissingContactDescription": "Le contact que vous essayez de modifier n'existe plus.", + "errorContactLimitTitle": "Limite de contacts atteinte", + "errorContactLimitDescription": "Vous ne pouvez actuellement plus ajouter de contacts." + }, + + "dashboard": { + "siteTitle": "Tableau de bord", + + "inDevBanner": "Bienvenue dans la bêta privée de TenebraWeb v2! Ce site est encore en développement, donc la plupart des fonctionnalités sont actuellement manquantes. Veuillez signaler tous bogues sur <1>GitHub. Merci!", + + "walletOverviewCardTitle": "Portefeuilles", + "walletOverviewTotalBalance": "Solde total", + "walletOverviewNames": "Noms", + "walletOverviewNamesCount": "{{count, number}} nom", + "walletOverviewNamesCount_plural": "{{count, number}} noms", + "walletOverviewNamesCountEmpty": "Aucun nom", + "walletOverviewSeeMore": "Voir tous les {{count, number}}...", + "walletOverviewAddWallets": "Ajouter des portefeuilles...", + + "transactionsCardTitle": "Transactions", + "transactionsError": "Une erreur s'est produite lors de la récupération de vos transactions. Voir console pour plus de détails.", + + "blockValueCardTitle": "Valeur des blocs", + "blockValueBaseValue": "Valeur de base (<1>)", + "blockValueBaseValueNames": "{{count, number}} nom", + "blockValueBaseValueNames_plural": "{{count, number}} noms", + "blockValueNextDecrease": "Diminuera de <1> dans <3>{{count, number}} bloc", + "blockValueNextDecrease_plural": "Diminuera de <1> dans <3>{{count, number}} blocs", + "blockValueReset": "Réinitialisera dans <1>{{count, number}} bloc", + "blockValueReset_plural": "Réinitialisera dans <1>{{count, number}} blocs", + "blockValueEmptyDescription": "La valeur des blocs augmente lorsque des <1>noms sont achetés.", + + "blockDifficultyCardTitle": "Difficulté", + "blockDifficultyError": "Une erreur s'est produite lors de la récupération de difficulté. Voir console pour plus de détails.", + "blockDifficultyHashRate": "Approx. <1 />", + "blockDifficultyHashRateTooltip": "Taux de hachage estimé du réseau, basé sur la difficulté en cours.", + "blockDifficultyChartWork": "Difficulté", + "blockDifficultyChartLinear": "Linéaire", + "blockDifficultyChartLog": "Logarithmique", + + "motdCardTitle": "Message du jour", + "motdDebugMode": "Ce noeud de synchronisation est un serveur de développement non officiel. Les soldes et les transactions peuvent être manipulées. Procédez avec prudence.", + + "whatsNewCardTitle": "Quoi de neuf", + "whatsNewButton": "Quoi de neuf", + + "tipsCardTitle": "Astuce du jour", + "tipsPrevious": "Précédent", + "tipsNext": "Suivant", + "tips": { + "0": "Découvrez les nouveautés de Tenebra et TenebraWeb sur la page [Quoi de neuf](/whatsnew)!", + "1": "Vous pouvez naviguer rapidement dans les tableaux avec les touches fléchées.", + "2": "Vous pouvez cliquer sur les en-têtes de tableau pour les trier.", + "3": "Vous pouvez filtrer par catégories dans la page [Mes portefeuilles](/wallets) en cliquant sur l'icône de filtre dans l'en-tête du tableau.", + "4": "La page [Paramètres](/settings) propose de nombreuses options avancées pour personnaliser votre expérience avec TenebraWeb.", + "5": "Générez des liens de transaction pré-remplis avec la nouvelle page [Demander](/request).", + "6": "Assurez-vous de sauvegarder vos [portefeuilles](/wallets)!", + "7": "Recherchez rapidement le réseau Tenebra avec le raccourci clavier Ctrl+K (Cmd+K sur macOS).", + "8": "Ajoutez des contacts dans le [Carnet d'adresses](/contacts) pour leur envoyer rapidement des transactions.", + "9": "Une transaction 'bumped' est une transaction envoyée depuis et vers la même adresse.", + "10": "Le graphique de 'Difficulté' peut être affiché avec une échelle logarithmique pour voir les petits changements plus facilement à des difficultés plus faibles.", + "1-status": "Vous êtes connecté à un serveur non officiel. Les mots de passe de votre portefeuille peuvent être envoyés à l'opérateur, qui peut les utiliser pour accéder à vos portefeuilles sur le serveur officiel. Veuillez poser une question <1>ici pour plus d’informations.", + "11": "Le format de la date peut être modifié dans les [Paramètres avancés](/settings).", + "12": "Vous pouvez voir les [haches de blocs minés les plus bas](/network/blocks/lowest).", + "13": "Les noms les plus récemment achetés peuvent être consultés sur la page [Noms réseau](/network/names/new).", + "14": "La valeur des blocs augmente lorsque des [noms](/network/names) sont achetés.", + "15": "Si vous vous inquiétez de transactions accidentelles, vous pouvez activer une invite de confirmation dans les [Paramètres avancés](/settings)." + } + }, + + "credits": { + "title": "Credits", + "madeBy": "Fait par <1>{{authorName}}", + "hostedBy": "Hébergé <1>{{host}}", + "supportersTitle": "Supporteurs", + "supportersDescription": "Ce projet a été rendu possible par les formidables supporters suivants:", + "supportButton": "Supporter TenebraWeb", + "translatorsTitle": "Traducteurs", + "translatorsDescription": "Ce projet a été traduit par les formidables contributeurs suivants:", + "translateButton": "Traduire TenebraWeb", + "tmpim": "Created by tmpim", + + "versionInfo": { + "version": "Version", + "commitHash": "Commit", + "buildTime": "Build time", + "variant": "Build variant", + "license": "License" + }, + + "privacyTitle": "Vie privée", + "privacy": { + "tenebraServer": "Serveur Tenebra", + "tenebraServerDesc": "Les seules informations personnelles que le <1>serveur Tenebra stocke sont votre adresse IP, User-Agent et Origin, dans le cadre des journaux du serveur Web. Ces informations sont automatiquement purgées après 30 jours.", + "tenebraweb": "TenebraWeb", + "tenebrawebDesc1": "TenebraWeb utilise un serveur <1> Sentry auto-hébergé pour le rapport d'erreur automatique. Ce système stocke votre adresse IP, User-Agent, Origin, breadcrumbs et les détails des erreurs qui sont automatiquement signalées. Ces informations sont automatiquement purgées après 30 jours.", + "tenebrawebDesc2": "Si vous disposez d'une extension de blocage de publicités ou de blocage de traqueur telle que <1>uBlock Origin (recommandé), notre système Sentry est déjà bloqué par les listes intégrées, vous n'avez pas à vous soucier de votre vie privée. Vous pouvez également désactiver le signalement des erreurs dans les <4>paramètres. Cela dit, si vous souhaitez nous aider en fournissant des rapports d'erreur plus détaillés, veuillez envisager de faire une exception pour TenebraWeb dans votre logiciel de blocage de suivi. Ce site ne diffuse pas d'annonces.", + "tenebrawebDesc3": "Si vous avez des questions ou des préoccupations concernant votre vie privée, veuillez contacter les développeurs." + } + }, + + "settings": { + "siteTitle": "Paramètres", + "title": "Paramètres", + + "messageSuccess": "Paramètres modifiés avec succès!", + + "settingIntegerSave": "Sauvegarder", + + "menuLanguage": "Langage", + + "subMenuBackups": "Gérer les sauvegardes", + "importBackup": "Importer des portefeuilles", + "exportBackup": "Exporter des portefeuilles", + + "subMenuMasterPassword": "Mot de passe maître", + "changeMasterPassword": "Changer le mot de passe maître", + "resetMasterPassword": "Réinitialiser le mot de passe maître", + + "subMenuAutoRefresh": "Actualisation automatique", + "autoRefreshTables": "Actualisation automatique des listes", + "autoRefreshTablesDescription": "Si les grandes listes (ex. les transactions, les noms) doivent être actualisées automatiquement lorsqu'un changement est détecté sur le réseau.", + "autoRefreshAddressPage": "Actualisation automatique de la page d'adresses", + "autoRefreshNamePage": "Actualisation automatique de la page des noms", + + "subMenuAdvanced": "Paramètres avancés", + "alwaysIncludeMined": "Toujours inclure les transactions minées dans les listes de transactions (nécessite une actualisation)", + "copyNameSuffixes": "Inclure le suffixe lors de la copie des noms", + "addressCopyButtons": "Afficher un bouton de copie à côté de toutes les adresses", + "nameCopyButtons": "Afficher un bouton de copie à côté de tous les noms", + "blockHashCopyButtons": "Afficher un bouton de copie à côté de tous les hachages de bloc", + "showRelativeDates": "Afficher des dates relatives au lieu des dates absolues si elles sont récentes", + "showRelativeDatesDescription": "Si une date date de moins de 7 jours, elle s'affichera sous forme de date relative.", + "showNativeDates": "Afficher les dates dans un format de date natif à la langue", + "showNativeDatesDescription": "Si désactivée, les dates seront affichées sous la forme de AAAA/MM/JJ HH:mm:ss", + "transactionsHighlightOwn": "Mettez en surbrillance vos propres transactions dans les tableaux de transactions", + "transactionsHighlightVerified": "Mettez en surbrillance les adresses vérifiées dans les tableaux de transactions", + "transactionDefaultRaw": "Afficher l'onglet \"Raw\" au lieu de \"CommonMeta\" sur les pages de transactions", + "confirmTransactions": "Afficher une demande une confirmation pour toutes les transactions", + "clearTransactionForm": "Effacez le formulaire d'envoi de transaction après avoir cliqué sur 'Envoyer'", + "sendTransactionDelay": "Temps d'attente, en millisecondes, avant d'autoriser l'envoi d'une autre transaction", + "defaultPageSize": "Nombre d'entrées par défaut des listes", + "tableHotkeys": "Activer les raccourcis clavier de navigation dans les tableaux (flèches gauche et droite)", + + "subMenuPrivacy": "Vie privée", + "privacyInfo": "Informations sur la confidentialité", + "errorReporting": "Activer le rapport d'erreur automatique (nécessite une actualisation)", + "messageOnErrorReport": "Afficher une notification lorsqu'une erreur est automatiquement signalée (nécessite une actualisation)", + + "subMenuDebug": "Paramètres de débogage", + "advancedWalletFormats": "Formats de portefeuille avancés", + "menuTranslations": "Traductions", + + "subTitleTranslations": "Traductions", + + "translations": { + "errorMissingLanguages": "Le fichier languages.json semble être manquant. TenebraWeb a-t-il été compilé correctement?", + "errorNoKeys": "Aucune clé de traduction", + + "columnLanguageCode": "Code", + "columnLanguage": "Langage", + "columnKeys": "Clés", + "columnMissingKeys": "Clés manquantes", + "columnProgress": "Progrès", + + "tableUntranslatedKeys": "Clés non traduites", + "columnKey": "Clé", + "columnEnglishString": "Traduction anglaise", + + "importJSON": "Importer en JSON", + "exportCSV": "Exporter en CSV", + + "importedLanguageTitle": "Langue importée" + } + }, + + "breadcrumb": { + "dashboard": "Tableau de bord", + "wallets": "Portefeuilles", + + "settings": "Paramètres", + "settingsDebug": "Débogage", + "settingsTranslations": "Traductions" + }, + + "ws": { + "errorToken": "Une erreur s'est produite lors de la connexion websocket au serveur Tenebra.", + "errorWS": "Une erreur s'est produite lors de la connexion websocket au serveur Tenebra (code <1>{{code}})." + }, + + "rateLimitTitle": "Taux de débit dépassé", + "rateLimitDescription": "Trop de demandes ont été envoyées au serveur Tenebra dans un court lapse de temps. Ceci est probablement causé par un bug!", + + "address": { + "title": "Adresse", + + "walletLabel": "Étiquette:", + "walletCategory": "Catégorie:", + "contactLabel": "Contact:", + + "balance": "Solde actuel", + "names": "Noms", + "nameCount": "{{count, number}} nom", + "nameCount_plural": "{{count, number}} noms", + "nameCountEmpty": "Aucun nom", + "firstSeen": "Vu la première fois", + + "buttonSendTenebra": "Envoyez des Tenebra à {{address}}", + "buttonTransferTenebra": "Transférer des Tenebra à {{address}}", + "buttonAddContact": "Ajouter au carnet d'adresses", + "buttonEditContact": "Modifier dans le carnet d'adresses", + "buttonEditWallet": "Modifier le portefeuille", + + "tooltipV1Address": "Les transactions ne peuvent pas être envoyées aux adresses v1, car elles sont obsolètes.", + + "cardRecentTransactionsTitle": "Transactions récentes", + "cardNamesTitle": "Noms", + + "transactionsError": "Une erreur s'est produite lors de la récupération des transactions. Voir console pour plus de détails.", + "namesError": "Une erreur s'est produite lors de la récupération des noms. Voir console pour plus de détails.", + + "namePurchased": "Acheté <1 />", + "nameReceived": "Reçu <1 />", + "namesSeeMore": "Voir tous les {{count, number}}...", + + "resultInvalidTitle": "Adresse invalide", + "resultInvalid": "Cela ne ressemble pas à une adresse Tenebra valide.", + "resultNotFoundTitle": "Adresse introuvable", + "resultNotFound": "Cette adresse n'a pas encore été initialisée sur le réseau Tenebra.", + + "verifiedCardTitle": "Adresse vérifiée", + "verifiedInactive": "Ce service n'est pas actif actuellement.", + "verifiedWebsiteButton": "Visiter le site web" + }, + + "transactionSummary": { + "itemID": "ID de transaction: {{id}}", + "itemFrom": "<0>De: <1 />", + "itemTo": "<0>À: <1 />", + "itemName": "<0>Nom: <1 />", + "itemARecord": "<0>Enregistrement A: <1 />", + "itemARecordRemoved": "(supprimé)", + "seeMore": "Voir tous les {{count, number}}..." + }, + + "transactions": { + "title": "Transactions réseau", + "myTransactionsTitle": "Mes transactions", + "nameHistoryTitle": "Historique du nom", + "nameTransactionsTitle": "Transactions de nom", + "searchTitle": "Recherche de transactions", + + "siteTitleWallets": "Mes transactions", + "siteTitleNetworkAll": "Transactions réseau", + "siteTitleNetworkAddress": "Transactions de {{address}}", + "siteTitleNameHistory": "Historique du nom", + "siteTitleNameSent": "Transactions de nom", + "siteTitleSearch": "Recherche de transactions", + + "subTitleSearchAddress": "Impliquant {{address}}", + "subTitleSearchName": "Impliquant {{name}}", + "subTitleSearchMetadata": "Avec métadonnées '{{query}}'", + + "columnID": "ID", + "columnType": "Type", + "columnFrom": "De", + "columnTo": "À", + "columnValue": "Valeur", + "columnName": "Nom", + "columnMetadata": "Métadonnées", + "columnTime": "Quand", + + "tableTotal": "{{count, number}} item", + "tableTotal_plural": "{{count, number}} items", + "tableTotalEmpty": "Aucun item", + + "includeMined": "Inclure les transactions minées", + + "resultInvalidTitle": "Adresse invalide", + "resultInvalid": "Cela ne ressemble pas à une adresse Tenebra valide.", + + "types": { + "transferred": "Transféré", + "sent": "Envoyé", + "received": "Reçu", + "mined": "Minée", + "name_a_record": "Nom mis à jour", + "name_transferred": "Nom déplacé", + "name_sent": "Nom envoyé", + "name_received": "Nom reçu", + "name_purchased": "Nom acheté", + "bumped": "Bumped", + "unknown": "Inconnue" + } + }, + + "names": { + "titleWallets": "Mes noms", + "titleNetworkAll": "Noms réseau", + "titleNetworkAddress": "Noms réseau", + + "siteTitleWallets": "Mes noms", + "siteTitleNetworkAll": "Noms réseau", + "siteTitleNetworkAddress": "Noms de {{address}}", + + "columnName": "Nom", + "columnOwner": "Propriétaire", + "columnOriginalOwner": "Propriétaire d'origine", + "columnRegistered": "Inscrit", + "columnUpdated": "Mis à jour", + "columnARecord": "Enregistrement A", + "columnUnpaid": "Blocs impayés", + + "mobileOwner": "<0>Propriétaire: <1 />", + "mobileOriginalOwner": "<0>Propriétaire d'origine: <1 />", + "mobileRegistered": "Inscrit: <1 />", + "mobileUpdated": "Mis à jour: <1 />", + "mobileARecordTag": "A", + "mobileUnpaidTag": "{{count, number}} bloc", + "mobileUnpaidTag_plural": "{{count, number}} blocs", + + "actions": "Actions", + "actionsViewName": "Afficher le nom", + "actionsViewOwner": "Afficher l'adresse du propriétaire", + "actionsViewOriginalOwner": "Afficher l'adresse du propriétaire d'origine", + "actionsSendTenebra": "Envoyer Tenebra", + "actionsTransferTenebra": "Transférer Tenebra", + "actionsUpdateARecord": "Changer l'enregistrement A", + "actionsTransferName": "Transférer le nom", + + "tableTotal": "{{count, number}} nom", + "tableTotal_plural": "{{count, number}} noms", + "tableTotalEmpty": "Aucun nom", + + "resultInvalidTitle": "Adresse invalide", + "resultInvalid": "Cela ne ressemble pas à une adresse Tenebra valide.", + + "purchaseButton": "Nom d'achat" + }, + + "name": { + "title": "Nom", + + "buttonSendTenebra": "Envoyez des Tenebra à {{name}}", + "buttonTransferTenebra": "Transférer des Tenebra à {{name}}", + "buttonARecord": "Changer l'enregistrement A", + "buttonTransferName": "Transférer le nom", + + "owner": "Appartient à", + "originalOwner": "Acheté par", + "registered": "Inscrit", + "updated": "Dernière mise à jour", + "unpaid": "Blocs impayés", + "unpaidCount": "{{count, number}} bloc", + "unpaidCount_plural": "{{count, number}} blocs", + "aRecord": "Enregistrement A", + "aRecordEditTooltip": "Changer l'enregistrement A", + + "cardRecentTransactionsTitle": "Transactions récentes", + "cardHistoryTitle": "Historique du nom", + "transactionsError": "Une erreur s'est produite lors de la récupération des transactions. Voir console pour plus de détails.", + "historyError": "Une erreur s'est produite lors de la récupération de l'historique du nom. Voir console pour plus de détails.", + + "resultInvalidTitle": "Nom invalide", + "resultInvalid": "Cela ne ressemble pas à un nom Tenebra valide.", + "resultNotFoundTitle": "Nom introuvable", + "resultNotFound": "Ce nom n'existe pas." + }, + + "blocks": { + "title": "Blocs réseau", + "titleLowest": "Blocs les plus bas", + "siteTitle": "Blocs réseau", + "siteTitleLowest": "Blocs les plus bas", + + "columnHeight": "Hauteur", + "columnAddress": "Mineur", + "columnHash": "Hache du bloc", + "columnValue": "Valeur", + "columnDifficulty": "Difficulté", + "columnTime": "Quand", + + "mobileHeight": "Bloc #{{height, number}}", + "mobileMiner": "<0>Mineur: <1 />", + "mobileHash": "<0>Hache: <1 />", + "mobileDifficulty": "<0>Difficulté: <1 />", + + "tableTotal": "{{count, number}} bloc", + "tableTotal_plural": "{{count, number}} blocs", + "tableTotalEmpty": "Aucun bloc" + }, + + "block": { + "title": "Bloc", + "siteTitle": "Bloc", + "siteTitleBlock": "Bloc #{{id, number}}", + "subTitleBlock": "#{{id, number}}", + + "height": "Hauteur", + "miner": "Mineur", + "value": "Valeur", + "time": "Quand", + "hash": "Hache", + "difficulty": "Difficulté", + + "previous": "Précédent", + "previousTooltip": "Bloc précédent (#{{id, number}})", + "previousTooltipNone": "Bloc précédent", + "next": "Suivant", + "nextTooltip": "Bloc Suivant (#{{id, number}})", + "nextTooltipNone": "Bloc Suivant", + + "resultInvalidTitle": "Hauteur de bloc invalide", + "resultInvalid": "Cela ne ressemble pas à une hauteur de bloc valide.", + "resultNotFoundTitle": "Bloc introuvable", + "resultNotFound": "Ce bloc n'existe pas." + }, + + "transaction": { + "title": "Transaction", + "siteTitle": "Transaction", + "siteTitleTransaction": "Transaction #{{id, number}}", + "subTitleTransaction": "#{{id, number}}", + + "type": "Type", + "from": "De", + "to": "À", + "address": "Adresse", + "name": "Nom", + "value": "Valeur", + "time": "Quand", + "aRecord": "Enregistrement A", + + "cardMetadataTitle": "Métadonnées", + "tabCommonMeta": "CommonMeta", + "tabRaw": "Raw", + + "commonMetaError": "L'analyse CommonMeta a échoué.", + "commonMetaParsed": "Enregistrements analysés", + "commonMetaParsedHelp": "Ces valeurs ne sont pas directement contenues dans les métadonnées des transactions, mais ils ont été déduits par l'analyseur CommonMeta.", + "commonMetaCustom": "Registres de transaction", + "commonMetaCustomHelp": "Ces valeurs étaient directement contenues dans les métadonnées de la transaction.", + "commonMetaColumnKey": "Clé", + "commonMetaColumnValue": "Valeur", + + "cardRawDataTitle": "Données brutes", + "cardRawDataHelp": "La transaction exactement telle qu'elle a été renvoyée par l'API Tenebra.", + + "rawDataColumnKey": "Clé", + "rawDataColumnValue": "Valeur", + + "resultInvalidTitle": "ID de transaction non valide", + "resultInvalid": "Cela ne ressemble pas à un ID de transaction valide.", + "resultNotFoundTitle": "Transaction introuvable", + "resultNotFound": "Cette transaction n'existe pas." + }, + + "apiErrorResult": { + "resultUnknownTitle": "Erreur inconnue", + "resultUnknown": "Voir console pour plus de détails." + }, + + "noWalletsResult": { + "title": "Aucun portefeuille", + "subTitle": "Vous n'avez actuellement aucun portefeuille enregistré dans TenebraWeb, il n'y a donc rien à voir ici pour le moment. Souhaitez-vous ajouter un portefeuille?", + "subTitleSendTransaction": "Vous n'avez actuellement aucun portefeuille enregistré dans TenebraWeb, vous ne pouvez donc pas encore effectuer de transaction. Souhaitez-vous ajouter un portefeuille?", + "button": "Ajouter des portefeuilles", + "buttonNetworkTransactions": "Transactions réseau", + "buttonNetworkNames": "Noms de réseaux" + }, + + "backups": { + "importButton": "Importer une sauvegarde", + "exportButton": "Exporter une sauvegarde" + }, + + "import": { + "description": "Collez le code de sauvegarde (ou importez à partir d'un fichier ci-dessous) et entrez le mot de passe principal correspondant. Les sauvegardes de TenebraWeb v1 sont également prises en charge.", + + "masterPasswordPlaceholder": "Mot de passe maître", + "masterPasswordRequired": "Mot de passe maître est requis.", + "masterPasswordIncorrect": "Le mot de passe maître est incorrect.", + + "appMasterPasswordRequired": "Vous devez être authentifié pour importer des portefeuilles.", + + "fromFileButton": "Importer depuis un fichier", + "textareaPlaceholder": "Collez le code de sauvegarde ici", + "textareaRequired": "Un code de sauvegarde est requis.", + "fileErrorTitle": "Erreur d'importation", + "fileErrorNotText": "Le fichier importé doit être un fichier texte.", + + "overwriteCheckboxLabel": "Mettre à jour les étiquettes de portefeuille existantes en cas de conflit", + + "modalTitle": "Importer une sauvegarde", + "modalButton": "Importer", + + "detectedFormat": "<0>Format détecté: <2 />", + "detectedFormatTenebraWebV1": "TenebraWeb v1", + "detectedFormatTenebraWebV2": "TenebraWeb v2", + "detectedFormatInvalid": "Invalide!", + + "progress": "Importation de <1>{{count, number}} item...", + "progress_plural": "Importation de <1>{{count, number}} items...", + + "decodeErrors": { + "atob": "La sauvegarde n'a pas pu être décodée car elle n'est pas valide en base64!", + "json": "La sauvegarde n'a pas pu être décodée car elle n'est pas valide en JSON!", + "missingTester": "La sauvegarde n'a pas pu être décodée car il manque une clé de testeur!", + "missingSalt": "La sauvegarde n'a pas pu être décodée car il manque une clé de sel!", + "invalidTester": "La sauvegarde n'a pas pu être décodée car la clé 'tester' est du mauvais type!", + "invalidSalt": "La sauvegarde n'a pas pu être décodée car la clé 'salt' est du mauvais type!", + "invalidWallets": "La sauvegarde n'a pas pu être décodée car la clé 'wallets' est du mauvais type!", + "invalidFriends": "La sauvegarde n'a pas pu être décodée car la clé 'friends' est du mauvais type!", + "invalidContacts": "La sauvegarde n'a pas pu être décodée car la clé 'contacts' est du mauvais type!", + "unknown": "La sauvegarde n'a pas pu être décodée en raison d'une erreur inconnue. Voir console pour plus de détails." + }, + + "walletMessages": { + "success": "Portefeuille importé avec succès.", + "successSkipped": "Un portefeuille avec la même adresse ({{address}}) et les mêmes paramètres existe déjà, il a donc été ignoré.", + "successUpdated": "Un portefeuille avec la même adresse ({{address}}) existe déjà. Son étiquette a été mis à jour en \"{{label}}\"", + "successSkippedNoOverwrite": "Un portefeuille avec la même adresse ({{address}}) existe déjà et vous avez choisi de ne pas écraser son étiquette, il a donc été ignoré.", + "successImportSkipped": "Un portefeuille avec la même adresse ({{address}}) a déjà été importé, il a donc été ignoré.", + + "warningSyncNode": "Ce portefeuille avait un noeud de synchronisation personnalisé, qui n'est pas pris en charge dans TenebraWeb v2. Le noeud de synchronisation a été ignoré.", + "warningIcon": "Ce portefeuille avait une icône personnalisée, qui n'est pas prise en charge dans TenebraWeb v2. L'icône a été ignorée.", + "warningLabelInvalid": "L'étiquette de ce portefeuille était invalide. L'étiquette a été ignorée.", + "warningCategoryInvalid": "La catégorie de ce portefeuille n'était pas valide. La catégorie a été ignorée.", + "warningAdvancedFormat": "Ce portefeuille utilise un format avancé ({{format}}), qui a un soutien limité dans TenebraWeb v2.", + + "errorInvalidTypeString": "Ce portefeuille n'est pas une chaîne de caractères!", + "errorInvalidTypeObject": "Ce portefeuille n'est pas un objet!", + "errorDecrypt": "Ce portefeuille n'a pas pu être déchiffré!", + "errorPasswordDecrypt": "Le mot de passe de ce portefeuille n'a pas pu être déchiffré!", + "errorDataJSON": "Les données déchiffrées n'étaient pas du JSON valide!", + "errorUnknownFormat": "Ce portefeuille utilise un format inconnu ou non pris en charge!", + "errorFormatMissing": "Ce portefeuille n'a pas de format!", + "errorUsernameMissing": "Ce portefeuille n'a pas de nom d'utilisateur!", + "errorPasswordMissing": "Ce portefeuille n'a pas de mot de passe!", + "errorPrivateKeyMissing": "Ce portefeuille manque une clé privée!", + "errorMasterKeyMissing": "Ce portefeuille manque une clé maîtresse!", + "errorPrivateKeyMismatch": "Le mot de passe de ce portefeuille ne correspondait pas à sa clé privée stockée!", + "errorMasterKeyMismatch": "Le mot de passe de ce portefeuille ne correspondait pas à sa clé principale stockée!", + "errorLimitReached": "Vous avez atteint la limite de portefeuille. Vous pouvez actuellement pas ajouter plus de portefeuilles.", + "errorUnknown": "Une erreur inconnue s'est produite. Voir console pour plus de détails." + }, + + "contactMessages": { + "success": "Contact importé avec succès.", + "successSkipped": "Un contact avec la même adresse ({{address}}) et les mêmes paramètres existe déjà, il a donc été ignoré.", + "successUpdated": "Un contact avec la même adresse ({{address}}) existe déjà. Son étiquette a été mis à jour en \"{{label}}\"", + "successSkippedNoOverwrite": "Un contact avec la même adresse ({{address}}) existe déjà, et vous avez choisi de ne pas écraser le libellé, il a donc été ignoré.", + "successImportSkipped": "Un contact avec la même adresse ({{address}}) a déjà été importé, il a donc été ignoré.", + + "warningSyncNode": "Ce contact avait un noeud de synchronisation personnalisé, qui n'est pas pris en charge dans TenebraWeb v2. Le noeud de synchronisation a été ignoré.", + "warningIcon": "Ce contact avait une icône personnalisée, qui n'est pas prise en charge dans TenebraWeb v2. L'icône a été ignorée.", + "warningLabelInvalid": "Le étiquette de ce contact n’était pas valide. L'étiquette a été ignorée.", + + "errorInvalidTypeString": "Ce contact n'était pas une chaîne de caractères!", + "errorInvalidTypeObject": "Ce contact n'était pas un objet!", + "errorDecrypt": "Ce contact n'a pas pu être déchiffré!", + "errorDataJSON": "Les données déchiffrées n'étaient pas du JSON valide!", + "errorAddressMissing": "Ce contact manque une adresse!", + "errorAddressInvalid": "L'adresse de ce contact n'est pas valide!", + "errorLimitReached": "Vous avez atteint la limite de contacts. Vous ne pouvez actuellement plus ajouter de contacts.", + "errorUnknown": "Une erreur inconnue est survenue. Voir console pour plus de détails." + }, + + "results": { + "noneImported": "Aucun nouveau portefeuille n'a été importé.", + + "walletsImported": "<0>{{count, number}} nouveau portefeuille a été importé.", + "walletsImported_plural": "<0>{{count, number}} nouveaux portefeuilles ont été importés.", + "walletsSkipped": "{{count, number}} portefeuille a été ignoré.", + "walletsSkipped_plural": "{{count, number}} portefeuilles ont été ignorés.", + + "contactsImported": "<0>{{count, number}} nouvel contacte a été importé.", + "contactsImported_plural": "<0>{{count, number}} nouveaux contactes ont été importés.", + "contactsSkipped": "{{count, number}} contacte a été ignoré.", + "contactsSkipped_plural": "{{count, number}} contactes ont été ignorés.", + + "warnings": "Il y a eu <1>{{count, number}} avertissement lors de l'importation de votre sauvegarde.", + "warnings_plural": "Il y a eu <1>{{count, number}} avertissements lors de l'importation de votre sauvegarde.", + "errors": "Il y a eu <1>{{count, number}} erreur lors de l'importation de votre sauvegarde.", + "errors_plural": "Il y a eu <1>{{count, number}} erreurs lors de l'importation de votre sauvegarde.", + + "treeHeaderWallets": "Portefeuilles", + "treeHeaderContacts": "Contactes", + + "treeWallet": "Portefeuille {{id}}", + "treeContact": "Contacte {{id}}" + } + }, + + "export": { + "modalTitle": "Exporter la sauvegarde", + + "description": "Ce code secret contient vos portefeuilles et les contacts de votre carnet d'adresses. Vous pouvez l'utiliser pour les importer dans un autre navigateur ou pour les sauvegarder. Vous aurez toujours besoin de votre mot de passe maître pour importer les portefeuilles à l'avenir. <1>Ne partagez ce code avec personne.", + "size": "Taille: <1 />", + + "buttonSave": "Enregistrer dans un fichier", + "buttonCopy": "Copier dans le presse-papier" + }, + + "walletLimitMessage": "Vous avez plus de portefeuilles stockés que ne le prend en charge TenebraWeb. Cela a été soit causé par un bogue, soit vous l'avez contourné intentionnellement. Attendez-vous à des problèmes de synchronisation.", + "contactLimitMessage": "Vous avez plus de contacts stockés que ne le prend en charge TenebraWeb. Cela a été soit causé par un bogue, soit vous l'avez contourné intentionnellement. Attendez-vous à des problèmes de synchronisation.", + + "optionalFieldUnset": "(unset)", + + "addressPicker": { + "placeholder": "Choisissez un destinataire", + "placeholderWalletsOnly": "Choisissez un portefeuille", + "placeholderNoWallets": "Adresse ou nom", + "placeholderNoWalletsNoNames": "Adresse", + + "hintCurrentBalance": "Solde actuel: <1 />", + + "errorAddressRequired": "L'adresse est requise.", + "errorRecipientRequired": "Le destinataire est requis.", + "errorWalletRequired": "Le portefeuille est requis.", + + "errorInvalidAddress": "Adresse ou nom invalide.", + "errorInvalidAddressOnly": "Adresse invalide.", + "errorInvalidRecipient": "Destinataire non valide. Doit être une adresse ou un nom.", + "errorInvalidWalletsOnly": "Adresse de portefeuille non valide.", + "errorEqual": "Le destinataire ne peut pas être le même que l'expéditeur.", + + "categoryWallets": "Portefeuilles", + "categoryOtherWallets": "Autres portefeuilles", + "categoryAddressBook": "Carnet d'adresses", + "categoryExactAddress": "Adresse exacte", + "categoryExactName": "Nom exact", + + "addressHint": "Solde: <1 />", + "addressHintWithNames": "Noms: <1>{{names, number}}", + "nameHint": "Propriétaire: <1 />", + "nameHintNotFound": "Nom introuvable.", + "walletHint": "Portefeuille: <1 />" + }, + + "sendTransaction": { + "title": "Envoyer la transaction", + "siteTitle": "Envoyer la transaction", + + "modalTitle": "Envoyer la transaction", + "modalSubmit": "Envoyer", + + "buttonSubmit": "Envoyer", + + "labelFrom": "Du portefeuille", + "labelTo": "À l'adresse/nom", + "labelAmount": "Montant", + "labelMetadata": "Métadonnées", + "placeholderMetadata": "Métadonnées optionnelles", + + "buttonMax": "Max", + + "errorAmountRequired": "Le montant est requis.", + "errorAmountNumber": "Le montant doit être un nombre.", + "errorAmountTooLow": "Le montant doit être d'au moins 1.", + "errorAmountTooHigh": "Fonds insuffisants dans le portefeuille.", + + "errorMetadataTooLong": "Les métadonnées doivent comporter moins de 256 caractères.", + "errorMetadataInvalid": "Les métadonnées contiennent des caractères non valides.", + + "errorWalletGone": "Ce portefeuille n'existe plus.", + "errorWalletDecrypt": "Votre portefeuille n'a pas pu être déchiffré.", + + "errorParameterTo": "Destinataire non valide.", + "errorParameterAmount": "Montant invalide.", + "errorParameterMetadata": "Métadonnées non valides.", + "errorInsufficientFunds": "Fonds insuffisants dans le portefeuille.", + "errorNameNotFound": "Le nom du destinataire est introuvable.", + + "errorUnknown": "Erreur inconnue lors de l'envoi de la transaction. Voir console pour plus de détails.", + + "payLargeConfirm": "Êtes-vous sûr de vouloir envoyer <1 />?", + "payLargeConfirmHalf": "Voulez-vous vraiment en envoyer <1 />? C'est plus de la moitié de votre solde!", + "payLargeConfirmAll": "Voulez-vous vraiment envoyer <1 />? Ceci est votre solde en entier!", + "payLargeConfirmDefault": "Vous êtes sur le point d'envoyer votre clé privée et votre mot de passe de portefeuille à un serveur Tenebra non officiel, qui leur donnera accès à votre Tenebra sur le serveur officiel. Etes-vous sûr de vouloir faire ça?", + + "errorNotificationTitle": "Échec de la transaction", + "successNotificationTitle": "Transaction réussie", + "successNotificationContent": "Vous avez envoyé <1 /> de <3 /> à <5 />.", + "successNotificationButton": "Afficher la transaction", + + "errorInvalidQuery": "Les paramètres de requête n'étaient pas valides, ils ont été ignorés." + }, + + "request": { + "title": "Demander des Tenebra", + "siteTitle": "Demander des Tenebra", + + "labelTo": "Destinataire de la demande", + "labelAmount": "Montant demandé", + "labelMetadata": "Métadonnées de la demande", + "placeholderMetadata": "Métadonnées", + + "generatedLink": "Générer le lien", + "generatedLinkHint": "Envoyez ce lien à quelqu'un pour lui demander un paiement." + }, + + "authFailed": { + "title": "L'authentification a échoué", + "message": "Cette adresse ne vous appartient pas.", + "messageLocked": "Cette adresse a été verrouillée.", + "alert": "Message du noeud de synchronisation:" + }, + + "whatsNew": { + "title": "Quoi de neuf", + "siteTitle": "Quoi de neuf", + + "titleTenebra": "Tenebra", + "titleTenebraWeb": "TenebraWeb", + + "tooltipGitHub": "Voir sur GitHub", + + "cardWhatsNewTitle": "Quoi de neuf", + "cardCommitsTitle": "Commits", + "cardCommitsSeeMore": "Voir plus", + + "new": "Nouveau!" + }, + + "namePicker": { + "placeholder": "Choisissez un nom", + "placeholderMultiple": "Choisissez les noms", + + "buttonAll": "Tout", + + "warningTotalLimit": "Vous semblez avoir plus de 1 000 noms, ce qui n'est pas encore pris en charge dans TenebraWeb v2. Veuillez publier un problème sur GitHub.", + "errorLookup": "Une erreur s'est produite lors de la récupération des noms. Voir console pour plus de détails." + }, + + "nameTransfer": { + "modalTitle": "Transférer des noms", + + "labelNames": "Noms", + "labelRecipient": "Destinataire", + + "buttonSubmit": "Transférer les noms", + + "errorNameRequired": "Au moins un nom est requis.", + + "errorWalletGone": "Ce portefeuille n'existe plus.", + "errorWalletDecrypt": "Le portefeuille \"{{address}}\" n'a pas pu être déchiffré.", + "errorParameterNames": "Noms invalides.", + "errorParameterRecipient": "Destinataire invalide.", + "errorNameNotFound": "Un ou plusieurs noms sont introuvables.", + "errorNotNameOwner": "Vous n'êtes pas propriétaire d'un ou plusieurs noms.", + "errorUnknown": "Erreur inconnue lors du transfert des noms. Voir console pour plus de détails.", + "errorNotificationTitle": "Le transfert de nom a échoué", + + "warningMultipleNames": "Voulez-vous vraiment transférer <1>{{count, number}} nom vers <3 />?", + "warningMultipleNames_plural": "Voulez-vous vraiment transférer <1>{{count, number}} noms vers <3 />?", + "warningAllNames": "Voulez-vous vraiment transférer <1>{{count, number}} nom vers <3 />? Ce sont tous vos noms!", + "warningAllNames_plural": "Voulez-vous vraiment transférer <1>{{count, number}} noms vers <3 />? Ce sont tous vos noms!", + + "successMessage": "Nom transféré avec succès", + "successMessage_plural": "Noms transférés avec succès", + "successDescription": "<1>{{count, number}} nom transféré à <3 />.", + "successDescription_plural": "<1>{{count, number}} noms transférés à <3 />.", + + "progress": "Transfert de <1>{{count, number}} nom...", + "progress_plural": "Transfert de <1>{{count, number}} noms..." + }, + + "nameUpdate": { + "modalTitle": "Mettre à jour les noms", + + "labelNames": "Noms", + "labelARecord": "Enregistrement A", + "placeholderARecord": "Enregistrement A (optionnel)", + + "buttonSubmit": "Mettre à jour", + + "errorNameRequired": "Au moins un nom est requis.", + + "errorWalletGone": "Ce portefeuille n'existe plus.", + "errorWalletDecrypt": "Le portefeuille \"{{address}}\" n'a pas pu être déchiffré.", + "errorParameterNames": "Noms invalides.", + "errorParameterARecord": "Enregistrement A non valide.", + "errorNameNotFound": "Un ou plusieurs noms sont introuvables.", + "errorNotNameOwner": "Vous n'êtes pas propriétaire d'un ou plusieurs noms.", + "errorUnknown": "Erreur inconnue lors de la mise à jour des noms. Voir console pour plus de détails.", + "errorNotificationTitle": "Le transfert de nom a échoué", + + "successMessage": "Nom mis à jour avec succès", + "successMessage_plural": "Noms mis à jour avec succès", + "successDescription": "<1>{{count, number}} nom mis à jour.", + "successDescription_plural": "<1>{{count, number}} noms mis à jour.", + + "progress": "Mise à jour de <1>{{count, number}} nom...", + "progress_plural": "Mise à jour de <1>{{count, number}} noms..." + }, + + "noNamesResult": { + "title": "Pas encore de noms", + "subTitle": "Vous n'avez actuellement aucun nom dans aucun de vos portefeuilles enregistré dans TenebraWeb, il n'y a donc rien à voir ici pour le moment. Souhaitez-vous acheter un nom?", + "button": "Acheter un nom" + }, + + "namePurchase": { + "modalTitle": "Acheter un nom", + + "nameCost": "Coût d'achat: <1 />", + + "labelWallet": "Portefeuille", + "labelName": "Nom", + "placeholderName": "Nom", + + "buttonSubmit": "Acheter (<1 />)", + + "errorNameRequired": "Le nom est requis.", + "errorInvalidName": "Nom invalide.", + "errorNameTooLong": "Le nom est trop long.", + "errorNameTaken": "Ce nom est déjà pris!", + "errorInsufficientFunds": "Votre portefeuille ne dispose pas de suffisamment de fonds pour acheter un nom.", + + "errorWalletGone": "Ce portefeuille n'existe plus.", + "errorWalletDecrypt": "Le portefeuille \"{{address}}\" n'a pas pu être déchiffré.", + "errorUnknown": "Erreur inconnue lors de l'achat d'un nom. Voir console pour plus de détails.", + "errorNotificationTitle": "L'achat du nom a échoué", + + "successMessage": "Nom acheté avec succès", + "successNotificationButton": "Afficher le nom", + + "nameAvailable": "Le nom est disponible!" + }, + + "purchaseTenebra": { + "modalTitle": "Acheter des Tenebra", + "connection": "Une connexion vient d'être établie avec un serveur Tenebra non officiel. Vos mots de passe et vos portefeuilles Tenebra sont en danger." + }, + + "syncWallets": { + "errorMessage": "Erreur lors de la synchronisation des portefeuilles", + "errorDescription": "Une erreur s'est produite lors de la synchronisation de vos portefeuilles. Voir console pour plus de détails." + }, + + "legacyMigration": { + "modalTitle": "Migration de TenebraWeb v1", + "description": "Bienvenue sur TenebraWeb v2! Il semble que vous ayez déjà utilisé TenebraWeb v1 sur ce domaine.

Veuillez saisir votre mot de passe maître pour migrer vos portefeuilles vers le nouveau format. Vous n'aurez à le faire qu'une seule fois.", + + "walletCount": "<1>{{count, number}} portefeuille détecté", + "walletCount_plural": "<1>{{count, number}} portefeuilles détectés", + "contactCount": "<1>{{count, number}} contact détecté", + "contactCount_plural": "<1>{{count, number}} contacts détectés", + + "masterPasswordLabel": "Mot de passe maître", + "masterPasswordPlaceholder": "Mot de passe maître", + + "errorPasswordRequired": "Mot de passe requis.", + "errorPasswordLength": "Doit contenir au moins 1 caractère.", + "errorPasswordIncorrect": "Mot de passe incorrect.", + "errorUnknown": "Une erreur inconnue est survenue. Voir console pour plus de détails.", + + "buttonForgotPassword": "Mot de passe oublié", + "buttonSubmit": "Commencer la migration", + + "forgotPassword": { + "modalTitle": "Ignorer la migration depuis la v1", + "modalContent": "Si vous avez oublié votre mot de passe principal pour TenebraWeb v1, vous ne pourrez pas migrer vos portefeuilles. On ne vous demandera plus jamais. Voulez-vous vraiment ignorer la migration?", + "buttonSkip": "Ignorer" + } + }, + + "sortModal": { + "title": "Trier les résultats", + + "sortBy": "Trier par", + "sortOrder": "Ordre de tri", + "sortAscending": "Ascendant", + "sortDescending": "Descendant", + + "buttonReset": "Réinitialiser", + + "options": { + "transactionsFrom": "De", + "transactionsTo": "À", + "transactionsValue": "Valeur", + "transactionsName": "Nom", + "transactionsTime": "Quand", + + "namesName": "Nom", + "namesOwner": "Propriétaire", + "namesOriginalOwner": "Propriétaire d'origine", + "namesARecord": "Enregistrement A", + "namesUnpaid": "Blocs impayés", + "namesRegistered": "Date d'enregistrement", + "namesUpdated": "Date de mise à jour", + + "blocksMiner": "Mineur", + "blocksHash": "Hache", + "blocksValue": "Valeur", + "blocksDifficulty": "Difficulté", + "blocksTime": "Quand" + } + } +} diff --git a/public/locales/nl.json b/public/locales/nl.json new file mode 100644 index 0000000..1b26e4f --- /dev/null +++ b/public/locales/nl.json @@ -0,0 +1,1238 @@ +{ + "app": { + "name": "TenebraWeb" + }, + + "nav": { + "connection": { + "online": "Online", + "offline": "Offline", + "connecting": "Verbinden" + }, + + "search": { + "placeholder": "Doorzoek het Tenebra netwerk", + "placeholderShortcut": "Doorzoek het Tenebra netwerk ({{shortcut}})", + "placeholderShort": "Zoeken...", + "rateLimitHit": "Niet zo snel alsjeblieft.", + "noResults": "Geen resultaten.", + + "resultAddress": "Adres", + "resultName": "Naam", + "resultNameOwner": "Eigendom van <1 />", + "resultBlockID": "Blok ID", + "resultBlockIDMinedBy": "Gemijnd door <1 />", + "resultTransactionID": "Transactie ID", + "resultTransactions": "Transacties", + "resultTransactionsAddress": "Zoeken naar transacties betreffende <1 />", + "resultTransactionsAddressResult": "<0>{{count, number}} transactie betreffende <2 />", + "resultTransactionsAddressResult_plural": "<0>{{count, number}} transacties betreffende <2 />", + "resultTransactionsAddressResultEmpty": "Geen transacties betreffende <1 />", + "resultTransactionsName": "Zoeken naar transacties betreffende <1 />", + "resultTransactionsNameResult": "<0>{{count, number}} transactie verzonden naar <2 />", + "resultTransactionsNameResult_plural": "<0>{{count, number}} transacties verzonden naar <2 />", + "resultTransactionsNameResultEmpty": "Geen transacties verzonden naar <1 />", + "resultTransactionsMetadata": "Zoeken naar metagegevens betreffende <1 />", + "resultTransactionsMetadataResult": "<0>{{count, number}} transactie betreffende metagegevens <2 />", + "resultTransactionsMetadataResult_plural": "<0>{{count, number}} transacties betreffende metagegevens <2 />", + "resultTransactionsMetadataResultEmpty": "Geen transacties betreffende metagegevens <1 />" + }, + + "send": "Verzenden", + "sendLong": "Tenebra verzenden", + "request": "Ontvangen", + "requestLong": "Tenebra ontvangen", + "sort": "Sorteer resultaten", + + "settings": "Instellingen", + "more": "Meer" + }, + + "sidebar": { + "totalBalance": "Totaalbalans", + "dashboard": "Dashboard", + "myWallets": "Mijn wallets", + "addressBook": "Adresboek", + "transactions": "Transacties", + "names": "Namen", + "mining": "Mijnen", + "network": "Netwerk", + "blocks": "Blokken", + "statistics": "Statistieken", + "madeBy": "Gemaakt door <1>{{authorName}}", + "hostedBy": "Gehost door <1>{{host}}", + "github": "GitHub", + "credits": "Credits", + "whatsNew": "What is er nieuw", + + "updateTitle": "Update beschikbaar!", + "updateDescription": "Een nieuwe versie van TenebraWeb is beschikbaar. Herlaad alstublieft.", + "updateReload": "Herladen" + }, + + "dialog": { + "close": "Sluiten", + "yes": "Ja", + "no": "Nee", + "ok": "Oké", + "cancel": "Annuleren" + }, + + "pagination": { + "justPage": "Pagina {{page}}", + "pageWithTotal": "Pagina {{page}} van {{total}}" + }, + + "error": "Fout", + "errorBoundary": { + "title": "Kritische fout", + "description": "Er is een kritieke fout opgetreden in TenebraWeb, dus deze pagina is beëindigd. Zie console voor details.", + "sentryNote": "De fout is automatisch gerapporteerd." + }, + "errorReported": "Er is automatisch een fout gemeld. Zie console voor details.", + + "loading": "Laden...", + + "copy": "Kopiëren naar klembord", + "copied": "Gekopiëerd!", + + "pageNotFound": { + "resultTitle": "Pagina niet gevonden", + "nyiTitle": "Nog niet geimplementeerd", + "nyiSubTitle": "Deze functie komt binnenkort!", + "buttonGoBack": "Ga terug" + }, + + "contextualAddressUnknown": "Onbekend", + "contextualAddressNonExistentTooltip": "Dit adres is nog niet geinitialiseerd op het Tenebra netwerk.", + + "typeahead": { + "emptyLabel": "Geen match gevonden.", + "paginationText": "Extra resultaten weergeven..." + }, + + "masterPassword": { + "dialogTitle": "Hoofdwachtwoord", + "passwordPlaceholder": "Hoofdwachtwoord", + "passwordConfirmPlaceholder": "Bevestig hoofdwachtwoord", + "createPassword": "Creëer wachtwoord", + "logIn": "Log in", + "forgotPassword": "Wachtwoord vergeten?", + "intro2": "Voer een <1>hoofdwachtwoord in om je wallet-sleutels te beschermen. Deze worden in je browser opgeslagen, en je dient je hoofdwachtwoord elke sessie opnieuw in te voeren.", + "learnMore": "meer informatie", + "errorPasswordRequired": "Wachtwoord is vereist.", + "errorPasswordLength": "Moet op zijn minst 1 karakter bevatten.", + "errorPasswordUnset": "Hoofdwachtwoord nog niet ingesteld.", + "errorPasswordIncorrect": "Fout wachtwoord.", + "errorPasswordInequal": "Wachtwoorden moeten overeen komen.", + "errorStorageCorrupt": "Wallet-opslag is corrupt.", + "errorNoPassword": "Hoofdwachtwoord is vereist.", + "errorUnknown": "Onbekende fout.", + "helpWalletStorageTitle": "Help: Wallet-opslag", + "popoverTitle": "Ontsleutel wallets", + "popoverTitleEncrypt": "Versleutel wallets", + "popoverAuthoriseButton": "Autoriseren", + "popoverDescription": "Voer je hoofdwachtwoord in om je wallets te ontsleutelen.", + "popoverDescriptionEncrypt": "Voer je hoofdwachtwoord in om je wallets te versleutelen en te ontsleutelen.", + "forcedAuthWarning": "Je bent automatisch ingelogd door een onveilige debug-instelling.", + "earlyAuthError": "De app is nog niet volledig geladen, probeer het opnieuw.", + + "reset": { + "modalTitle": "Reset het hoofdwachtwoord", + "description": "Weet u zeker dat u uw hoofdwachtwoord opnieuw wilt instellen? Al uw portemonnees worden verwijderd. Zorg ervoor dat u eerst <3>een back-up exporteert!", + "buttonConfirm": "Reset en verwijderen", + + "modalTitle2": "VERWIJDER ALLE WALLETS", + "description2": "Weet je ECHT zeker dat je AL JE PORTEMONNEES VERWIJDEREN wilt?", + "buttonConfirm2": "Ja, ik weet het zeker [{{n}}]", + "buttonConfirmFinal": "Ja, ik weet het zeker!" + }, + + "change": { + "modalTitle": "Verander het hoofdwachtwoord" + } + }, + + "myWallets": { + "title": "Wallets", + "manageBackups": "Beheer backups", + "importBackup": "Importeer wallets", + "exportBackup": "Exporteer wallets", + "createWallet": "Creëer wallet", + "addExistingWallet": "Voeg bestaande wallet toe", + "searchPlaceholder": "Doorzoek wallets...", + "categoryDropdownAll": "Alle categorieën", + "columnLabel": "Label", + "columnAddress": "Adres", + "columnBalance": "Balans", + "columnNames": "Namen", + "columnCategory": "Categorie", + "columnFirstSeen": "Introductie", + "nameCount": "{{count, number}} naam", + "nameCount_plural": "{{count, number}} namen", + "nameCountEmpty": "Geen namen", + "firstSeen": "Geïntroduceert op {{date}}", + "firstSeenMobile": "Geïntroduceert op: <1 />", + + "walletCount": "{{count, number}} wallet", + "walletCount_plural": "{{count, number}} wallets", + "walletCountEmpty": "Geen wallets", + + "noWalletsHint": "Nog geen wallets", + "noWalletsText": "Voeg een wallet toe of maak een wallet aan door rechtsboven op het menu <1 /> te klikken!", + + "actionsViewAddress": "Adres weergeven", + "actionsEditTooltip": "Wallet bewerken", + "actionsSendTransaction": "Tenebra verzenden", + "actionsWalletInfo": "Wallet informatie", + "actionsDelete": "Wallet verwijderen", + "actionsDeleteConfirm": "Weet je zeker dat je deze wallet wil verwijderen?", + "actionsDeleteConfirmDescription": "Als je het wallet-sleutel niet hebt opgeslagen of opgeschreven, is deze voor altijd verloren!", + + "tagDontSave": "Tijdelijk", + "tagDontSaveTooltip": "Tijdelijke wallet", + + "info": { + "title": "Wallet informatie - {{address}}", + + "titleBasicInfo": "Basis informatie", + "id": "ID", + "label": "Label", + "category": "Categorie", + "username": "Gebruikersnaam", + "password": "Wachtwoord", + "privatekey": "Privésleutel", + "format": "Formaat", + + "titleSyncedInfo": "Gesynchroniseerde informatie", + "address": "Adres", + "balance": "Balans", + "names": "Aantal namen", + "firstSeen": "Introductie", + "existsOnNetwork": "Bestaat op het netwerk", + "lastSynced": "Laatst gesynchroniseerd", + + "titleAdvancedInfo": "Geavanceerde informatie", + "encPassword": "Versleuteld wachtwoord", + "encPrivatekey": "Versleutelde sleutel", + "saved": "Opgeslagen", + + "revealLink": "Tonen", + "hideLink": "Verbergen", + + "true": "Ja", + "false": "Nee" + } + }, + + "addressBook": { + "title": "Adresboek", + + "contactCount": "{{count, number}} contact", + "contactCount_plural": "{{count, number}} contacten", + "contactCountEmpty": "Geen contacten", + + "buttonAddContact": "Contact toeveoegen", + + "columnLabel": "Label", + "columnAddress": "Adres", + + "actionsViewAddress": "Toon address", + "actionsViewName": "Toon naam", + "actionsEditTooltip": "Bewerk contact", + "actionsSendTransaction": "Verzend Tenebra", + "actionsDelete": "Verwijder contact", + "actionsDeleteConfirm": "Weet u zeker dat u dit contact wilt verwijderen?" + }, + + "myTransactions": { + "title": "Transacties", + "searchPlaceholder": "Doorzoek transacties...", + "columnFrom": "Van", + "columnTo": "Naar", + "columnValue": "Waarde", + "columnTime": "Tijd" + }, + + "addWallet": { + "dialogTitle": "Wallet toevoegen", + "dialogTitleCreate": "Creëer wallet", + "dialogTitleEdit": "Bewerk wallet", + "dialogOkAdd": "Toevoegen", + "dialogOkCreate": "Creëren", + "dialogOkEdit": "Opslaan", + "dialogAddExisting": "Voeg bestaande wallet toe", + + "walletLabel": "Wallet label", + "walletLabelPlaceholder": "Wallet label (optioneel)", + "walletLabelMaxLengthError": "Niet langer dan 32 karakters", + "walletLabelWhitespaceError": "Moet niet alleen spaties bevatten", + + "walletCategory": "Wallet categorie", + "walletCategoryDropdownNone": "Geen categorie", + "walletCategoryDropdownNew": "Nieuw", + "walletCategoryDropdownNewPlaceholder": "Categorienaam", + + "walletAddress": "Wallet adres", + "walletUsername": "Wallet gebruikersnaam", + "walletUsernamePlaceholder": "Wallet gebruikersnaam", + "walletPassword": "Wallet wachtwoord", + "walletPasswordPlaceholder": "Wallet wachtwoord", + "walletPasswordWarning": "Zorg dat je dit ergens <1>veilig bewaard!", + "walletPasswordRegenerate": "Regenereren", + "walletPrivatekey": "Wallet privésleutel", + "walletPrivatekeyPlaceholder": "Wallet privésleutel", + + "advancedOptions": "Geavanceerde opties", + + "walletFormat": "Wallet formaat", + "walletFormatTenebraWallet": "TenebraWallet, KWallet (aanbevolen)", + "walletFormatTenebraWalletUsernameAppendhashes": "KW-Username (appendhashes)", + "walletFormatTenebraWalletUsername": "KW-Username (pre-appendhashes)", + "walletFormatJwalelset": "jwalelset", + "walletFormatApi": "Rauw/API (geavanceerde gebruikers)", + + "walletSave": "Sla deze wallet op in TenebraWeb", + + "messageSuccessAdd": "Wallet succesvol toegevoegd!", + "messageSuccessCreate": "Wallet succesvol aangemaakt!", + "messageSuccessEdit": "Wallet succesvol opgeslagen!", + + "errorPasswordRequired": "Wachtwoord is vereist.", + "errorPrivatekeyRequired": "Privésleutel is vereist.", + "errorUnexpectedTitle": "Onverwachte fout", + "errorUnexpectedDescription": "Er is een fout opgetreden bij het toevoegen van de wallet. Zie de console voor details.", + "errorUnexpectedEditDescription": "Er is een fout opgetreden bij het bewerken van de wallet. Zie console voor details.", + "errorDuplicateWalletTitle": "Wallet bestaat al", + "errorDuplicateWalletDescription": "U heeft al een wallet voor dat adres.", + "errorMissingWalletTitle": "Wallet niet gevonden", + "errorMissingWalletDescription": "De wallet die u probeert te bewerken, bestaat niet meer.", + "errorDecryptTitle": "Foutief hoofdwachtwoord", + "errorDecryptDescription": "Ontsleutelen van het wallet-wachtwoord is mislukt. Is het hoofdwachtwoord correct?", + "errorWalletLimitTitle": "Wallet limiet bereikt", + "errorWalletLimitDescription": "U kunt momenteel geen wallets meer toevoegen." + }, + + "addContact": { + "modalTitle": "Contact toevoegen", + "modalTitleEdit": "Bewerk contact", + + "buttonSubmit": "Toevoegen", + "buttonSubmitEdit": "Opslaan", + + "contactLabel": "Label", + "contactLabelPlaceholder": "Contactlabel (optioneel)", + "contactLabelMaxLengthError": "Niet langer dan 32 tekens", + "contactLabelWhitespaceError": "Mag niet alleen spaties bevatten", + + "contactAddressLabel": "Adres of naam", + + "messageSuccessAdd": "Contact succesvol toegevoegd!", + "messageSuccessEdit": "Contact succesvol opgeslagen!", + + "errorDuplicateContactTitle": "Contact bestaat al", + "errorDuplicateContactDescription": "U heeft al een contact voor dat adres.", + "errorMissingContactTitle": "Contact niet gevonden", + "errorMissingContactDescription": "Het contact dat u probeert te bewerken, bestaat niet meer.", + "errorContactLimitTitle": "Contactlimiet bereikt", + "errorContactLimitDescription": "U kunt momenteel geen contacten meer toevoegen." + }, + + "dashboard": { + "siteTitle": "Dashboard", + + "inDevBanner": "Welkom bij de privé-bèta van TenebraWeb v2! Deze site is nog in ontwikkeling, dus de meeste features ontbreken momenteel. Rapporteer alstublieft alle bugs op <1>GitHub. Bedankt!", + + "walletOverviewCardTitle": "Wallets", + "walletOverviewTotalBalance": "Totaalbalans", + "walletOverviewNames": "Namen", + "walletOverviewNamesCount": "{{count, number}} naam", + "walletOverviewNamesCount_plural": "{{count, number}} namen", + "walletOverviewNamesCountEmpty": "Geen namen", + "walletOverviewSeeMore": "Zie alle {{count, number}}...", + "walletOverviewAddWallets": "Wallets toevoegen...", + + "transactionsCardTitle": "Transacties", + "transactionsError": "Er was een fout bij het ophalen van de transacties. Zie de console voor details.", + + "blockValueCardTitle": "Blokwaarde", + "blockValueBaseValue": "Basiswaarde (<1>)", + "blockValueBaseValueNames": "{{count, number}} naam", + "blockValueBaseValueNames_plural": "{{count, number}} namen", + "blockValueNextDecrease": "Zal verminderen in <1> in <3>{{count, number}} blok", + "blockValueNextDecrease_plural": "Zal verminderen in <1> in <3>{{count, number}} blokken", + "blockValueReset": "Zal resetten in <1>{{count, number}} blok", + "blockValueReset_plural": "Zal resetten in <1>{{count, number}} blokken", + "blockValueEmptyDescription": "De blokwaarde verhoogt wanneer <1>namen aangeschaft worden", + + "blockDifficultyCardTitle": "Blok moeilijkheidsgraad", + "blockDifficultyError": "Er is een fout opgetreden bij het ophalen van de moeilijkheidsgraad van het blok. Zie de console voor details.", + "blockDifficultyHashRate": "Ongeveer <1 />", + "blockDifficultyHashRateTooltip": "Geschatte hash-snelheid van gecombineerde netwerkmining, gebaseerd op het huidige werk.", + "blockDifficultyChartWork": "Blok moeilijkheidsgraad", + "blockDifficultyChartLinear": "Lineair", + "blockDifficultyChartLog": "Logaritmisch", + + "motdCardTitle": "Server MOTD", + "motdDebugMode": "Dit synchronisatieknooppunt is een niet-officiële ontwikkelserver. Saldi en transacties kunnen worden gemanipuleerd. Ga voorzichtig verder.", + + "whatsNewCardTitle": "Wat is er nieuw", + "whatsNewButton": "Wat is er nieuw", + + "tipsCardTitle": "Tip van de dag", + "tipsPrevious": "Vorige", + "tipsNext": "Volgende", + "tips": { + "0": "Kijk wat er nieuw is in Tenebra en TenebraWeb op de [Wat is er nieuw pagina](/whatsnew)!", + "1": "U kunt snel door tabellen navigeren met de pijltjestoetsen op de computer.", + "2": "U kunt op tabelkoppen klikken om ze te sorteren.", + "3": "U kunt filteren op categorieën op de pagina [Mijn Wallets](/wallets) door op het filterpictogram in de tabelkop te klikken.", + "4": "De [Instellingenpagina](/settings) heeft veel geavanceerde opties om uw TenebraWeb-ervaring te personaliseren.", + "5": "Genereer vooraf ingevulde transactielinks met de nieuwe [Verzoeken pagina](/request).", + "6": "Zorg ervoor dat u een back-up maakt van [uw wallets](/wallets)!", + "7": "Zoek snel in het Tenebra-netwerk met de sneltoets Ctrl + K (Cmd + K op macOS).", + "8": "Voeg contacten toe aan het [adresboek](/contacts) om ze snel transacties te sturen.", + "9": "Een 'bumped'-transactie is een transactie die van en naar hetzelfde adres wordt verzonden.", + "10": "De 'blok moeilijkheidsgraad' grafiek kan worden weergegeven met een logaritmische schaal om kleine veranderingen gemakkelijker te zien bij lagere moeilijkheidsgraden.", + "1-status": "U bent verbonden met een niet-officiële server. Uw portemonnee-wachtwoorden kunnen naar de operator worden gestuurd, die ze kan gebruiken om toegang te krijgen tot uw portemonnee op de officiële server. Stel <1>hier een vraag voor meer informatie.", + "11": "De datumnotatie kan worden gewijzigd in de [geavanceerde instellingen](/settings).", + "12": "Je kunt de [laagste gemijnde blok hashes](/network/blocks/lowest) zien.", + "13": "De meest recent aangeschafte namen zijn te zien op de [Netwerknamen-pagina](/network/names/new).", + "14": "De blokwaarde neemt toe wanneer [namen](/network/names) gekocht worden.", + "15": "Als u zich zorgen maakt over onbedoelde transacties, kunt u een bevestigingsprompt inschakelen in de [geavanceerde instellingen](/settings)." + } + }, + + "credits": { + "title": "Credits", + "madeBy": "Gemaakt door <1>{{authorName}}", + "hostedBy": "Gehost by <1>{{host}}", + "supportersTitle": "Supporters", + "supportersDescription": "Dit project is mogelijk gemaakt door de volgende geweldige supporters:", + "supportButton": "Support TenebraWeb", + "translatorsTitle": "Vertalers", + "translatorsDescription": "Dit project is vertaald door de volgende geweldige bijdragers:", + "translateButton": "Vertaal TenebraWeb", + "tmpim": "Gemaakt door tmpim", + + "versionInfo": { + "version": "Versie", + "commitHash": "Commit", + "buildTime": "Buildtijd", + "variant": "Build variant", + "license": "Licentie" + }, + + "privacyTitle": "Privacy", + "privacy": { + "tenebraServer": "Tenebraserver", + "tenebraServerDesc": "De enige PII die de <1> Tenebra Server opslaat, is uw IP-adres, User-Agent en Origin, als onderdeel van de webserverlogboeken. Deze informatie wordt automatisch na 30 dagen verwijderd.", + "tenebraweb": "TenebraWeb", + "tenebrawebDesc1": "TenebraWeb gebruikt een door zichzelf gehoste <1> Sentry -server voor automatische foutrapportage. Dit systeem slaat uw IP-adres, User-Agent, Origin, breadcrumbs en de details op voor eventuele fouten die automatisch worden gerapporteerd. Deze informatie wordt automatisch na 30 dagen verwijderd.", + "tenebrawebDesc2": "Als je een advertentieblokkerende of trackerblokkerende extensie hebt, zoals <1> uBlock Origin (aanbevolen) , is ons Sentry-systeem al geblokkeerd door de ingebouwde lijsten, dus je doet het u hoeft zich geen zorgen te maken over uw privacy. U kunt zich ook afmelden voor foutrapportage op de <4> instellingenpagina . Dat gezegd hebbende, als u ons wilt helpen door meer gedetailleerde foutrapporten te verstrekken, overweeg dan om een uitzondering te maken voor TenebraWeb in uw tracker blocker-software. Deze site geeft geen advertenties weer.", + "tenebrawebDesc3": "Neem bij vragen of opmerkingen contact op met de ontwikkelaars." + } + }, + + "settings": { + "siteTitle": "Instellingen", + "title": "Instellingen", + + "messageSuccess": "Instelling succesvol gewijzigd!", + + "settingIntegerSave": "Opslaan", + + "menuLanguage": "Taal", + + "subMenuBackups": "Beheer backups", + "importBackup": "Importeer wallets", + "exportBackup": "Exporteer wallets", + + "subMenuMasterPassword": "Hoofdwachtwoord", + "changeMasterPassword": "Verander hoofdwachtwoord", + "resetMasterPassword": "Reset hoofdwachtwoord", + + "subMenuAutoRefresh": "Automatisch-verversen", + "autoRefreshTables": "Automatisch-verversen tabellen", + "autoRefreshTablesDescription": "Of grote tafellijsten (bijv. Transacties, namen) al dan niet automatisch moeten worden vernieuwd wanneer er een wijziging op het netwerk wordt gedetecteerd.", + "autoRefreshAddressPage": "Automatisch-verversen adrespagina", + "autoRefreshNamePage": "Automatisch-verversen naampagina", + + "subMenuAdvanced": "Geavanceerde instellingen", + "alwaysIncludeMined": "Neem altijd gemijnde transacties op in transactielijsten (mogelijk moet er worden vernieuwd)", + "copyNameSuffixes": "Voeg het achtervoegsel toe bij het kopiëren van namen", + "addressCopyButtons": "Toon kopieerknoppen naast alle adressen", + "nameCopyButtons": "Toon kopieerknoppen naast alle namen", + "blockHashCopyButtons": "Toon kopieerknoppen naast alle blok-hashes", + "showRelativeDates": "Geef relatieve datums weer in plaats van absolute, indien recent", + "showRelativeDatesDescription": "Overal op de site, als een datum minder dan 7 dagen geleden is, wordt deze als een relatieve datum weergegeven.", + "showNativeDates": "Toon datums in een oorspronkelijke datumnotatie vanuit de taal", + "showNativeDatesDescription": "Indien uitgeschakeld, worden datums altijd weergegeven als YYYY/MM/DD HH:mm:ss", + "transactionsHighlightOwn": "Markeer eigen transacties in de transactietabel", + "transactionsHighlightVerified": "Markeer geverifieerde adressen in de transactietabel", + "transactionDefaultRaw": "Laat standaard het tabblad 'Rauw' zien in plaats van 'CommonMeta' op de transactiepagina", + "confirmTransactions": "Vraag om bevestiging voor alle transacties", + "clearTransactionForm": "Wis het formulier 'Tenebra Verzenden' nadat u op 'Verzenden' hebt geklikt", + "sendTransactionDelay": "Tijd om te wachten, in milliseconden, voordat een andere transactie kan worden verzonden", + "defaultPageSize": "Standaard paginalengte voor tabellen", + "tableHotkeys": "Sneltoetsen voor tabelnavigatie inschakelen (pijlen naar links en naar rechts)", + + "subMenuPrivacy": "Privacy", + "privacyInfo": "Privacyinformatie", + "errorReporting": "Automatische foutrapportage inschakelen (vernieuwen vereist)", + "messageOnErrorReport": "Laat een melding zien wanneer er automatisch een fout wordt gerapporteerd (vernieuwing vereist)", + + "subMenuDebug": "Debug instellingen", + "advancedWalletFormats": "Geavanceerde wallet formaten", + "menuTranslations": "Vertalingen", + + "subTitleTranslations": "Vertalingen", + + "translations": { + "errorMissingLanguages": "Het bestand languages.json lijkt te ontbreken. Is TenebraWeb correct gecompileerd?", + "errorNoKeys": "Geen vertalingssleutels", + + "columnLanguageCode": "Code", + "columnLanguage": "Taal", + "columnKeys": "Sleutels", + "columnMissingKeys": "Missende sleutels", + "columnProgress": "Voortgang", + + "tableUntranslatedKeys": "Onvertaalde sleutels", + "columnKey": "Sleutel", + "columnEnglishString": "Engelse tekst", + + "importJSON": "Importeer JSON", + "exportCSV": "Exporteer CSV", + + "importedLanguageTitle": "Geïmporteerde taal" + } + }, + + "breadcrumb": { + "dashboard": "Dashboard", + "wallets": "Wallets", + + "settings": "Instellingen", + "settingsDebug": "Debug", + "settingsTranslations": "Vertalingen" + }, + + "ws": { + "errorToken": "Er is een fout opgetreden bij het verbinden met de Tenebra websocket-server.", + "errorWS": "Er is een fout opgetreden bij het verbinden met de Tenebra websocket-server (code <1>{{code}})." + }, + + "rateLimitTitle": "Verzoek-limiet bereikt", + "rateLimitDescription": "Er zijn in korte tijd te veel verzoeken naar de Tenebra-server gestuurd. Dit wordt waarschijnlijk veroorzaakt door een bug!", + + "address": { + "title": "Adres", + + "walletLabel": "Label:", + "walletCategory": "Categorie:", + "contactLabel": "Contact:", + + "balance": "Huidige balans", + "names": "Namen", + "nameCount": "{{count, number}} naam", + "nameCount_plural": "{{count, number}} namen", + "nameCountEmpty": "Geen namen", + "firstSeen": "Geïntroduceert", + + "buttonSendTenebra": "Verzend Tenebra naar {{address}}", + "buttonTransferTenebra": "Maak Tenebra over naar {{address}}", + "buttonAddContact": "Toevoegen aan adresboek", + "buttonEditContact": "Bewerken in adresboek", + "buttonEditWallet": "Wallet bewerken", + + "tooltipV1Address": "Transacties kunnen niet naar v1-adressen worden verzonden, omdat ze zijn uitgefaseerd.", + + "cardRecentTransactionsTitle": "Recente transacties", + "cardNamesTitle": "Namen", + + "transactionsError": "Er is een fout opgetreden bij het ophalen van de transacties. Zie de console voor details.", + "namesError": "Er is een fout opgetreden bij het ophalen van de namen. Zie de console voor details.", + + "namePurchased": "Aangeschaft <1 />", + "nameReceived": "Ontvangen <1 />", + "namesSeeMore": "Zie alle {{count, number}}...", + + "resultInvalidTitle": "Foutief adres", + "resultInvalid": "Dat lijkt niet op een correct tenebra adres.", + "resultNotFoundTitle": "Adres niet gevonden", + "resultNotFound": "Dat adres is nog niet geinitialiseerd op het Tenebra netwerk", + + "verifiedCardTitle": "Geverifieerd adres", + "verifiedInactive": "Deze service is momenteel niet actief.", + "verifiedWebsiteButton": "Bezoek website" + }, + + "transactionSummary": { + "itemID": "Transactie ID: {{id}}", + "itemFrom": "<0>Van: <1 />", + "itemTo": "<0>Naar: <1 />", + "itemName": "<0>Naam: <1 />", + "itemARecord": "<0>A record: <1 />", + "itemARecordRemoved": "(verwijderd)", + "seeMore": "Zie alle {{count, number}}..." + }, + + "transactions": { + "title": "Netwerktransacties", + "myTransactionsTitle": "Mijn transacties", + "nameHistoryTitle": "Naamgeschiedenis", + "nameTransactionsTitle": "Naamtransacties", + "searchTitle": "Transacties doorzoeken", + + "siteTitleWallets": "Mijn transacties", + "siteTitleNetworkAll": "Netwerktransacties", + "siteTitleNetworkAddress": "{{address}}'s transacties", + "siteTitleNameHistory": "Naamgeschiedenis", + "siteTitleNameSent": "Naamtransacties", + "siteTitleSearch": "Transacties doorzoeken", + + "subTitleSearchAddress": "Betreffende {{address}}", + "subTitleSearchName": "Betreffende {{name}}", + "subTitleSearchMetadata": "Met metagegevens '{{query}}'", + + "columnID": "ID", + "columnType": "Type", + "columnFrom": "Van", + "columnTo": "Naar", + "columnValue": "Waarde", + "columnName": "Naam", + "columnMetadata": "Metagegevens", + "columnTime": "Tijd", + + "tableTotal": "{{count, number}} resultaat", + "tableTotal_plural": "{{count, number}} resultaten", + "tableTotalEmpty": "Geen resultaten", + + "includeMined": "Inclusief gemijnde transacties", + + "resultInvalidTitle": "Foutief adres", + "resultInvalid": "Dat lijkt niet op een correct tenebra adres.", + + "types": { + "transferred": "Overgedragen", + "sent": "Verzonden", + "received": "Ontvangen", + "mined": "Gemijnd", + "name_a_record": "Naam gewijzigd", + "name_transferred": "Naam overgedragen", + "name_sent": "Naam verzonden", + "name_received": "Naam ontvangen", + "name_purchased": "Naam aangeschaft", + "bumped": "Bumped", + "unknown": "Onbekend" + } + }, + + "names": { + "titleWallets": "Mijn namen", + "titleNetworkAll": "Netwerknamen", + "titleNetworkAddress": "Netwerknamen", + + "siteTitleWallets": "Mijn namen", + "siteTitleNetworkAll": "Netwerknamen", + "siteTitleNetworkAddress": "{{address}}'s namen", + + "columnName": "Naam", + "columnOwner": "Eigenaar", + "columnOriginalOwner": "Originele igenaar", + "columnRegistered": "Geregistreerd", + "columnUpdated": "Bijgewerkt", + "columnARecord": "A Record", + "columnUnpaid": "Onbetaalde blokken", + + "mobileOwner": "<0>Eigenaar: <1 />", + "mobileOriginalOwner": "<0>Originele eigenaar: <1 />", + "mobileRegistered": "Geregistreerd: <1 />", + "mobileUpdated": "Bijgewerkt: <1 />", + "mobileARecordTag": "A", + "mobileUnpaidTag": "{{count, number}} blok", + "mobileUnpaidTag_plural": "{{count, number}} blokken", + + "actions": "Acties", + "actionsViewName": "Toon naam", + "actionsViewOwner": "Toon het adres van de eigenaar", + "actionsViewOriginalOwner": "Toon het adres van de originele eigenaar", + "actionsSendTenebra": "Verzend Tenebra", + "actionsTransferTenebra": "Maak Tenebra over", + "actionsUpdateARecord": "Update een A record", + "actionsTransferName": "Draag naam over", + + "tableTotal": "{{count, number}} naam", + "tableTotal_plural": "{{count, number}} namen", + "tableTotalEmpty": "Geen namen", + + "resultInvalidTitle": "Foutief adres", + "resultInvalid": "Dat lijkt niet op een correct tenebra adres.", + + "purchaseButton": "Koop naam" + }, + + "name": { + "title": "Naam", + + "buttonSendTenebra": "Verzend Tenebra naar {{name}}", + "buttonTransferTenebra": "Maak Tenebra over naar {{name}}", + "buttonARecord": "Werk A record bij", + "buttonTransferName": "Naam overmaken", + + "owner": "Eigendom van", + "originalOwner": "Gekocht door", + "registered": "Geregistreerd", + "updated": "Laatst bijgewerkt", + "unpaid": "Onbetaalde blokken", + "unpaidCount": "{{count, number}} blok", + "unpaidCount_plural": "{{count, number}} blokken", + "aRecord": "A record", + "aRecordEditTooltip": "A record bijwerken", + + "cardRecentTransactionsTitle": "Recente transacties", + "cardHistoryTitle": "Naamgeschiedenis", + "transactionsError": "Er is een fout opgetreden bij het ophalen van transacties. Zie de console voor details.", + "historyError": "Er is een fout opgetreden bij het ophalen van de naamgeschiedenis. Zie de console voor details.", + + "resultInvalidTitle": "Foutieve naam", + "resultInvalid": "Dit lijkt niet op een correct tenebra adres.", + "resultNotFoundTitle": "Naam niet gevonden", + "resultNotFound": "Die naam bestaat niet." + }, + + "blocks": { + "title": "Netwerkblokken", + "titleLowest": "Laagste blokken", + "siteTitle": "Netwerkblokken", + "siteTitleLowest": "Laagste blokken", + + "columnHeight": "Hoogte", + "columnAddress": "Mijner", + "columnHash": "Blok hash", + "columnValue": "Waarde", + "columnDifficulty": "Moeilijkheidsgraad", + "columnTime": "Tijd", + + "mobileHeight": "Blok #{{height, number}}", + "mobileMiner": "<0>Mijner: <1 />", + "mobileHash": "<0>Hash: <1 />", + "mobileDifficulty": "<0>Moeilijkheidsgraad: <1 />", + + "tableTotal": "{{count, number}} blok", + "tableTotal_plural": "{{count, number}} blokken", + "tableTotalEmpty": "Geen blokken" + }, + + "block": { + "title": "Blok", + "siteTitle": "Blok", + "siteTitleBlock": "Blok #{{id, number}}", + "subTitleBlock": "#{{id, number}}", + + "height": "Hoogte", + "miner": "Mijner", + "value": "Waarde", + "time": "Tijd", + "hash": "Hash", + "difficulty": "Moeilijkheidsgraad", + + "previous": "Vorige", + "previousTooltip": "Vorige blok (#{{id, number}})", + "previousTooltipNone": "Vorige blok ", + "next": "Volgende", + "nextTooltip": "Volgend blok (#{{id, number}})", + "nextTooltipNone": "Volgend blok", + + "resultInvalidTitle": "Foutieve blokhoogte", + "resultInvalid": "Dit lijkt niet op een correcte blokhoogte.", + "resultNotFoundTitle": "Blok niet gevonden", + "resultNotFound": "Dit blok is niet gevonden." + }, + + "transaction": { + "title": "Transactie", + "siteTitle": "Transactie", + "siteTitleTransaction": "Transactie #{{id, number}}", + "subTitleTransaction": "#{{id, number}}", + + "type": "Type", + "from": "Van", + "to": "Naar", + "address": "Adres", + "name": "Naam", + "value": "Waarde", + "time": "Tijd", + "aRecord": "A record", + + "cardMetadataTitle": "Metagegevens", + "tabCommonMeta": "CommonMeta", + "tabRaw": "Raw", + + "commonMetaError": "CommonMeta parseren heeft gefaald.", + "commonMetaParsed": "Geparseerde gegevens", + "commonMetaParsedHelp": "Deze waarden waren niet rechtstreeks opgenomen in de metagegevens van de transactie, maar werden afgeleid door de CommonMeta-parser.", + "commonMetaCustom": "Transactiegegevens", + "commonMetaCustomHelp": "Deze waarden waren direct opgenomen in de metagegevens van de transactie.", + "commonMetaColumnKey": "Sleutel", + "commonMetaColumnValue": "Waarde", + + "cardRawDataTitle": "Rauwe gegevens", + "cardRawDataHelp": "De transactie zoals deze is geretourneerd door de Tenebra API.", + + "rawDataColumnKey": "Sleutel", + "rawDataColumnValue": "Waarde", + + "resultInvalidTitle": "Foutieve transactie ID", + "resultInvalid": "Dat lijkt niet op een geldige transactie-ID.", + "resultNotFoundTitle": "Transactie niet gevonden", + "resultNotFound": "Die transactie bestaat niet." + }, + + "apiErrorResult": { + "resultUnknownTitle": "Onbekende fout", + "resultUnknown": "Zie de console voor details." + }, + + "noWalletsResult": { + "title": "Nog geen wallets", + "subTitle": "Je hebt momenteel geen wallets opgeslagen in TenebraWeb, dus er is hier nog niets te zien. Wil je een wallet toevoegen?", + "subTitleSendTransaction": "Je hebt momenteel geen wallets opgeslagen in TenebraWeb, dus je kan nog geen transactie uitvoeren. Wil je een wallet toevoegen?", + "button": "Wallets toevoegen", + "buttonNetworkTransactions": "Netwerktransacties", + "buttonNetworkNames": "Netwerknamen" + }, + + "backups": { + "importButton": "Importeer back-up", + "exportButton": "Exporteer back-up" + }, + + "import": { + "description": "Plak de back-upcode (of importeer uit een bestand hieronder) en voer het bijbehorende hoofdwachtwoord in. Back-ups van TenebraWeb v1 worden ook ondersteund.", + + "masterPasswordPlaceholder": "Hoofdwachtwoord", + "masterPasswordRequired": "Hoofdwachtwoord is vereist.", + "masterPasswordIncorrect": "Hoofdwachtwoord is incorrect.", + + "appMasterPasswordRequired": "U moet geauthenticeerd zijn om wallets te importeren.", + + "fromFileButton": "Importeer uit bestand", + "textareaPlaceholder": "Plak back-up code hier.", + "textareaRequired": "Back-up code is vereist.", + "fileErrorTitle": "Importeerfout", + "fileErrorNotText": "Het geïmporteerde bestand moet een tekstbestand zijn.", + + "overwriteCheckboxLabel": "Werk bestaande wallet-labels bij als er conflicten zijn", + + "modalTitle": "Importeer back-up", + "modalButton": "Importeren", + + "detectedFormat": "<0>Gedetecteerd format: <2 />", + "detectedFormatTenebraWebV1": "TenebraWeb v1", + "detectedFormatTenebraWebV2": "TenebraWeb v2", + "detectedFormatInvalid": "Foutief!", + + "progress": "<1>{{count, number}} item wordt geïporteerd...", + "progress_plural": "<1>{{count, number}} items worden geïporteerd...", + + "decodeErrors": { + "atob": "De back-up kon niet worden gedecodeerd omdat het geen geldige base64 is!", + "json": "De back-up kon niet worden gedecodeerd omdat het geen geldige JSON is!", + "missingTester": "De back-up kon niet worden gedecodeerd omdat er een testsleutel ontbreekt!", + "missingSalt": "De back-up kon niet worden gedecodeerd omdat er een salt key ontbreekt!", + "invalidTester": "De back-up kon niet worden gedecodeerd omdat de 'tester'-sleutel van het verkeerde type is!", + "invalidSalt": "De back-up kon niet worden gedecodeerd omdat de 'salt'-sleutel van het verkeerde type is!", + "invalidWallets": "De back-up kon niet worden gedecodeerd omdat de 'wallets'-sleutel van het verkeerde type is!", + "invalidFriends": "De back-up kon niet worden gedecodeerd omdat de 'friends'-sleutel van het verkeerde type is!", + "invalidContacts": "De back-up kon niet worden gedecodeerd omdat de 'contacts'-sleutel van het verkeerde type is!", + "unknown": "De back-up kon niet worden gedecodeerd vanwege een onbekende fout. Zie console voor details." + }, + + "walletMessages": { + "success": "Wallet succesvol geïmporteerd.", + "successSkipped": "Er bestaat al een wallet met hetzelfde adres ({{address}}) en instellingen, dus deze is overgeslagen.", + "successUpdated": "Er bestaat al een wallet met hetzelfde adres ({{address}}). Het label is geüpdatet naar \"{{label}}\"", + "successSkippedNoOverwrite": "Er bestaat al een wallet met hetzelfde adres ({{address}}) en u heeft ervoor gekozen het label niet te overschrijven, dus het is overgeslagen.", + "successImportSkipped": "Er is al een wallet met hetzelfde adres ({{address}}) geïmporteerd, dus deze is overgeslagen.", + + "warningSyncNode": "Deze wallet had een aangepast synchronisatieknooppunt, dat niet wordt ondersteund in TenebraWeb v2. Het synchronisatieknooppunt is overgeslagen.", + "warningIcon": "Deze wallet had een aangepast pictogram, dat niet wordt ondersteund in TenebraWeb v2. Het pictogram is overgeslagen.", + "warningLabelInvalid": "Het label voor deze wallet is ongeldig. Het label is overgeslagen.", + "warningCategoryInvalid": "De categorie voor deze wallet is ongeldig. De categorie is overgeslagen.", + "warningAdvancedFormat": "Deze wallet gebruikt een geavanceerd formaat ({{format}}), dat beperkte ondersteuning heeft in TenebraWeb v2.", + + "errorInvalidTypeString": "Deze wallet was geen string!", + "errorInvalidTypeObject": "Deze wallet was geen object!", + "errorDecrypt": "Deze wallet kon niet worden gedecodeerd!", + "errorPasswordDecrypt": "Het wachtwoord voor deze wallet kan niet worden gedecodeerd!", + "errorDataJSON": "De ontsleutelde gegevens waren geen geldige JSON!", + "errorUnknownFormat": "Deze wallet gebruikt een onbekend of niet-ondersteund formaat!", + "errorFormatMissing": "Deze wallet mist een formaat!", + "errorUsernameMissing": "Deze wallet mist een gebruikersnaam!", + "errorPasswordMissing": "Deze wallet mist een wachtwoord!", + "errorPrivateKeyMissing": "Deze wallet mist een privésleutel!", + "errorMasterKeyMissing": "Deze wallet mist een hoofdsleutel!", + "errorPrivateKeyMismatch": "Het wachtwoord van deze wallet matcht niet met de opgeslagen privésleutel!", + "errorMasterKeyMismatch": "Het wachtwoord van deze wallet matcht niet met de opgeslagen hoofdsleutel!", + "errorLimitReached": "U heeft de wallet-limiet bereikt. U kunt momenteel geen wallets meer toevoegen.", + "errorUnknown": "Er heeft een onbekende fout opgetreden. Zie de console voor details." + }, + + "contactMessages": { + "success": "Contact succesvol geïmporteerd.", + "successSkipped": "Er bestaat al een contactpersoon met hetzelfde adres ({{address}}) en instellingen, dus deze is overgeslagen.", + "successUpdated": "Er bestaat al een contactpersoon met hetzelfde adres ({{address}}). Het label is geüpdatet naar \"{{label}} \"", + "successSkippedNoOverwrite": "Er bestaat al een contact met hetzelfde adres ({{address}}) en je hebt ervoor gekozen het label niet te overschrijven, dus het is overgeslagen.", + "successImportSkipped": "Er is al een contactpersoon met hetzelfde adres ({{address}}) geïmporteerd, dus deze is overgeslagen.", + + "warningSyncNode": "Deze contactpersoon had een aangepast synchronisatieknooppunt, dat niet wordt ondersteund in TenebraWeb v2. Het synchronisatieknooppunt is overgeslagen.", + "warningIcon": "Deze contactpersoon had een aangepast pictogram, dat niet wordt ondersteund in TenebraWeb v2. Het pictogram is overgeslagen.", + "warningLabelInvalid": "Het label voor dit contact is ongeldig. Het label is overgeslagen.", + + "errorInvalidTypeString": "Dit contact was geen string!", + "errorInvalidTypeObject": "Dit contact was geen object!", + "errorDecrypt": "Dit contact kon niet worden gedecodeerd!", + "errorDataJSON": "De ontsleutelde gegevens waren geen geldige JSON!", + "errorAddressMissing": "Dit contact mist een adres!", + "errorAddressInvalid": "Het adres van dit contact is ongeldig!", + "errorLimitReached": "Je hebt de contactlimiet bereikt. Je kan momenteel geen contacten meer toevoegen.", + "errorUnknown": "Een onbekende fout is opgetreden. Zie console voor details." + }, + + "results": { + "noneImported": "Er zijn geen nieuwe wallets geïmporteerd.", + + "walletsImported": "<0>{{count, number}} nieuwe wallet was geïmporteerd.", + "walletsImported_plural": "<0>{{count, number}} nieuwe wallets waren geïmporteerd.", + "walletsSkipped": "{{count, number}} wallet was overgeslagen.", + "walletsSkipped_plural": "{{count, number}} wallets waren overgeslagen.", + + "contactsImported": "<0>{{count, number}} nieuwe contact was geïmporteerd.", + "contactsImported_plural": "<0>{{count, number}} nieuwe contacten waren geïmporteerd.", + "contactsSkipped": "{{count, number}} contact was overgeslagen.", + "contactsSkipped_plural": "{{count, number}} contacten waren overgeslagen.", + + "warnings": "Er is <1>{{count, number}} waarschuwing opgetreden tijdens het importeren.", + "warnings_plural": "Er zijn <1>{{count, number}} waarschuwingen opgetreden tijdens het importeren.", + "errors": "Er is <1>{{count, number}} fout opgetreden tijdens het importeren.", + "errors_plural": "Er zijn <1>{{count, number}} fouten opgetreden tijdens het importeren.", + + "treeHeaderWallets": "Wallets", + "treeHeaderContacts": "Contacten (Adresboek)", + + "treeWallet": "Wallet {{id}}", + "treeContact": "Contact {{id}}" + } + }, + + "export": { + "modalTitle": "Exporteer backup", + + "description": "Deze geheime code bevat je wallets en adresboekcontacten. Je kan het gebruiken om ze in een andere browser te importeren of om er een back-up van te maken. Je hebt nog steeds je hoofdwachtwoord nodig om de wallets in de toekomst te importeren. <1>Deel deze code met niemand.", + "size": "Grootte: <1 />", + + "buttonSave": "Opslaan in bestand", + "buttonCopy": "Kopieer naar klembord" + }, + + "walletLimitMessage": "Je hebt meer wallets opgeslagen dan TenebraWeb ondersteunt. Dit werd veroorzaakt door een bug, of u hebt deze opzettelijk omzeild. Verwacht problemen met synchroniseren.", + "contactLimitMessage": "Er zijn meer contacten opgeslagen dan TenebraWeb ondersteunt. Dit werd veroorzaakt door een bug, of u hebt deze opzettelijk omzeild. Verwacht problemen met synchroniseren.", + + "optionalFieldUnset": "(geen waarde)", + + "addressPicker": { + "placeholder": "Kies een ontvanger", + "placeholderWalletsOnly": "Kies een wallet", + "placeholderNoWallets": "Adres of naam", + "placeholderNoWalletsNoNames": "Adres", + + "hintCurrentBalance": "Huidige balans: <1 />", + + "errorAddressRequired": "Adres is vereist.", + "errorRecipientRequired": "Ontvanger is vereist.", + "errorWalletRequired": "Wallet is vereist.", + + "errorInvalidAddress": "Ongeldig adres of naam.", + "errorInvalidAddressOnly": "Ongeldig adres.", + "errorInvalidRecipient": "Ongeldige ontvanger. Moet een adres of naam zijn.", + "errorInvalidWalletsOnly": "Ongeldig walletadres.", + "errorEqual": "De ontvanger kan niet dezelfde zijn als de afzender.", + + "categoryWallets": "Wallets", + "categoryOtherWallets": "Andere wallets", + "categoryAddressBook": "Adresboek", + "categoryExactAddress": "Exact adres", + "categoryExactName": "Exacte naam", + + "addressHint": "Balans: <1 />", + "addressHintWithNames": "Namen: <1>{{names, number}}", + "nameHint": "Eigenaar: <1 />", + "nameHintNotFound": "Naam niet gevonden.", + "walletHint": "Wallet: <1 />" + }, + + "sendTransaction": { + "title": "Transactie verzenden", + "siteTitle": "Transactie verzenden", + + "modalTitle": "Transactie verzenden", + "modalSubmit": "Verzenden", + + "buttonSubmit": "Verzenden", + + "labelFrom": "Van wallet", + "labelTo": "Naar address/name", + "labelAmount": "Bedrag", + "labelMetadata": "Metadata", + "placeholderMetadata": "Optionele metadata", + + "buttonMax": "Max", + + "errorAmountRequired": "Amount is required.", + "errorAmountNumber": "Bedrag moet een getal zijn.", + "errorAmountTooLow": "Het bedrag moet minimaal 1 zijn.", + "errorAmountTooHigh": "Onvoldoende saldo in wallet.", + + "errorMetadataTooLong": "Metagegevens mogen niet langer zijn dan 256 tekens.", + "errorMetadataInvalid": "Metagegevens bevat ongeldige tekens.", + + "errorWalletGone": "Die wallet bestaat niet meer.", + "errorWalletDecrypt": "Uw portemonnee kan niet worden gedecodeerd.", + + "errorParameterTo": "Ongeldige ontvanger.", + "errorParameterAmount": "Ongeldige bedrag.", + "errorParameterMetadata": "Ongeldige metagegevens.", + "errorInsufficientFunds": "Onvoldoende saldo in wallet.", + "errorNameNotFound": "De naam van de ontvanger is niet gevonden.", + + "errorUnknown": "Onbekende fout bij het verzenden van transactie. Zie console voor details.", + + "payLargeConfirm": "Weet u zeker dat u <1 /> wilt verzenden?", + "payLargeConfirmHalf": "Weet u zeker dat u <1 /> wilt verzenden? Dit is meer dan de helft van uw saldo!", + "payLargeConfirmAll": "Weet u zeker dat u <1 /> wilt verzenden? Dit is je hele saldo!", + "payLargeConfirmDefault": "U staat op het punt om de privésleutel en het wachtwoord van uw portemonnee naar een niet-officiële Tenebra-server te sturen, die hen toegang zal geven tot uw Tenebra op de officiële server. Weet u zeker dat u dit wilt doen?", + + "errorNotificationTitle": "Transactie mislukt", + "successNotificationTitle": "Transactie geslaagd", + "successNotificationContent": "Je hebt <1 /> verzonden van <3 /> naar <5 />.", + "successNotificationButton": "Bekijk transactie", + + "errorInvalidQuery": "De queryparameters waren ongeldig, ze werden genegeerd." + }, + + "request": { + "title": "Verzoek Tenebra", + "siteTitle": "Verzoek Tenebra", + + "labelTo": "Verzoek ontvanger", + "labelAmount": "Verzoek bedrag", + "labelMetadata": "Verzoek metagegevens", + "placeholderMetadata": "Metagegevens", + + "generatedLink": "Gegenereerde link", + "generatedLinkHint": "Stuur deze link naar iemand om een betaling van hen aan te vragen." + }, + + "authFailed": { + "title": "Authenticatie mislukt", + "message": "U bent niet de eigenaar van dit adres.", + "messageLocked": "Dit adres is vergrendeld.", + "alert": "Bericht van de Tenebra-server:" + }, + + "whatsNew": { + "title": "Wat is er nieuw", + "siteTitle": "Wat is er nieuw", + + "titleTenebra": "Tenebra", + "titleTenebraWeb": "TenebraWeb", + + "tooltipGitHub": "Toon op GitHub", + + "cardWhatsNewTitle": "Wat is er nieuw", + "cardCommitsTitle": "Commits", + "cardCommitsSeeMore": "Meer zien", + + "new": "Nieuw!" + }, + + "namePicker": { + "placeholder": "Kies een naam", + "placeholderMultiple": "Kies namen", + + "buttonAll": "Allen", + + "warningTotalLimit": "Het lijkt erop dat u meer dan 1.000 namen heeft, wat nog niet wordt ondersteund in TenebraWeb v2. Plaats een probleem op GitHub.", + "errorLookup": "Er is een fout opgetreden bij het ophalen van de namen. Zie de console voor details." + }, + + "nameTransfer": { + "modalTitle": "Transfer namen", + + "labelNames": "Namen", + "labelRecipient": "Ontvanger", + + "buttonSubmit": "Transfer namen", + + "errorNameRequired": "Er is minimaal één naam vereist.", + + "errorWalletGone": "Die wallet bestaat niet meer.", + "errorWalletDecrypt": "De portemonnee \"{{adres}}\" kon niet worden gedecodeerd.", + "errorParameterNames": "Ongeldige namen.", + "errorParameterRecipient": "Ongeldige ontvanger.", + "errorNameNotFound": "Een of meer namen zijn niet gevonden.", + "errorNotNameOwner": "U bent niet de eigenaar van een of meer namen.", + "errorUnknown": "Onbekende fout bij het overzetten van namen. Zie console voor details.", + "errorNotificationTitle": "Naamoverdracht mislukt", + + // Note that the zero and singular cases of these warnings will never be + // shown, but they're provided for i18n compatibility. + "warningMultipleNames": "Weet u zeker dat u <1>{{count, number}} naam wilt overdragen aan <3 />?", + "warningMultipleNames_plural": "Weet u zeker dat u <1>{{count, number}} namen wilt overdragen naar <3 />?", + "warningAllNames": "Weet u zeker dat u <1>{{count, number}} naam wilt overdragen aan <3 />? Dit zijn al je namen!", + "warningAllNames_plural": "Weet u zeker dat u <1>{{count, number}} namen wilt overdragen naar <3 />? Dit zijn al je namen!", + + "successMessage": "Naam succesvol overgedragen", + "successMessage_plural": "Namen succesvol overgedragen", + "successDescription": "<1>{{count, number}} naam overgedragen aan <3 />.", + "successDescription_plural": "<1>{{count, number}} namen overgedragen aan <3 />.", + + "progress": "<1>{{count, number}} naam aan het overmaken...", + "progress_plural": "<1>{{count, number}} namen aan het overmaken..." + }, + + "nameUpdate": { + "modalTitle": "Namen bijwerken", + + "labelNames": "Namen", + "labelARecord": "A record", + "placeholderARecord": "A record (optioneel)", + + "buttonSubmit": "Update namen", + + "errorNameRequired": "Er is minimaal één naam vereist.", + + "errorWalletGone": "Die wallet bestaat niet meer.", + "errorWalletDecrypt": "De wallet \"{{adres}}\" kon niet worden gedecodeerd.", + "errorParameterNames": "Ongeldige namen.", + "errorParameterARecord": "Ongeldig A record.", + "errorNameNotFound": "Een of meer namen zijn niet gevonden.", + "errorNotNameOwner": "U bent niet de eigenaar van een of meer namen.", + "errorUnknown": "Onbekende fout bij het updaten van namen. Zie console voor details.", + "errorNotificationTitle": "Naamoverdracht mislukt", + + "successMessage": "Naam succesvol bijgewerkt", + "successMessage_plural": "Namen zijn bijgewerkt", + "successDescription": "<1>{{count, number}} naam bijgewerkt.", + "successDescription_plural": "<1>{{count, number}} namen bijgewerkt.", + + "progress": "<1>{{count, number}} naam bijwerken...", + "progress_plural": "<1>{{count, number}} namen bijwerken..." + }, + + "noNamesResult": { + "title": "Nog geen namen", + "subTitle": "Je hebt momenteel geen namen in je portemonnees die zijn opgeslagen in TenebraWeb, dus er is hier nog niets te zien. Wilt u een naam kopen?", + "button": "Koop naam" + }, + + "namePurchase": { + "modalTitle": "Koop naam", + + "nameCost": "Kosten: <1 />", + + "labelWallet": "Wallet", + "labelName": "Naam", + "placeholderName": "Naam", + + "buttonSubmit": "Koop (<1 />)", + + "errorNameRequired": "Naam is vereist.", + "errorInvalidName": "Ongeldige naam.", + "errorNameTooLong": "Naam is te lang.", + "errorNameTaken": "Die naam is al in gebruik!", + "errorInsufficientFunds": "Uw wallet heeft niet genoeg tenebra om een naam te kopen.", + + "errorWalletGone": "Die portemonnee bestaat niet meer.", + "errorWalletDecrypt": "De wallet \"{{adres}}\" kon niet worden gedecodeerd.", + "errorUnknown": "Onbekende fout bij het kopen van naam. Zie console voor details.", + "errorNotificationTitle": "Naamaankoop mislukt", + + "successMessage": "Naam succesvol gekocht", + "successNotificationButton": "Toon naam", + + "nameAvailable": "Naam is beschikbaar!" + }, + + "purchaseTenebra": { + "modalTitle": "Koop Tenebra", + "connection": "Er is zojuist verbinding gemaakt met een niet-officiële Tenebra-server. Uw wachtwoorden en Tenebra-wallets lopen gevaar." + }, + + "syncWallets": { + "errorMessage": "Fout bij het synchroniseren van wallets", + "errorDescription": "Er is een fout opgetreden bij het synchroniseren van uw wallets. Zie console voor details." + }, + + "legacyMigration": { + "modalTitle": "TenebraWeb v1 migratie", + "description": "Welkom bij TenebraWeb v2! Het lijkt erop dat u TenebraWeb v1 eerder op dit domein heeft gebruikt.

Voer uw hoofdwachtwoord in om uw portemonnee naar het nieuwe formaat te migreren. U hoeft dit maar één keer te doen.", + + "walletCount": "<1>{{count, number}} wallet gedetecteerd", + "walletCount_plural": "<1>{{count, number}} wallets gedetecteerd", + "contactCount": "<1>{{count, number}} contact gedetecteerd", + "contactCount_plural": "<1>{{count, number}} contacten gedetecteerd", + + "masterPasswordLabel": "Hoofdwachtwoord", + "masterPasswordPlaceholder": "Hoofdwachtwoord", + + "errorPasswordRequired": "Een wachtwoord is verplicht.", + "errorPasswordLength": "Moet minimaal 1 teken zijn.", + "errorPasswordIncorrect": "Incorrect wachtwoord.", + "errorUnknown": "Een onbekende fout is opgetreden. Zie console voor details.", + + "buttonForgotPassword": "Wachtwoord vergeten", + "buttonSubmit": "Begin migratie", + + "forgotPassword": { + "modalTitle": "Sla v1 migratie over", + "modalContent": "Als je je hoofdwachtwoord voor TenebraWeb v1 bent vergeten, kan je portemonnee niet gemigreerd worden. Je wordt hiervoor niet opnieuw gevraagd. Weet je zeker dat je de migratie wilt overslaan?", + "buttonSkip": "Overslaan" + } + }, + + "sortModal": { + "title": "Sorteer resultaten", + + "sortBy": "Sorteer op", + "sortOrder": "Sorteervolgorde", + "sortAscending": "Oplopend", + "sortDescending": "Aflopend", + + "buttonReset": "Reset", + + "options": { + "transactionsFrom": "Van", + "transactionsTo": "Naar", + "transactionsValue": "Waarde", + "transactionsName": "Naam", + "transactionsTime": "Tijd", + + "namesName": "Naam", + "namesOwner": "Eigenaar", + "namesOriginalOwner": "Originele eigenaar", + "namesARecord": "A Record", + "namesUnpaid": "Onbetaalde blokken", + "namesRegistered": "Geregistreerde tijd", + "namesUpdated": "Bijgewerkte tijd", + + "blocksMiner": "Mijner", + "blocksHash": "Hash", + "blocksValue": "Waarde", + "blocksDifficulty": "Moeilijkheidsgraad", + "blocksTime": "Tijd" + } + } +} diff --git a/public/locales/pl.json b/public/locales/pl.json new file mode 100644 index 0000000..fafc3fb --- /dev/null +++ b/public/locales/pl.json @@ -0,0 +1,1271 @@ +{ + "app": { + "name": "TenebraWeb" + }, + + "nav": { + "connection": { + "online": "Połączony", + "offline": "Rozłączony", + "connecting": "Nawiązywanie połączenia" + }, + + "search": { + "placeholder": "Przeszukaj sieć Tenebra", + "placeholderShortcut": "Przeszukaj sieć Tenebra ({{shortcut}})", + "placeholderShort": "Przeszukaj...", + "rateLimitHit": "Proszę zwolnij.", + "noResults": "Brak wyników.", + + "resultAddress": "Adres", + "resultName": "Nazwa", + "resultNameOwner": "Posiadany przez <1 />", + "resultBlockID": "ID bloku", + "resultBlockIDMinedBy": "Wydobyty przez <1 />", + "resultTransactionID": "ID transakcji", + "resultTransactions": "Transakcje", + "resultTransactionsAddress": "Wyszukaj transakcje z udziałem <1 />", + "resultTransactionsAddressResult_0": "<0>{{count, number}} transakcja z udziałem <2 />", + "resultTransactionsAddressResult_1": "<0>{{count, number}} transakcje z udziałem <2 />", + "resultTransactionsAddressResult_2": "<0>{{count, number}} transakcji z udziałem <2 />", + "resultTransactionsAddressResultEmpty": "Brak transakcji z udziałem <1 />", + "resultTransactionsName": "Wyszukaj transakcje z udziałem <1 />", + "resultTransactionsNameResult_0": "<0>{{count, number}} transakcja wysłana do <2 />", + "resultTransactionsNameResult_1": "<0>{{count, number}} transakcje wysłano do <2 />", + "resultTransactionsNameResult_2": "<0>{{count, number}} transakcji wysłanych do <2 />", + "resultTransactionsNameResultEmpty": "Brak transakcji wysłanych do <1 />", + "resultTransactionsMetadata": "Wyszukiwanie metadanych zawierających <1 />", + "resultTransactionsMetadataResult_0": "<0>{{count, number}} transakcja zawierająca metadane <2 />", + "resultTransactionsMetadataResult_1": "<0>{{count, number}} transakcje zawierające metadane <2 />", + "resultTransactionsMetadataResult_2": "<0>{{count, number}} transakcji zawierających metadane <2 />", + "resultTransactionsMetadataResultEmpty": "Brak transakcji zawierających metadane <1 />" + }, + + "send": "Wyślij", + "sendLong": "Wyślij Tenebra", + "request": "Zażądaj", + "requestLong": "Zażądaj Tenebra", + "sort": "Sortuj wyniki", + + "settings": "Ustawienia", + "more": "Więcej" + }, + + "sidebar": { + "totalBalance": "Saldo całkowite", + "dashboard": "Pulpit nawigacyjny", + "myWallets": "Moje portfele", + "addressBook": "Książka adresowa", + "transactions": "Transakcje", + "names": "Nazwy", + "mining": "Wydobywanie", + "network": "Sieć", + "blocks": "Bloki", + "statistics": "Statystyka", + "madeBy": "Stworzone przez <1>{{authorName}}", + "hostedBy": "Hostowany przez <1>{{host}}", + "github": "GitHub", + "credits": "Uznania", + "whatsNew": "Nowości", + + "updateTitle": "Dostępna aktualizacja!", + "updateDescription": "Dostępna jest nowa wersja TenebraWeb. Proszę odświeżyć stronę.", + "updateReload": "Odśwież" + }, + + "dialog": { + "close": "Zamknij", + "yes": "Tak", + "no": "Nie", + "ok": "OK", + "cancel": "Anuluj" + }, + + "pagination": { + "justPage": "Strona {{page}}", + "pageWithTotal": "Strona {{page}} z {{total}}" + }, + + "error": "Błąd", + "errorBoundary": { + "title": "Błąd krytyczny", + "description": "Wystąpił błąd krytyczny w TenebraWeb, więc ta strona została zamknięta. Szczegółowe informacje można znaleźć w konsoli.", + "sentryNote": "Ten błąd został zgłoszony automatycznie." + }, + "errorReported": "Błąd został zgłoszony automatycznie. Szczegółowe informacje można znaleźć w konsoli.", + + "loading": "Ładowanie...", + + "copy": "Skopiuj do schowka", + "copied": "Skopiowano!", + + "pageNotFound": { + "resultTitle": "Strona nie znaleziona", + "nyiTitle": "Jeszcze nie zaimplementowane", + "nyiSubTitle": "Ta funkcja będzie dostępna wkrótce!", + "buttonGoBack": "Wstecz" + }, + + "contextualAddressUnknown": "Nieznany", + "contextualAddressNonExistentTooltip": "Ten adres nie został jeszcze zainicjowany w sieci Tenebra.", + + "typeahead": { + "emptyLabel": "Nie znaleziono dopasowań.", + "paginationText": "Wyświetl dodatkowe wyniki ..." + }, + + "masterPassword": { + "dialogTitle": "Hasło główne", + "passwordPlaceholder": "Hasło główne", + "passwordConfirmPlaceholder": "Potwierdź hasło główne", + "createPassword": "Stwórz hasło", + "logIn": "Zaloguj sie", + "forgotPassword": "Zapomniałeś hasła?", + "intro2": "Wprowadź <1>hasło główne, aby zaszyfrować klucze prywatne portfela. Zostaną one zapisane w lokalnej pamięci przeglądarki i zostaniesz poproszony o podanie hasła głównego, aby je odszyfrować raz na sesję.", + "learnMore": "dowiedz się więcej", + "errorPasswordRequired": "Wymagane jest hasło.", + "errorPasswordLength": "Musi mieć co najmniej 1 znak.", + "errorPasswordUnset": "Hasło główne nie zostało skonfigurowane.", + "errorPasswordIncorrect": "Niepoprawne hasło.", + "errorPasswordInequal": "Hasła muszą się zgadzać.", + "errorStorageCorrupt": "Pamięć portfela jest uszkodzona.", + "errorNoPassword": "Wymagane jest hasło główne.", + "errorUnknown": "Nieznany błąd.", + "helpWalletStorageTitle": "Pomoc: Pamięć portfela", + "popoverTitle": "Odszyfruj portfele", + "popoverTitleEncrypt": "Zaszyfruj portfele", + "popoverAuthoriseButton": "Autoryzuj", + "popoverDescription": "Wprowadź swoje hasło główne, aby odszyfrować swoje portfele.", + "popoverDescriptionEncrypt": "Wprowadź swoje hasło główne, aby zaszyfrować i odszyfrować swoje portfele.", + "forcedAuthWarning": "Zostałeś automatycznie zalogowany przez niebezpieczne ustawienie debugowania.", + "earlyAuthError": "Aplikacja nie została jeszcze w pełni załadowana, spróbuj ponownie.", + + "reset": { + "modalTitle": "Zresetuj hasło główne", + "description": "Czy na pewno chcesz zresetować swoje hasło główne? Wszystkie Twoje portfele zostaną usunięte. Pamiętaj, aby najpierw <3>wyeksportować kopię zapasową!!!", + "buttonConfirm": "Zresetuj i usuń", + + "modalTitle2": "USUŃ WSZYSTKIE PORTFELE", + "description2": "Czy na PEWNO chcesz USUNĄĆ WSZYSTKIE SWOJE PORTFELE?", + "buttonConfirm2": "Tak, na pewno [{{n}}]", + "buttonConfirmFinal": "Tak, na pewno!" + }, + + "change": { + "modalTitle": "Zmień hasło główne" + } + }, + + "myWallets": { + "title": "Portfele", + "manageBackups": "Zarządzaj kopiami zapasowymi", + "importBackup": "Importuj portfele", + "exportBackup": "Eksport portfeli", + "createWallet": "Utwórz portfel", + "addExistingWallet": "Dodaj istniejący portfel", + "searchPlaceholder": "Przeszukaj portfele ...", + "categoryDropdownAll": "Wszystkie kategorie", + "columnLabel": "Etykieta", + "columnAddress": "Adres", + "columnBalance": "Saldo", + "columnNames": "Nazwy", + "columnCategory": "Kategoria", + "columnFirstSeen": "Pierwszy raz widziany", + "nameCount_0": "{{count, number}} nazwa", + "nameCount_1": "{{count, number}} nazwy", + "nameCount_2": "{{count, number}} nazw", + "nameCountEmpty": "Brak nazw", + "firstSeen": "Pierwszy raz widziany {{date}}", + "firstSeenMobile": "Pierwszy raz widziany: <1 />", + + "walletCount_0": "{{count, number}} portfel", + "walletCount_1": "{{count, number}} portfele", + "walletCount_2": "{{count, number}} portfeli", + "walletCountEmpty": "Brak portfeli", + + "noWalletsHint": "Nie ma jeszcze portfeli", + "noWalletsText": "Dodaj lub utwórz portfel, klikając menu <1 /> w prawym górnym rogu!", + + "actionsViewAddress": "Wyświetl adres", + "actionsEditTooltip": "Edytuj portfel", + "actionsSendTransaction": "Wyślij Tenebra", + "actionsWalletInfo": "Informacje o portfelu", + "actionsDelete": "Usuń portfel", + "actionsDeleteConfirm": "Czy na pewno chcesz usunąć ten portfel?", + "actionsDeleteConfirmDescription": "Jeśli nie wykonałeś kopii zapasowej lub nie zapisałeś hasła, zostanie ono utracone na zawsze!", + + "tagDontSave": "Tymczasowy", + "tagDontSaveTooltip": "Tymczasowy portfel", + + "info": { + "title": "Informacje o portfelu - {{address}}", + + "titleBasicInfo": "Podstawowe informacje", + "id": "ID", + "label": "Etykieta", + "category": "Kategoria", + "username": "Nazwa Użytkownika", + "password": "Hasło", + "privatekey": "Prywatny klucz", + "format": "Format", + + "titleSyncedInfo": "Informacje zsynchronizowane", + "address": "Adres", + "balance": "Saldo", + "names": "Liczba nazw", + "firstSeen": "Pierwszy raz widziany", + "existsOnNetwork": "Istnieje w sieci", + "lastSynced": "Ostatnia synchronizacja", + + "titleAdvancedInfo": "Informacje zaawansowane", + "encPassword": "Zaszyfrowane hasło", + "encPrivatekey": "Zaszyfrowany klucz prywatny", + "saved": "zapisane", + + "revealLink": "Pokaż", + "hideLink": "Schowaj", + + "true": "Tak", + "false": "Nie" + } + }, + + "addressBook": { + "title": "Książka adresowa", + + "contactCount_0": "{{count, number}} kontakt", + "contactCount_1": "{{count, number}} kontakty", + "contactCount_2": "{{count, number}} kontaktów", + "contactCountEmpty": "Brak kontaktów", + + "buttonAddContact": "Dodaj kontakt", + + "columnLabel": "Etykieta", + "columnAddress": "Adres", + + "actionsViewAddress": "Wyświetl adres", + "actionsViewName": "Wyświetl nazwę", + "actionsEditTooltip": "Edytuj kontakt", + "actionsSendTransaction": "Wyślij Tenebra", + "actionsDelete": "Usuń kontakt", + "actionsDeleteConfirm": "Czy na pewno chcesz usunąć ten kontakt?" + }, + + "myTransactions": { + "title": "Transakcje", + "searchPlaceholder": "Wyszukaj transakcje...", + "columnFrom": "Od", + "columnTo": "Do", + "columnValue": "Wartość", + "columnTime": "Czas" + }, + + "addWallet": { + "dialogTitle": "Dodaj portfel", + "dialogTitleCreate": "Utwórz portfel", + "dialogTitleEdit": "Edytuj portfel", + "dialogOkAdd": "Dodaj", + "dialogOkCreate": "Utwórz", + "dialogOkEdit": "Zapisz", + "dialogAddExisting": "Dodaj istniejący portfel", + + "walletLabel": "Etykieta portfela", + "walletLabelPlaceholder": "Etykieta portfela (opcjonalna)", + "walletLabelMaxLengthError": "Nie więcej niż 32 znaki", + "walletLabelWhitespaceError": "Nie mogą to być tylko spacje", + + "walletCategory": "Kategoria portfela", + "walletCategoryDropdownNone": "Brak kategorii", + "walletCategoryDropdownNew": "Nowa", + "walletCategoryDropdownNewPlaceholder": "Nazwa kategorii", + + "walletAddress": "Adres portfela", + "walletUsername": "Nazwa użytkownika portfela", + "walletUsernamePlaceholder": "Nazwa użytkownika portfela", + "walletPassword": "Hasło do portfela", + "walletPasswordPlaceholder": "Hasło do portfela", + "walletPasswordWarning": "Upewnij się, że zapisałeś to w <1>bezpiecznym miejscu!", + "walletPasswordRegenerate": "Wygeneruj nowy", + "walletPrivatekey": "Klucz prywatny portfela", + "walletPrivatekeyPlaceholder": "Klucz prywatny portfela", + + "advancedOptions": "Zaawansowane opcje", + + "walletFormat": "Format portfela", + "walletFormatTenebraWallet": "TenebraWallet, KWallet (zalecany)", + "walletFormatTenebraWalletUsernameAppendhashes": "KW-Username (appendhashes)", + "walletFormatTenebraWalletUsername": "KW-Username (pre-appendhashes)", + "walletFormatJwalelset": "jwalelset", + "walletFormatApi": "Raw/API (advanced users)", + + "walletSave": "Zapisz ten portfel w TenebraWeb", + + "messageSuccessAdd": "Pomyślnie dodano portfel!", + "messageSuccessCreate": "Pomyślnie utworzono portfel!", + "messageSuccessEdit": "Pomyślnie zapisano portfel!", + + "errorPasswordRequired": "Wymagane jest hasło.", + "errorPrivatekeyRequired": "Wymagany jest klucz prywatny.", + "errorUnexpectedTitle": "Niespodziewany błąd", + "errorUnexpectedDescription": "Wystąpił błąd podczas dodawania portfela. Szczegółowe informacje można znaleźć w konsoli.", + "errorUnexpectedEditDescription": "Wystąpił błąd podczas edycji portfela. Szczegółowe informacje można znaleźć w konsoli.", + "errorDuplicateWalletTitle": "Portfel już istnieje", + "errorDuplicateWalletDescription": "Masz już portfel dla tego adresu.", + "errorMissingWalletTitle": "Nie znaleziono portfela", + "errorMissingWalletDescription": "Portfel, który próbujesz edytować, już nie istnieje.", + "errorDecryptTitle": "Nieprawidłowe hasło główne", + "errorDecryptDescription": "Nie udało się odszyfrować hasła do portfela. Czy hasło główne jest prawidłowe?", + "errorWalletLimitTitle": "Osiągnięto limit portfeli", + "errorWalletLimitDescription": "Obecnie nie możesz dodać więcej portfeli." + }, + + "addContact": { + "modalTitle": "Dodaj kontakt", + "modalTitleEdit": "Edytuj kontakt", + + "buttonSubmit": "Dodaj", + "buttonSubmitEdit": "Zapisz", + + "contactLabel": "Etykieta", + "contactLabelPlaceholder": "Etykieta kontaktowu (opcjonalna)", + "contactLabelMaxLengthError": "Nie więcej niż 32 znaki", + "contactLabelWhitespaceError": "Nie mogą to być tylko spacje", + + "contactAddressLabel": "Adres lub nazwa", + + "messageSuccessAdd": "Pomyślnie dodano kontakt!", + "messageSuccessEdit": "Kontakt zapisany pomyślnie!", + + "errorDuplicateContactTitle": "Kontakt już istnieje", + "errorDuplicateContactDescription": "Masz już kontakt dla tego adresu.", + "errorMissingContactTitle": "Nie znaleziono kontaktu", + "errorMissingContactDescription": "Kontakt, który próbujesz edytować, już nie istnieje.", + "errorContactLimitTitle": "Osiągnięto limit kontaktów", + "errorContactLimitDescription": "Obecnie nie możesz dodać więcej kontaktów." + }, + + "dashboard": { + "siteTitle": "Pulpit nawigacyjny", + + "inDevBanner": "Witamy w prywatnej wersji beta TenebraWeb v2! Ta strona jest wciąż w fazie rozwoju, więc obecnie brakuje większości funkcji. Prosimy o zgłaszanie wszystkich błędów na <1> GitHub . Dzięki!", + + "walletOverviewCardTitle": "Portfele", + "walletOverviewTotalBalance": "Saldo całkowite", + "walletOverviewNames": "Nazwy", + "walletOverviewNamesCount_0": "{{count, number}} nazwa", + "walletOverviewNamesCount_1": "{{count, number}} nazwy", + "walletOverviewNamesCount_2": "{{count, number}} nazw", + "walletOverviewNamesCountEmpty": "Brak nazw", + "walletOverviewSeeMore": "Zobacz wszystkie {{count, number}}...", + "walletOverviewAddWallets": "Dodaj portfele...", + + "transactionsCardTitle": "Transakcje", + "transactionsError": "Wystąpił błąd podczas pobierania transakcji. Zobacz konsolę, aby uzyskać szczegółowe informacje.", + + "blockValueCardTitle": "Wartość bloku", + "blockValueBaseValue": "Wartość bloku (<1>)", + "blockValueBaseValueNames_0": "{{count, number}} nazwa", + "blockValueBaseValueNames_1": "{{count, number}} nazwy", + "blockValueBaseValueNames_2": "{{count, number}} nazw", + "blockValueNextDecrease_0": "Zmniejsza o <1> w <3>{{count, number}} blok", + "blockValueNextDecrease_1": "Zmniejsza o <1> w <3>{{count, number}} bloki", + "blockValueNextDecrease_2": "Zmniejsza o <1> w <3>{{count, number}} bloków", + "blockValueReset_0": "Resetuje się za <1>{{count, number}} blok", + "blockValueReset_1": "Resetuje się za <1>{{count, number}} bloki", + "blockValueReset_2": "Resetuje się za <1>{{count, number}} bloków", + "blockValueEmptyDescription": "Wartość bloku wzrasta, gdy kupowane są <1>nazwy.", + + "blockDifficultyCardTitle": "Trudność bloku", + "blockDifficultyError": "Wystąpił błąd podczas pobierania trudności bloku. Zobacz konsolę, aby uzyskać szczegółowe informacje.", + "blockDifficultyHashRate": "Około <1 />", + "blockDifficultyHashRateTooltip": "Szacowana łączna szybkość wydobycia w sieci, na podstawie bieżącej pracy.", + "blockDifficultyChartWork": "Trudność bloku", + "blockDifficultyChartLinear": "Liniowy", + "blockDifficultyChartLog": "Logarytmiczny", + + "motdCardTitle": "MOTD serwera", + "motdDebugMode": "Ten serwer jest nieoficjalnym serwerem developerskim. Można manipulować saldami i transakcjami. Postępuj ostrożnie.", + + "whatsNewCardTitle": "Nowości", + "whatsNewButton": "Nowości", + + "tipsCardTitle": "Porada dnia", + "tipsPrevious": "Poprzedni", + "tipsNext": "Następny", + "tips": { + "0": "Sprawdź, co nowego w Tenebra i TenebraWeb na stronie [Co nowego](/whatsnew)!", + "1": "Możesz szybko poruszać się po tabelach za pomocą klawiszy strzałek na pulpicie.", + "2": "Możesz kliknąć nagłówki tabeli, aby je posortować.", + "3": "Możesz filtrować według kategorii na stronie [Moje portfele](/wallets), klikając ikonę filtru w nagłówku tabeli.", + "4": "Na [stronie ustawień](/settings) dostępnych jest wiele zaawansowanych opcji umożliwiających personalizację korzystania z TenebraWeb.", + "5": "Wygeneruj wstępnie wypełnione linki do transakcji za pomocą nowej strony [Żądania](/request).", + "6": "Pamiętaj, aby wykonać kopię zapasową [swoich portfeli](/wallets)!", + "7": "Szybko przeszukaj sieć Tenebra za pomocą skrótu klawiaturowego Ctrl + K (Cmd + K w systemie macOS).", + "8": "Dodawaj kontakty w [książce adresowej](/contacts), aby szybko wysyłać im transakcje.", + "9": "Transakcja „podbita” to transakcja wysyłana z adresu do tego samego adresu.", + "10": "Wykres „trudności bloku” może być przedstawiony w skali logarytmicznej, aby łatwiej dostrzec małe zmiany przy niższych trudnościach.", + "1-status": "Masz połączenie z nieoficjalnym serwerem. Twoje hasła do portfela mogą zostać wysłane do operatora, który może ich użyć, aby uzyskać dostęp do Twoich portfeli na oficjalnym serwerze. Aby uzyskać więcej informacji, zadaj pytanie <1>tutaj.", + "11": "Format daty można zmienić w [ustawieniach zaawansowanych](/settings).", + "12": "Możesz zobaczyć [najniższe hashe wydobytych bloków](/network/blocks/lowest).", + "13": "Ostatnio zakupione nazwy można zobaczyć na stronie [Nowych Nazw](/network/names/new).", + "14": "Wartość bloku wzrasta, gdy kupowane są [nazwy](/network/names)", + "15": "Jeśli martwisz się przypadkowymi transakcjami, możesz włączyć monit potwierdzenia w [ustawieniach zaawansowanych](/settings)." + } + }, + + "credits": { + "title": "Uznania", + "madeBy": "Stworzone przez <1>{{authorName}}", + "hostedBy": "Hostowany przez <1>{{host}}", + "supportersTitle": "Darczyńcy", + "supportersDescription": "Ten projekt był możliwy dzięki następującym niesamowitym darczyńcom:", + "supportButton": "Wesprzyj TenebraWeb", + "translatorsTitle": "Tłumacze", + "translatorsDescription": "Ten projekt został przetłumaczony przez następujących niesamowitych współpracowników:", + "translateButton": "Przetłumacz TenebraWeb", + "tmpim": "Stworzony przez tmpim", + + "versionInfo": { + "version": "Wersja", + "commitHash": "Commit", + "buildTime": "Czas budowy", + "variant": "Wariant", + "license": "Licencja" + }, + + "privacyTitle": "Prywatność", + "privacy": { + "tenebraServer": "Server Tenebra", + "tenebraServerDesc": "Jedyne informacje umożliwiające identyfikację, które przechowuje <1>Tenebra Server, to Twój adres IP, User-Agent i Origin w dziennikach serwera internetowego. Te informacje są automatycznie usuwane po 30 dniach.", + "tenebraweb": "TenebraWeb", + "tenebrawebDesc1": "TenebraWeb używa samoobsługowego serwera <1>Sentry do automatycznego raportowania błędów. Ten system przechowuje Twój adres IP, User-Agent, Origin, okruszki chleba i szczegóły wszelkich błędów, które są automatycznie zgłaszane. Te informacje są automatycznie usuwane po 30 dniach.", + "tenebrawebDesc2": "Jeśli masz rozszerzenie blokujące reklamy lub śledzące, takie jak <1>uBlock Origin (zalecane), nasz system Sentry jest już blokowany przez wbudowane listy, więc nie musisz martwić się o swoją prywatność. Możesz również zrezygnować z raportowania błędów na <4>stronie ustawień. To powiedziawszy, jeśli chcesz nam pomóc, dostarczając bardziej szczegółowe raporty o błędach, rozważ zrobienie wyjątku dla TenebraWeb w swoim oprogramowaniu do blokowania elementów śledzących. Ta witryna nie wyświetla reklam.", + "tenebrawebDesc3": "Jeśli masz jakieś pytania lub wątpliwości, skontaktuj się z programistami." + } + }, + + "settings": { + "siteTitle": "Ustawienia", + "title": "Ustawienia", + + "messageSuccess": "Ustawienie zostało pomyślnie zmienione!", + + "settingIntegerSave": "Zapisz", + + "menuLanguage": "Język", + + "subMenuBackups": "Zarządzaj kopiami zapasowymi", + "importBackup": "Importuj portfele", + "exportBackup": "Eksport portfeli", + + "subMenuMasterPassword": "Hasło główne", + "changeMasterPassword": "Zmień hasło główne", + "resetMasterPassword": "Zresetuj hasło główne", + + "subMenuAutoRefresh": "Automatyczne odświeżanie", + "autoRefreshTables": "Automatyczne odświeżanie tabel", + "autoRefreshTablesDescription": "Określa, czy listy w dużych tabelach (np. Transakcje, nazwy) powinny być automatycznie odświeżane po wykryciu zmiany w sieci.", + "autoRefreshAddressPage": "Automatyczne odświeżanie strony z adresami", + "autoRefreshNamePage": "Automatyczne odświeżanie strony z nazwami", + + "subMenuAdvanced": "Zaawansowane ustawienia", + "alwaysIncludeMined": "Zawsze uwzględniaj transakcje wydobycia na listach transakcji (może wymagać odświeżenia)", + "copyNameSuffixes": "Uwzględnij przyrostek przy kopiowaniu nazw", + "addressCopyButtons": "Pokaż przyciski kopiowania obok wszystkich adresów", + "nameCopyButtons": "Pokaż przyciski kopiowania obok wszystkich nazw", + "blockHashCopyButtons": "Pokaż przyciski kopiowania obok wszystkich skrótów bloków", + "showRelativeDates": "Pokaż daty względne zamiast bezwzględnych, jeśli są niedawne", + "showRelativeDatesDescription": "Wszędzie w witrynie, jeśli data jest wcześniejsza niż 7 dni, zostanie wyświetlona jako data względna.", + "showNativeDates": "Pokaż daty w rodzimym formacie daty z danego języka", + "showNativeDatesDescription": "Jeśli wyłączone, daty zawsze będą wyświetlane jako YYYY/MM/DD HH:mm:ss", + "transactionsHighlightOwn": "Podświetl własne transakcje w tabeli transakcji", + "transactionsHighlightVerified": "Podświetl zweryfikowane adresy w tabeli transakcji", + "transactionDefaultRaw": "Domyślnie wybierz zakładkę 'Nieprzetworzone' zamiast 'CommonMeta' na stronie transakcji", + "confirmTransactions": "Pytaj o potwierdzenie wszystkich transakcji", + "clearTransactionForm": "Wyczyść formularz Wyślij transakcję po kliknięciu „Wyślij”", + "sendTransactionDelay": "Czas oczekiwania (w milisekundach) przed zezwoleniem na wysłanie kolejnej transakcji", + "defaultPageSize": "Domyślny rozmiar strony dla tabel", + "tableHotkeys": "Włącz klawisze skrótów nawigacji tabeli (strzałki w lewo i w prawo)", + + "subMenuPrivacy": "Prywatność", + "privacyInfo": "Informacje dotyczące prywatności", + "errorReporting": "Włącz automatyczne raportowanie błędów (wymaga odświeżenia)", + "messageOnErrorReport": "Pokaż powiadomienie, gdy błąd zostanie automatycznie zgłoszony (wymaga odświeżenia)", + + "subMenuDebug": "Ustawienia debugowania", + "advancedWalletFormats": "Zaawansowane formaty portfeli", + "menuTranslations": "Tłumaczenia", + + "subTitleTranslations": "Tłumaczenia", + + "translations": { + "errorMissingLanguages": "Wydaje się, że brakuje pliku languages.json. Czy TenebraWeb został poprawnie skompilowany?", + "errorNoKeys": "Brak kluczy tłumaczenia", + + "columnLanguageCode": "Kod", + "columnLanguage": "Język", + "columnKeys": "Klucze", + "columnMissingKeys": "Brakujące klucze", + "columnProgress": "Postęp", + + "tableUntranslatedKeys": "Nieprzetłumaczone klucze", + "columnKey": "Klucz", + "columnEnglishString": "Angielska wersja", + + "importJSON": "Importuj JSON", + "exportCSV": "Eksportuj CSV", + + "importedLanguageTitle": "Zaimportowany Język" + } + }, + + "breadcrumb": { + "dashboard": "Pulpit nawigacyjny", + "wallets": "Portfele", + + "settings": "Ustawienia", + "settingsDebug": "Debug", + "settingsTranslations": "Tłumaczenia" + }, + + "ws": { + "errorToken": "Wystąpił błąd podczas łączenia się z serwerem websocket Tenebra.", + "errorWS": "Wystąpił błąd podczas łączenia się z serwerem websocket Tenebra (code <1>{{code}})." + }, + + "rateLimitTitle": "Osiągnięto limit częstotliwości", + "rateLimitDescription": "Zbyt wiele żądań zostało wysłanych do serwera Tenebra w krótkim czasie. Jest to prawdopodobnie spowodowane błędem!", + + "address": { + "title": "Adres", + + "walletLabel": "Etykieta:", + "walletCategory": "Kategoria:", + "contactLabel": "Kontakt:", + + "balance": "Aktualne saldo", + "names": "Nazwy", + "nameCount_0": "{{count, number}} nazwa", + "nameCount_1": "{{count, number}} nazwy", + "nameCount_2": "{{count, number}} nazw", + "nameCountEmpty": "Brak nazw", + "firstSeen": "Pierwszy raz widziany", + + "buttonSendTenebra": "Wyślij Tenebra do {{address}}", + "buttonTransferTenebra": "Przenieś Tenebra do {{address}}", + "buttonAddContact": "Dodaj do książki adresowej", + "buttonEditContact": "Edytuj w książce adresowej", + "buttonEditWallet": "Edytuj portfel", + + "tooltipV1Address": "Transakcji nie można wysyłać na adresy w wersji 1, ponieważ zostały one wycofane.", + + "cardRecentTransactionsTitle": "Ostatnie transakcje", + "cardNamesTitle": "Nazwy", + + "transactionsError": "Wystąpił błąd podczas pobierania transakcji. Zobacz konsolę, aby uzyskać szczegółowe informacje.", + "namesError": "Wystąpił błąd podczas pobierania nazw. Zobacz konsolę, aby uzyskać szczegółowe informacje.", + + "namePurchased": "Zakupiono <1 />", + "nameReceived": "Otrzymano <1 />", + "namesSeeMore": "Zobacz wszystkie {{count, number}}...", + + "resultInvalidTitle": "Nieprawidłowy adres", + "resultInvalid": "To nie wygląda na prawidłowy adres Tenebra.", + "resultNotFoundTitle": "Adres nie znaleziony", + "resultNotFound": "Ten adres nie został jeszcze zainicjowany w sieci Tenebra.", + + "verifiedCardTitle": "Zweryfikowany adres", + "verifiedInactive": "Ta usługa nie jest obecnie aktywna.", + "verifiedWebsiteButton": "Odwiedź stronę" + }, + + "transactionSummary": { + "itemID": "ID Transakcji: {{id}}", + "itemFrom": "<0>Od: <1 />", + "itemTo": "<0>Do: <1 />", + "itemName": "<0>Nazwa: <1 />", + "itemARecord": "<0>Rekord A: <1 />", + "itemARecordRemoved": "(usunięte)", + "seeMore": "Zobacz wszystkie {{count, number}}..." + }, + + "transactions": { + "title": "Wszystkie Transakcje", + "myTransactionsTitle": "Moje Transakcje", + "nameHistoryTitle": "Historia Nazw", + "nameTransactionsTitle": "Transakcje Nazw", + "searchTitle": "Wyszukiwanie Transakcji", + + "siteTitleWallets": "Moje Transakcje", + "siteTitleNetworkAll": "Wszystkie Transakcje", + "siteTitleNetworkAddress": "{{address}} - Transakcje", + "siteTitleNameHistory": "Historia Nazw", + "siteTitleNameSent": "Transakcje Nazw", + "siteTitleSearch": "Wyszukiwanie Transakcji", + + "subTitleSearchAddress": "Dotyczące {{address}}", + "subTitleSearchName": "Dotyczące {{name}}", + "subTitleSearchMetadata": "Z metadanymi '{{query}}'", + + "columnID": "ID", + "columnType": "Typ", + "columnFrom": "Od", + "columnTo": "Do", + "columnValue": "Wartość", + "columnName": "Nazwa", + "columnMetadata": "Metadane", + "columnTime": "Czas", + + "tableTotal_0": "{{count, number}} pozycja", + "tableTotal_1": "{{count, number}} pozycje", + "tableTotal_2": "{{count, number}} pozycji", + "tableTotalEmpty": "Brak pozycji", + + "includeMined": "Uwzględnij transakcje wydobycia", + + "resultInvalidTitle": "Nieprawidłowy adres.", + "resultInvalid": "To nie wygląda na prawidłowy adres Tenebra.", + + "types": { + "transferred": "Transfer", + "sent": "Wysłano", + "received": "Otrzymano", + "mined": "Wydobyto", + "name_a_record": "Aktualizacja nazwy", + "name_transferred": "Przeniesienie nazwy", + "name_sent": "Wysłano nazwę", + "name_received": "Otrzymano nazwę", + "name_purchased": "Zakup nazwy", + "bumped": "Podbita", + "unknown": "Nieznany" + } + }, + + "names": { + "titleWallets": "Moje Nazwy", + "titleNetworkAll": "Wszystkie Nazwy", + "titleNetworkAddress": "Wszystkie Nazwy", + + "siteTitleWallets": "Moje Nazwy", + "siteTitleNetworkAll": "Wszystkie Nazwy", + "siteTitleNetworkAddress": "{{address}} - Nazwy", + + "columnName": "Nazwa", + "columnOwner": "Właściciel", + "columnOriginalOwner": "Pierwotny Właściciel", + "columnRegistered": "Zarejestrowana", + "columnUpdated": "Zaktualizowana", + "columnARecord": "Rekord A", + "columnUnpaid": "Niewypłacone bloki", + + "mobileOwner": "<0>Właściciel: <1 />", + "mobileOriginalOwner": "<0>Pierwotny właściciel: <1 />", + "mobileRegistered": "Zarejestrowany: <1 />", + "mobileUpdated": "Zaktualizowano: <1 />", + "mobileARecordTag": "A", + "mobileUnpaidTag_0": "{{count, number}} blok", + "mobileUnpaidTag_1": "{{count, number}} blocki", + "mobileUnpaidTag_2": "{{count, number}} blocków", + + "actions": "Działania", + "actionsViewName": "Wyświetl nazwę", + "actionsViewOwner": "Wyświetl adres właściciela", + "actionsViewOriginalOwner": "Wyświetl adres pierwotnego właściciela", + "actionsSendTenebra": "Wyślij Tenebra", + "actionsTransferTenebra": "Przenieś Tenebra", + "actionsUpdateARecord": "Zaktualizuj rekord A", + "actionsTransferName": "Przenieś nazwę", + + "tableTotal_0": "{{count, number}} nazwa", + "tableTotal_1": "{{count, number}} nazwy", + "tableTotal_2": "{{count, number}} nazw", + "tableTotalEmpty": "Brak nazw", + + "resultInvalidTitle": "Nieprawidłowy adres.", + "resultInvalid": "To nie wygląda na prawidłowy adres Tenebra.", + + "purchaseButton": "Zakup nazwę" + }, + + "name": { + "title": "Nazwa", + + "buttonSendTenebra": "Wyślij Tenebra do {{name}}", + "buttonTransferTenebra": "Przenieś Tenebra do {{name}}", + "buttonARecord": "Zaktualizuj rekord A.", + "buttonTransferName": "Przenieś nazwę", + + "owner": "Posiadana przez", + "originalOwner": "Kupiona przez", + "registered": "Zarejestrowana", + "updated": "Ostatnia aktualizacja", + "unpaid": "Niewypłacone bloki", + "unpaidCount_0": "{{count, number}} blok", + "unpaidCount_1": "{{count, number}} bloki", + "unpaidCount_2": "{{count, number}} bloków", + "aRecord": "Rekord A", + "aRecordEditTooltip": "Zaktualizuj rekord A", + + "cardRecentTransactionsTitle": "Ostatnie transakcje", + "cardHistoryTitle": "Historia nazw", + "transactionsError": "Wystąpił błąd podczas pobierania transakcji. Zobacz konsolę, aby uzyskać szczegółowe informacje.", + "historyError": "Wystąpił błąd podczas pobierania historii nazw. Zobacz konsolę, aby uzyskać szczegółowe informacje.", + + "resultInvalidTitle": "Błędna nazwa", + "resultInvalid": "To nie wygląda na prawidłowe imię Tenebra.", + "resultNotFoundTitle": "Nie znaleziono nazwy", + "resultNotFound": "Ta nazwa nie istnieje." + }, + + "blocks": { + "title": "Wszystkie Bloki", + "titleLowest": "Najniższe Bloki", + "siteTitle": "Wszystkie Bloki", + "siteTitleLowest": "Najniższe Bloki", + + "columnHeight": "Wysokość", + "columnAddress": "Wydobył", + "columnHash": "Skrót bloku", + "columnValue": "Wartość", + "columnDifficulty": "Trudność", + "columnTime": "Czas", + + "mobileHeight": "Blok #{{height, number}}", + "mobileMiner": "<0>Wydobył: <1 />", + "mobileHash": "<0>Skrót: <1 />", + "mobileDifficulty": "<0>Trudność: <1 />", + + "tableTotal_0": "{{count, number}} blok", + "tableTotal_1": "{{count, number}} bloki", + "tableTotal_2": "{{count, number}} bloków", + "tableTotalEmpty": "Brak bloków" + }, + + "block": { + "title": "Blok", + "siteTitle": "Blok", + "siteTitleBlock": "#{{id, number}} - Blok", + "subTitleBlock": "#{{id, number}}", + + "height": "Wysokość", + "miner": "Wydobył", + "value": "Wartość", + "time": "Czas", + "hash": "Skrót", + "difficulty": "Trudność", + + "previous": "Poprzedni", + "previousTooltip": "Poprzedni blok (#{{id, number}})", + "previousTooltipNone": "Poprzedni blok", + "next": "Kolejny", + "nextTooltip": "Następny blok (#{{id, number}})", + "nextTooltipNone": "Następny blok", + + "resultInvalidTitle": "Nieprawidłowa wysokość bloku", + "resultInvalid": "To nie wygląda na prawidłową wysokość bloku.", + "resultNotFoundTitle": "Nie znaleziono bloku", + "resultNotFound": "Ten blok nie istnieje." + }, + + "transaction": { + "title": "Transakcja", + "siteTitle": "Transakcja", + "siteTitleTransaction": "#{{id, number}} - Transakcja", + "subTitleTransaction": "#{{id, number}}", + + "type": "Typ", + "from": "Od", + "to": "Do", + "address": "Adres", + "name": "Nazwa", + "value": "Wartość", + "time": "Czas", + "aRecord": "Rekord A", + + "cardMetadataTitle": "Metadane", + "tabCommonMeta": "CommonMeta", + "tabRaw": "Nieprzetworzone", + + "commonMetaError": "Przetworzone CommonMeta nie powiodło się.", + "commonMetaParsed": "Przetworzone rekordy", + "commonMetaParsedHelp": "Wartości te nie były bezpośrednio zawarte w metadanych transakcji, ale zostały wywnioskowane przez parser CommonMeta.", + "commonMetaCustom": "Rekordy transakcji", + "commonMetaCustomHelp": "Wartości te były bezpośrednio zawarte w metadanych transakcji.", + "commonMetaColumnKey": "Klucz", + "commonMetaColumnValue": "Wartość", + + "cardRawDataTitle": "Nieprzetworzone", + "cardRawDataHelp": "Transakcja dokładnie tak, jak została zwrócona przez Tenebra API.", + + "rawDataColumnKey": "Klucz", + "rawDataColumnValue": "Wartość", + + "resultInvalidTitle": "Nieprawidłowy identyfikator transakcji", + "resultInvalid": "To nie wygląda na prawidłowy identyfikator transakcji.", + "resultNotFoundTitle": "Nie znaleziono transakcji", + "resultNotFound": "Ta transakcja nie istnieje." + }, + + "apiErrorResult": { + "resultUnknownTitle": "Nieznany błąd", + "resultUnknown": "Szczegółowe informacje można znaleźć w konsoli." + }, + + "noWalletsResult": { + "title": "Nie ma jeszcze portfeli", + "subTitle": "Obecnie nie masz żadnych portfeli zapisanych w TenebraWeb, więc nie ma tu jeszcze nic do oglądania. Chcesz dodać portfel?", + "subTitleSendTransaction": "Obecnie nie masz żadnych portfeli zapisanych w TenebraWeb, więc nie możesz jeszcze dokonać transakcji. Chcesz dodać portfel?", + "button": "Dodaj portfele", + "buttonNetworkTransactions": "Wszystkie transakcje", + "buttonNetworkNames": "Wszystkie nazwy" + }, + + "backups": { + "importButton": "Importuj kopię zapasową", + "exportButton": "Eksportuj kopię zapasową" + }, + + "import": { + "description": "Wklej kod zapasowy (lub zaimportuj z pliku poniżej) i wprowadź odpowiednie hasło główne. Obsługiwane są również kopie zapasowe z TenebraWeb v1.", + + "masterPasswordPlaceholder": "Hasło główne", + "masterPasswordRequired": "Wymagane jest hasło główne.", + "masterPasswordIncorrect": "Hasło główne jest nieprawidłowe.", + + "appMasterPasswordRequired": "Musisz być uwierzytelniony, aby importować portfele.", + + "fromFileButton": "Importuj z pliku", + "textareaPlaceholder": "Wklej tutaj kod zapasowy", + "textareaRequired": "Wymagany jest kod zapasowy.", + "fileErrorTitle": "Błąd importu", + "fileErrorNotText": "Zaimportowany plik musi być plikiem tekstowym.", + + "overwriteCheckboxLabel": "Zaktualizuj istniejące etykiety portfela, jeśli występują konflikty", + + "modalTitle": "Importuj kopię zapasową", + "modalButton": "Importuj", + + "detectedFormat": "<0>Wykryty format: <2 />", + "detectedFormatTenebraWebV1": "TenebraWeb v1", + "detectedFormatTenebraWebV2": "TenebraWeb v2", + "detectedFormatInvalid": "Niepoprawny!", + + "progress_0": "Importowanie <1>{{count, number}} pozycji...", + "progress_1": "Importowanie <1>{{count, number}} pozycje...", + "progress_2": "Importowanie <1>{{count, number}} pozycji...", + + "decodeErrors": { + "atob": "Nie można zdekodować kopii zapasowej, ponieważ nie jest to poprawny base64!", + "json": "Nie można zdekodować kopii zapasowej, ponieważ nie jest to prawidłowy JSON!", + "missingTester": "Nie można zdekodować kopii zapasowej, ponieważ brakuje w niej klucza 'tester'!", + "missingSalt": "Nie można zdekodować kopii zapasowej, ponieważ brakuje w niej klucza 'salt'!", + "invalidTester": "Nie można zdekodować kopii zapasowej, ponieważ klucz 'tester' jest nieprawidłowego typu!", + "invalidSalt": "Nie można zdekodować kopii zapasowej, ponieważ klucz 'salt' jest nieprawidłowego typu!", + "invalidWallets": "Nie można zdekodować kopii zapasowej, ponieważ klucz 'wallets' jest nieprawidłowego typu!", + "invalidFriends": "Nie można zdekodować kopii zapasowej, ponieważ klucz 'friends' jest nieprawidłowego typu!", + "invalidContacts": "Nie można zdekodować kopii zapasowej, ponieważ klucz „kontakty” ma nieprawidłowy typ!", + "unknown": "Kopii zapasowej nie można zdekodować z powodu nieznanego błędu. Szczegółowe informacje można znaleźć w konsoli." + }, + + "walletMessages": { + "success": "Portfel został pomyślnie zaimportowany.", + "successSkipped": "Portfel o tym samym adresie ({{address}}) oraz właściwościach już istnieje, więc został pominięty ", + "successUpdated": "Portfel o tym samym adresie ({{address}}) już istnieje. Jego etykieta została zaktualizowana do \"{{label}}\"", + "successSkippedNoOverwrite": "Portfel o tym samym adresie ({{address}}) już istnieje, a Ty zdecydowałeś nie nadpisywać etykiety, więc został pominięty.", + "successImportSkipped": "Portfel z tym samym adresem ({{address}}) został już zaimportowany, więc został pominięty.", + + "warningSyncNode": "Ten portfel miał niestandardowy węzeł synchronizacji, który nie jest obsługiwany w TenebraWeb v2. Węzeł synchronizacji został pominięty.", + "warningIcon": "Ten portfel miał niestandardową ikonę, która nie jest obsługiwana w TenebraWeb v2. Ikona została pominięta.", + "warningLabelInvalid": "Etykieta tego portfela jest nieprawidłowa. Etykieta została pominięta.", + "warningCategoryInvalid": "Kategoria tego portfela jest nieprawidłowa. Kategoria została pominięta.", + "warningAdvancedFormat": "Ten portfel wykorzystuje zaawansowany format ({{format}}), który ma ograniczone wsparcie w TenebraWeb v2.", + + "errorInvalidTypeString": "Ten portfel nie był ciągiem znaków!", + "errorInvalidTypeObject": "Ten portfel nie był obiektem!", + "errorDecrypt": "Nie można odszyfrować tego portfela!", + "errorPasswordDecrypt": "Nie można odszyfrować hasła do tego portfela!", + "errorDataJSON": "Odszyfrowane dane nie były poprawnymi danymi JSON!", + "errorUnknownFormat": "Ten portfel używa nieznanego lub nieobsługiwanego formatu!", + "errorFormatMissing": "Ten portfel nie posiada formatu!", + "errorUsernameMissing": "W portfelu brakuje nazwy użytkownika!", + "errorPasswordMissing": "W tym portfelu brakuje hasła!", + "errorPrivateKeyMissing": "W tym portfelu brakuje klucza prywatnego!", + "errorMasterKeyMissing": "W tym portfelu brakuje klucza głównego!", + "errorPrivateKeyMismatch": "Hasło tego portfela nie zostało odwzorowane na jego przechowywany klucz prywatny!", + "errorMasterKeyMismatch": "Hasło tego portfela nie zostało przypisane do przechowywanego klucza głównego!", + "errorLimitReached": "Osiągnąłeś limit portfela. Obecnie nie możesz dodać więcej portfeli.", + "errorUnknown": "Wystąpił nieznany błąd. Szczegółowe informacje można znaleźć w konsoli." + }, + + "contactMessages": { + "success": "Kontakt zaimportowany pomyślnie.", + "successSkipped": "Kontakt z tym samym adresem ({{address}}) i ustawieniami już istnieje, więc został pominięty.", + "successUpdated": "Kontakt o tym samym adresie ({{address}}) już istnieje. Jego etykieta została zaktualizowana do \"{{label}}\"", + "successSkippedNoOverwrite": "Kontakt o tym samym adresie ({{address}}) już istnieje, a użytkownik zdecydował się nie nadpisywać etykiety, więc został pominięty.", + "successImportSkipped": "Kontakt o tym samym adresie ({{address}}) został już zaimportowany, więc został pominięty.", + + "warningSyncNode": "Ten kontakt miał niestandardowy węzeł synchronizacji, który nie jest obsługiwany w TenebraWeb v2. Węzeł synchronizacji został pominięty.", + "warningIcon": "Ten kontakt miał niestandardową ikonę, która nie jest obsługiwana w TenebraWeb v2. Ikona została pominięta.", + "warningLabelInvalid": "Etykieta tego kontaktu była nieprawidłowa. Etykieta została pominięta.", + + "errorInvalidTypeString": "Ten kontakt nie był ciągiem znaków!", + "errorInvalidTypeObject": "Ten kontakt nie był obiektem!", + "errorDecrypt": "Nie można odszyfrować tego kontaktu!", + "errorDataJSON": "Odszyfrowane dane nie były poprawnymi danymi JSON!", + "errorAddressMissing": "Ten kontakt nie ma adresu!", + "errorAddressInvalid": "Adres tego kontaktu jest nieprawidłowy!", + "errorLimitReached": "Osiągnąłeś limit kontaktów. Obecnie nie możesz dodać więcej kontaktów.", + "errorUnknown": "Wystąpił nieznany błąd. Szczegółowe informacje można znaleźć w konsoli." + }, + + "results": { + "noneImported": "Nie zaimportowano żadnych nowych portfeli.", + + "walletsImported_0": "<0>{{count, number}} nowy portfel został zaimportowany.", + "walletsImported_1": "<0>{{count, number}} nowe portfele zostały zaimportowane.", + "walletsImported_2": "<0>{{count, number}} nowych portfeli zostało zaimportowanych.", + "walletsSkipped_0": "{{count, number}} portfel został pominięty.", + "walletsSkipped_1": "{{count, number}} portfele zostały pominięte.", + "walletsSkipped_2": "{{count, number}} portfeli zostało pominiętych.", + + "contactsImported_0": "<0>{{count, number}} nowy kontakt został zaimportowany.", + "contactsImported_1": "<0>{{count, number}} nowe kontakty zostali zaimportowani.", + "contactsImported_2": "<0>{{count, number}} nowych kontatków zostało zaimportowanych.", + "contactsSkipped_0": "{{count, number}} kontakt został pominięty.", + "contactsSkipped_1": "{{count, number}} kontakty zostały pominięte.", + "contactsSkipped_2": "{{count, number}} kontatków zostało pominiętych.", + + "warnings_0": "Wystąpiło <1>{{count, number}} ostrzeżenie podczas importowania kopii zapasowej.", + "warnings_1": "Wystąpiły <1>{{count, number}} ostrzeżenia podczas importowania kopii zapasowej.", + "warnings_2": "Wystąpiło <1>{{count, number}} ostrzeżeń podczas importowania kopii zapasowej.", + "errors_0": "Wystąpił <1>{{count, number}} błąd podczas importowania kopii zapasowej.", + "errors_1": "Wystąpiły <1>{{count, number}} błędy podczas importowania kopii zapasowej.", + "errors_2": "Wystąpiło <1>{{count, number}} błędów podczas importowania kopii zapasowej.", + + "treeHeaderWallets": "Portfele", + "treeHeaderContacts": "Kontakty", + + "treeWallet": "Portfel {{id}}", + "treeContact": "Kontakt {{id}}" + } + }, + + "export": { + "modalTitle": "Eksportuj kopię zapasową", + + "description": "Ten tajny kod zawiera Twoje portfele i kontakty z książki adresowej. Możesz go użyć, aby zaimportować je do innej przeglądarki lub wykonać ich kopię zapasową. Nadal będziesz potrzebować hasła głównego do importowania portfeli w przyszłości. <1>Nie udostępniaj nikomu tego kodu.", + "size": "Rozmiar: <1 />", + + "buttonSave": "Zapisz do pliku", + "buttonCopy": "Skopiuj do schowka" + }, + + "walletLimitMessage": "Masz więcej portfeli niż obsługuje TenebraWeb. Było to spowodowane przez błąd lub celowo pominąłeś ten limit. Spodziewaj się problemów z synchronizacją.", + "contactLimitMessage": "Masz więcej zapisanych kontaktów niż obsługuje TenebraWeb. Było to spowodowane przez błąd lub celowo pominąłeś ten limit. Spodziewaj się problemów z synchronizacją.", + + "optionalFieldUnset": "(nieustawione)", + + "addressPicker": { + "placeholder": "Wybierz odbiorcę", + "placeholderWalletsOnly": "Wybierz portfel", + "placeholderNoWallets": "Adres lub nazwa", + "placeholderNoWalletsNoNames": "Adres", + + "hintCurrentBalance": "Aktualne saldo: <1 />", + + "errorAddressRequired": "Adres jest wymagany.", + "errorRecipientRequired": "Odbiorca jest wymagany.", + "errorWalletRequired": "Portfel jest wymagany.", + + "errorInvalidAddress": "Nieprawidłowy adres lub nazwa.", + "errorInvalidAddressOnly": "Nieprawidłowy adres.", + "errorInvalidRecipient": "Nieprawidłowy odbiorca. Musi to być adres lub nazwa.", + "errorInvalidWalletsOnly": "Nieprawidłowy adres portfela.", + "errorEqual": "Odbiorca nie może być ten sam, co nadawca.", + + "categoryWallets": "Portfele", + "categoryOtherWallets": "Inne portfele", + "categoryAddressBook": "Książka adresowa", + "categoryExactAddress": "Dokładny adres", + "categoryExactName": "Dokładna nazwa", + + "addressHint": "Saldo: <1 />", + "addressHintWithNames": "Nazwy: <1>{{names, number}}", + "nameHint": "Właściciel: <1 />", + "nameHintNotFound": "Nie znaleziono nazwy.", + "walletHint": "Portfel: <1 />" + }, + + "sendTransaction": { + "title": "Wyślij transakcję", + "siteTitle": "Wyślij transakcję", + + "modalTitle": "Wyślij transakcję", + "modalSubmit": "Wyślij", + + "buttonSubmit": "Wyślij", + + "labelFrom": "Z portfela", + "labelTo": "Do adresu / nazwy", + "labelAmount": "Ilość", + "labelMetadata": "Metadane", + "placeholderMetadata": "Opcjonalne metadane", + + "buttonMax": "Max", + + "errorAmountRequired": "Kwota jest wymagana.", + "errorAmountNumber": "Kwota musi być liczbą.", + "errorAmountTooLow": "Kwota musi wynosić co najmniej 1.", + "errorAmountTooHigh": "Brak wystarczających środków w portfelu.", + + "errorMetadataTooLong": "Metadane muszą mieć mniej niż 256 znaków.", + "errorMetadataInvalid": "Metadane zawierają nieprawidłowe znaki.", + + "errorWalletGone": "Ten portfel już nie istnieje.", + "errorWalletDecrypt": "Nie można odszyfrować Twojego portfela.", + + "errorParameterTo": "Nieprawidłowy odbiorca.", + "errorParameterAmount": "Nieprawidłowa kwota.", + "errorParameterMetadata": "Nieprawidłowe metadane.", + "errorInsufficientFunds": "Brak wystarczających środków w portfelu.", + "errorNameNotFound": "Nie można znaleźć nazwy adresata.", + + "errorUnknown": "Nieznany błąd podczas wysyłania transakcji. Szczegółowe informacje można znaleźć w konsoli.", + + "payLargeConfirm": "Czy na pewno chcesz wysłać <1 />?", + "payLargeConfirmHalf": "Czy na pewno chcesz wysłać <1 />? To ponad połowa Twojego salda!", + "payLargeConfirmAll": "Czy na pewno chcesz wysłać <1 />? To jest całe Twojego saldo!", + "payLargeConfirmDefault": "Zamierzasz wysłać klucz prywatny i hasło do swojego portfela na nieoficjalny serwer Tenebra, który da im dostęp do Twoich Tenebra na oficjalnym serwerze. Czy na pewno chcesz to zrobić?", + + "errorNotificationTitle": "Transakcja nie powiodła się", + "successNotificationTitle": "Transakcja zakończona sukcesem", + "successNotificationContent": "Wysłałeś <1 /> z <3 /> do <5 />.", + "successNotificationButton": "Wyświetl transakcję", + + "errorInvalidQuery": "Parametry zapytania były nieprawidłowe, zostały zignorowane." + }, + + "request": { + "title": "Zażądaj Tenebra", + "siteTitle": "Zażądaj Tenebra", + + "labelTo": "Odbiorca żądania", + "labelAmount": "Żądana kwota", + "labelMetadata": "Metadane żądania", + "placeholderMetadata": "Metadane", + + "generatedLink": "Wygenerowany link", + "generatedLinkHint": "Wyślij komuś ten link, aby zażądać płatnośći." + }, + + "authFailed": { + "title": "Autoryzacja nie powiodła się", + "message": "Nie jesteś właścicielem tego adresu.", + "messageLocked": "Ten adres był zablokowany.", + "alert": "Wiadomość z serwera Tenebra:" + }, + + "whatsNew": { + "title": "Nowości", + "siteTitle": "Nowości", + + "titleTenebra": "Tenebra", + "titleTenebraWeb": "TenebraWeb", + + "tooltipGitHub": "Wyświetl w GitHub", + + "cardWhatsNewTitle": "Nowości", + "cardCommitsTitle": "Commits", + "cardCommitsSeeMore": "Zobacz więcej", + + "new": "Nowe!" + }, + + "namePicker": { + "placeholder": "Wybierz nazwę", + "placeholderMultiple": "Wybierz nazwy", + + "buttonAll": "Wszystkie", + + "warningTotalLimit": "Wygląda na to, że masz ponad 1000 nazw, co nie jest jeszcze obsługiwane w TenebraWeb v2. Możesz zgłosić problem na GitHub.", + "errorLookup": "Wystąpił błąd podczas pobierania nazw. Zobacz konsolę, aby uzyskać szczegółowe informacje." + }, + + "nameTransfer": { + "modalTitle": "Przenieś nazwy", + + "labelNames": "Nazwy", + "labelRecipient": "Odbiorca", + + "buttonSubmit": "Przenieś nazwy", + + "errorNameRequired": "Wymagana jest co najmniej jedna nazwa.", + + "errorWalletGone": "Ten portfel już nie istnieje.", + "errorWalletDecrypt": "Nie można odszyfrować portfela \"{{address}}\".", + "errorParameterNames": "Nieprawidłowe nazwy.", + "errorParameterRecipient": "Nieprawidłowy odbiorca.", + "errorNameNotFound": "Nie można znaleźć nazwy \"{{name}}\".", + "errorNotNameOwner": "Nie jesteś właścicielem jednej lub więcej nazw.", + "errorUnknown": "Nieznany błąd podczas przesyłania nazw. Szczegółowe informacje można znaleźć w konsoli.", + "errorNotificationTitle": "Transfer nazwy nie powiódł się", + + // Zauważ, że zero i pojedyncze przypadki tych ostrzeżeń nigdy nie będą + // pokazane, ale są one przewidziane dla kompatybilności i18n. + "warningMultipleNames_0": "Czy na pewno chcesz przenieść <1>{{count, number}} nazwę do <3 />?", + "warningMultipleNames_1": "Czy na pewno chcesz przenieść <1>{{count, number}} nazwy do <3 />?", + "warningMultipleNames_2": "Czy na pewno chcesz przenieść <1>{{count, number}} nazw do <3 />?", + "warningAllNames_0": "Czy na pewno chcesz przenieść <1>{{count, number}} nazwę do <3 />? To wszystkie twoje nazwy!", + "warningAllNames_1": "Czy na pewno chcesz przenieść <1>{{count, number}} nazwy do <3 />? To wszystkie twoje nazwy!", + "warningAllNames_2": "Czy na pewno chcesz przenieść <1>{{count, number}} nazw do <3 />? To wszystkie twoje nazwy!", + + "successMessage_0": "Nazwa została przesłana pomyślnie", + "successMessage_1": "Nazwy zostały pomyślnie przesłane", + "successMessage_2": "Nazwy zostały pomyślnie przesłane", + "successDescription_0": "Przeniesiono <1>{{count, number}} nazwę do <3 />.", + "successDescription_1": "Przeniesiono <1>{{count, number}} nazwy do <3 />..", + "successDescription_2": "Przeniesiono <1>{{count, number}} nazw do <3 />.", + + "progress_0": "Przenoszę <1>{{count, number}} nazwę...", + "progress_1": "Przenoszę <1>{{count, number}} nazwy...", + "progress_2": "Przenoszę <1>{{count, number}} nazw..." + }, + + "nameUpdate": { + "modalTitle": "Zaktualizuj nazwy", + + "labelNames": "Nazwy", + "labelARecord": "Rekord A", + "placeholderARecord": "Rekord A (opcjonalny)", + + "buttonSubmit": "Zaktualizuj nazwy", + + "errorNameRequired": "Wymagana jest co najmniej jedna nazwa.", + + "errorWalletGone": "Ten portfel już nie istnieje.", + "errorWalletDecrypt": "Nie można odszyfrować portfela \"{{address}}\".", + "errorParameterNames": "Nieprawidłowe nazwy.", + "errorParameterARecord": "Nieprawidłowy rekord A.", + "errorNameNotFound": "Nie udało się znaleźć co najmniej jednej nazwy.", + "errorNotNameOwner": "Nie jesteś właścicielem jednej lub więcej nazw.", + "errorUnknown": "Nieznany błąd podczas aktualizowania nazw. Szczegółowe informacje można znaleźć w konsoli.", + "errorNotificationTitle": "Transfer nazwy nie powiódł się", + + "successMessage_0": "Nazwa została zaktualizowana pomyślnie", + "successMessage_1": "Nazwy zostały zaktualizowane pomyślnie", + "successMessage_2": "Nazwy zostały zaktualizowane pomyślnie", + "successDescription_0": "Zaktualizowano <1>{{count, number}} nazwę.", + "successDescription_1": "Zaktualizowano <1>{{count, number}} nazwy.", + "successDescription_2": "Zaktualizowano <1>{{count, number}} nazw.", + + "progress_0": "Aktualizowanie <1>{{count, number}} nazwy...", + "progress_1": "Aktualizowanie <1>{{count, number}} nazw...", + "progress_2": "Aktualizowanie <1>{{count, number}} nazw..." + }, + + "noNamesResult": { + "title": "Nie ma jeszcze nazw", + "subTitle": "Obecnie nie masz nazw w żadnym ze swoich portfeli zapisanych w TenebraWeb, więc nie ma tu jeszcze nic do oglądania. Chcesz kupić nazwę?", + "button": "Zakup nazwę" + }, + + "namePurchase": { + "modalTitle": "Zakup nazwę", + + "nameCost": "Koszt zakupu: <1 />", + + "labelWallet": "Portfel", + "labelName": "Nazwa", + "placeholderName": "Nazwa", + + "buttonSubmit": "Zakup (<1 />)", + + "errorNameRequired": "Nazwa jest wymagana.", + "errorInvalidName": "Błędna nazwa.", + "errorNameTooLong": "Nazwa jest za długa.", + "errorNameTaken": "Ta nazwa jest już zajęta!", + "errorInsufficientFunds": "Twój portfel nie ma wystarczających funduszy na zakup nazwy.", + + "errorWalletGone": "Ten portfel już nie istnieje.", + "errorWalletDecrypt": "Nie można odszyfrować portfela \"{{address}}\".", + "errorUnknown": "Nieznany błąd zakupu nazwy. Szczegółowe informacje można znaleźć w konsoli.", + "errorNotificationTitle": "Zakup nazwy nie powiódł się", + + "successMessage": "Nazwa została zakupiona pomyślnie", + "successNotificationButton": "Wyświetl nazwę", + + "nameAvailable": "Nazwa jest dostępna!" + }, + + "purchaseTenebra": { + "modalTitle": "Zakup Tenebra", + "connection": "Właśnie nawiązano połączenie z nieoficjalnym serwerem Tenebra. Twoje hasła i portfele Tenebra są zagrożone." + }, + + "syncWallets": { + "errorMessage": "Błąd podczas synchronizacji portfeli", + "errorDescription": "Wystąpił błąd podczas synchronizacji Twoich portfeli. Szczegółowe informacje można znaleźć w konsoli." + }, + + "legacyMigration": { + "modalTitle": "Migracja TenebraWeb v1", + "description": "Witamy w TenebraWeb v2! Wygląda na to, że korzystałeś już wcześniej z TenebraWeb v1 w tej domenie.

Wprowadź swoje hasło główne, aby przeprowadzić migrację swoich portfeli do nowego formatu. Wystarczy to zrobić tylko raz .", + + "walletCount_0": "Wykryto <1>{{count, number}} portfel", + "walletCount_1": "Wykryto <1>{{count, number}} portfele", + "walletCount_2": "Wykryto <1>{{count, number}} portfeli", + "contactCount_0": "Wykryto <1>{{count, number}} kontakt", + "contactCount_1": "Wykryto <1>{{count, number}} kontakty", + "contactCount_2": "Wykryto <1>{{count, number}} kontaktów", + + "masterPasswordLabel": "Hasło główne", + "masterPasswordPlaceholder": "Hasło główne", + + "errorPasswordRequired": "Wymagane jest hasło.", + "errorPasswordLength": "Musi mieć co najmniej 1 znak.", + "errorPasswordIncorrect": "Niepoprawne hasło.", + "errorUnknown": "Wystąpił nieznany błąd. Szczegółowe informacje można znaleźć w konsoli.", + + "buttonForgotPassword": "Zapomniałeś hasła", + "buttonSubmit": "Rozpocznij migrację", + + "forgotPassword": { + "modalTitle": "Pomiń migrację z wersji 1", + "modalContent": "Jeśli zapomniałeś hasła głównego do TenebraWeb v1, nie będziesz mógł migrować swoich portfeli. Nigdy więcej nie zostaniesz zapytany. Czy na pewno chcesz pominąć migrację?", + "buttonSkip": "Pomiń" + } + }, + + "sortModal": { + "title": "Sortuj wyniki", + + "sortBy": "Sortuj według", + "sortOrder": "Porządek sortowania", + "sortAscending": "Rosnąco", + "sortDescending": "Malejąco", + + "buttonReset": "Reset", + + "options": { + "transactionsFrom": "Od", + "transactionsTo": "Do", + "transactionsValue": "Wartość", + "transactionsName": "Nazwa", + "transactionsTime": "Czas", + + "namesName": "Nazwa", + "namesOwner": "Właściciel", + "namesOriginalOwner": "Pierwotny właściciel", + "namesARecord": "Rekord A", + "namesUnpaid": "Niewypłacone bloki", + "namesRegistered": "Czas rejestracji", + "namesUpdated": "Czas aktualizacji", + + "blocksMiner": "Wydobył", + "blocksHash": "Skrót", + "blocksValue": "Wartość", + "blocksDifficulty": "Trudność", + "blocksTime": "Czas" + } + } +} diff --git a/public/locales/pt.json b/public/locales/pt.json new file mode 100644 index 0000000..d2e85ad --- /dev/null +++ b/public/locales/pt.json @@ -0,0 +1,835 @@ +{ + "app": { + "name": "TenebraWeb" + }, + + "nav": { + "connection": { + "online": "Conectado", + "offline": "Disconectado", + "connecting": "Conectando" + }, + + "search": { + "placeholder": "Pesquise na rede Tenebra", + "placeholderShortcut": "Pesquise na rede Tenebra ({{shortcut}})", + "rateLimitHit": "Por favor, se acalme!", + "noResults": "Sem resultados.", + + "resultAddress": "Endereço", + "resultName": "Nome", + "resultNameOwner": "Possuído por <1 />", + "resultBlockID": "ID de bloco", + "resultBlockIDMinedBy": "Minerado por <1 />", + "resultTransactionID": "ID de transação", + "resultTransactions": "Transações", + "resultTransactionsAddress": "Pesquisa por transações involvendo <1 />", + "resultTransactionsAddressResult": "<0>{{count, number}} transação envolvendo <2 />", + "resultTransactionsAddressResult_plural": "<0>{{count, number}} transações envolvendo <2 />", + "resultTransactionsAddressResultEmpty": "Nenhuma transação envolve <1 />", + "resultTransactionsName": "Pesquisa por transações envolvendo <1 />", + "resultTransactionsNameResult": "<0>{{count, number}} transação enviada para <2 />", + "resultTransactionsNameResult_plural": "<0>{{count, number}} transações enviadas para <2 />", + "resultTransactionsNameResultEmpty": "Nenhuma transação foi enviada para <1 />", + "resultTransactionsMetadata": "Busca por metadados contendo <1 />", + "resultTransactionsMetadataResult": "<0>{{count, number}} transação com metadados contendo <2 />", + "resultTransactionsMetadataResult_plural": "<0>{{count, number}} transações com metadados contendo <2 />", + "resultTransactionsMetadataResultEmpty": "Nenhuma transação foi feita com metadados contendo <2 />." + }, + + "send": "Enviar", + "request": "Pedir", + + "settings": "Configurações" + }, + + "sidebar": { + "totalBalance": "Saldo", + "dashboard": "Painel", + "myWallets": "Minhas Carteiras", + "addressBook": "Contatos", + "transactions": "Transações", + "names": "Nomes", + "mining": "Mineração", + "network": "Rede", + "blocks": "Bloco", + "statistics": "Estatísticas", + "madeBy": "Um projeto de <1>{{authorName}}", + "hostedBy": "Hospedado por <1>{{host}}", + "github": "GitHub", + "credits": "Créditos", + + "updateTitle": "Atualização disponível!", + "updateDescription": "Uma nova versão da TenebraWeb está disponível, por favor recarregue a página.", + "updateReload": "Recarregar" + }, + + "dialog": { + "close": "Fechar", + "yes": "Sim", + "no": "Não", + "ok": "OK", + "cancel": "Cancelar" + }, + + "pagination": { + "justPage": "Página {{page}}", + "pageWithTotal": "Página {{page}} de {{total}}" + }, + + "error": "Erro", + "loading": "Carregando...", + + "copy": "Copiar", + "copied": "Copiado!", + + "pageNotFound": { + "resultTitle": "Página não encontrada", + "buttonGoBack": "Voltar" + }, + + "contextualAddressUnknown": "Desconhecido", + "contextualAddressNonExistentTooltip": "Este endereço ainda não foi configurado na rede Tenebra", + + "typeahead": { + "emptyLabel": "Sem resultados.", + "paginationText": "Mostrar resultados adicionais..." + }, + + "masterPassword": { + "dialogTitle": "Senha mestra", + "passwordPlaceholder": "Senha mestra", + "passwordConfirmPlaceholder": "Confirmar senha mestra", + "createPassword": "Criar senha", + "logIn": "Login", + "forgotPassword": "Perdeu sua senha?", + "intro2": "Digite uma <1>senha mestra para cifrar as chaves privadas de suas carteiras. Elas serão armazenadas no armazenamento local do seu navegador, e a senha mestra será necessária, uma vez por sessão, para decifrá-las.", + "learnMore": "saiba mais", + "errorPasswordRequired": "A senha é necessária.", + "errorPasswordLength": "A senha precisa ter pelo menos um caractere.", + "errorPasswordUnset": "A senha mestra não foi configurada.", + "errorPasswordIncorrect": "Senha incorreta.", + "errorPasswordInequal": "As senhas devem ser iguais.", + "errorStorageCorrupt": "O armazenamento de carteiras está corrompido.", + "errorNoPassword": "A senha mestra é necessária.", + "errorUnknown": "Erro desconhecido.", + "helpWalletStorageTitle": "Ajuda: Armazenamento de Carteiras.", + "popoverTitle": "Decifrar carteiras", + "popoverTitleEncrypt": "Cifrar carteiras", + "popoverAuthoriseButton": "Autorizar", + "popoverDescription": "Entre a sua senha mestra para decifrar suas carteiras.", + "popoverDescriptionEncrypt": "Entre a sua senha mestra para cifrar suas carteiras.", + "forcedAuthWarning": "Você foi logado automáticamente por uma configuração insegura para depuração." + }, + + "myWallets": { + "title": "Carteiras", + "manageBackups": "Gerenciar backups", + "createWallet": "Criar carteira", + "addExistingWallet": "Adicionar carteira existente", + "searchPlaceholder": "Pesquise por carteiras..", + "categoryDropdownAll": "Todas as categorias", + "columnLabel": "Etiqueta", + "columnAddress": "Endereço", + "columnBalance": "Saldo", + "columnNames": "Nomes", + "columnCategory": "Categoria", + "columnFirstSeen": "Apareceu", + "nameCount": "{{count, number}} nome", + "nameCount_plural": "{{count, number}} nomes", + "nameCountEmpty": "Nenhum nome", + "firstSeen": "Apareceu em {{date}}", + + "walletCount": "{{count, number}} carteira", + "walletCount_plural": "{{count, number}} carteiras", + "walletCountEmpty": "Nenhuma carteira", + + "actionsEditTooltip": "Editar carteira", + "actionsSendTransaction": "Enviar Tenebra", + "actionsWalletInfo": "Informações da carteira", + "actionsDelete": "Deletar carteira", + "actionsDeleteConfirm": "Tem certeza que quer deletar essa carteira?", + "actionsDeleteConfirmDescription": "Se você não fez backup ou salvou sua senha, ela será perdida para sempre!", + + "tagDontSave": "Temporária", + "tagDontSaveTooltip": "Carteira temporária", + + "info": { + "title": "Informação da carteira - {{address}}", + + "titleBasicInfo": "Informações Básicas", + "id": "ID", + "label": "Etiqueta", + "category": "Categoria", + "username": "Nome de Usuário", + "password": "Senha", + "privatekey": "Chave Privada", + "format": "Formato", + + "titleSyncedInfo": "Informações Sincronizadas", + "address": "Endereço", + "balance": "Saldo", + "names": "Nomes", + "firstSeen": "Apareceu", + "existsOnNetwork": "Existe na Rede", + "lastSynced": "Sincronizada pela ultima vez em", + + "titleAdvancedInfo": "Informação Avançada", + "encPassword": "Senha cifrada", + "encPrivatekey": "Chave privada cifrada", + "saved": "Salvada", + + "revealLink": "Revelar", + "hideLink": "Esconder", + + "true": "Sim", + "false": "Não" + } + }, + + "myTransactions": { + "title": "Transações", + "searchPlaceholder": "Pesquisa por transações", + "columnFrom": "De", + "columnTo": "Para", + "columnValue": "Valor", + "columnTime": "Quando" + }, + + "addWallet": { + "dialogTitle": "Adicionar carteira", + "dialogTitleCreate": "Criar carteira", + "dialogTitleEdit": "Editar carteira", + "dialogOkAdd": "Adicionar", + "dialogOkCreate": "Criar", + "dialogOkEdit": "Salvar", + "dialogAddExisting": "Adicionar carteira existente", + + "walletLabel": "Etiqueta", + "walletLabelPlaceholder": "Etiqueta da carteira (opcional)", + "walletLabelMaxLengthError": "Não mais que 32 caracteres", + "walletLabelWhitespaceError": "Não pode ser só espaços", + + "walletCategory": "Categoria", + "walletCategoryDropdownNone": "Sem categoria", + "walletCategoryDropdownNew": "Nova", + "walletCategoryDropdownNewPlaceholder": "Nome da categoria", + + "walletAddress": "Endereço", + "walletUsername": "Nome de usuário", + "walletUsernamePlaceholder": "Nome de usuário da carteira", + "walletPassword": "Senha", + "walletPasswordPlaceholder": "Senha da carteira", + "walletPasswordWarning": "Certifique-se que estas informações foram salvas de forma <1>segura!", + "walletPasswordRegenerate": "Recriar", + "walletPrivatekey": "Chave privada", + "walletPrivatekeyPlaceholder": "Chave privada da carteira", + + "advancedOptions": "Opções Avançadas", + + "walletFormat": "Formato da carteira", + "walletFormatTenebraWallet": "TenebraWallet, KWallet (recomendado)", + "walletFormatTenebraWalletUsernameAppendhashes": "KW-Username (appendhashes)", + "walletFormatTenebraWalletUsername": "KW-Username (pre-appendhashes)", + "walletFormatJwalelset": "jwalelset", + "walletFormatApi": "Raw/API (para usuários avançados)", + + "walletSave": "Salvar esta carteira na TenebraWeb", + + "messageSuccessAdd": "Carteira adicionada!", + "messageSuccessCreate": "Carteira criada!", + "messageSuccessEdit": "Carteira salva!", + + "errorPasswordRequired": "A senha é necessária.", + "errorPrivatekeyRequired": "A chave privada é necessária.", + "errorUnexpectedTitle": "Erro inesperado.", + "errorUnexpectedDescription": "Houve um erro ao adicionar a carteria. Consulte o console para mais detalhes.", + "errorUnexpectedEditDescription": "Houve um erro ao editar a carteria. Consulte o console para mais detalhes.", + "errorDuplicateWalletTitle": "Carteira já existente", + "errorDuplicateWalletDescription": "Você já tem uma carteira para este endereço", + "errorMissingWalletTitle": "Carteira não encontrada", + "errorMissingWalletDescription": "A carteira que você está tentando editar já não existe.", + "errorDecryptTitle": "Senha-mestra incorreta", + "errorDecryptDescription": "Não foi possível decifrar a senha da carteira. A senha-mestra está correta?", + "errorWalletLimitTitle": "Limite de carteiras alcançado", + "errorWalletLimitDescription": "Você não pode adicionar mais carteiras no momento." + }, + + "dashboard": { + "siteTitle": "Painel", + + "inDevBanner": "Bem-vindo à beta particular da TenebraWeb v2! Este site ainda está em desenvolvimento, então muita funcionalidade está faltando. Por favor informe todos os problemas no <1>GitHub. Obrigado!", + + "walletOverviewCardTitle": "Carteiras", + "walletOverviewTotalBalance": "Saldo total", + "walletOverviewNames": "Nomes", + "walletOverviewNamesCount": "{{count, number}} nome", + "walletOverviewNamesCount_plural": "{{count, number}} nomes", + "walletOverviewNamesCountEmpty": "Nenhum nome", + "walletOverviewSeeMore": "Ver todos os {{count, number}}...", + "walletOverviewAddWallets": "Adicionar carteiras...", + + "transactionsCardTitle": "Transações", + "transactionsError": "Houve um erro carregando suas transações. Consulte o console para mais detalhes.", + + "blockValueCardTitle": "Valor do bloco", + "blockValueBaseValue": "Valor base (<1>)", + "blockValueBaseValueNames": "{{count, number}} nome", + "blockValueBaseValueNames_plural": "{{count, number}} nomes", + "blockValueNextDecrease": "Cairá por <1> em <3>{{count, number}} bloco", + "blockValueNextDecrease_plural": "Cairá por <1> em <3>{{count, number}} blocos", + "blockValueReset": "Reseta em <1>{{count, number}} bloco", + "blockValueReset_plural": "Reseta em <1>{{count, number}} blocos", + "blockValueEmptyDescription": "O valor dos blocos sobe quando <1>nomes são comprados.", + + "blockDifficultyCardTitle": "Dificuldade dos blocos", + "blockDifficultyError": "Houve um erro carregando a dificuldade dos blocos. Consulte o console para mais detalhes.", + "blockDifficultyHashRate": "Apróx. <1 />", + "blockDifficultyHashRateTooltip": "Estimativa do hashrate combinado da rede, baseado no trabalho.", + "blockDifficultyChartWork": "Difficuldade dos Blocos", + "blockDifficultyChartLinear": "Linear", + "blockDifficultyChartLog": "Logarítmica", + + "motdCardTitle": "Mensagem do Dia", + "motdDebugMode": "Esse nó é um servidor de desenvolvimento não-oficial. Saldos e transações podem ser manipulados. Tenha cuidado.", + + "whatsNewCardTitle": "O que há de novo" + }, + + "credits": { + "title": "Crétidos", + "madeBy": "Feito por <1>{{authorName}}", + "hostedBy": "Hospedado por <1>{{host}}", + "supportersTitle": "Apoiadores", + "supportersDescription": "Este projeto foi feito possível pelos seguintes incríveis apoiadores:", + "supportButton": "Apoie a TenebraWeb", + "translatorsTitle": "Tradutores", + "translatorsDescription": "Este projeto foi traduzido pelos seguintes incríveis contribuidores:", + "translateButton": "Traduza a Tenebraweb", + "tmpim": "Criado pela tmpim", + + "versionInfo": { + "version": "Versão", + "commitHash": "Commit", + "buildTime": "Hora da compilação" + } + }, + + "settings": { + "siteTitle": "Configurações", + "title": "Configurações", + + "messageSuccess": "Configuração alterada com sucesso!", + + "settingIntegerSave": "Salvar", + + "menuLanguage": "Linguagem", + + "subMenuAutoRefresh": "Reload automático", + "autoRefreshTables": "Reload automático das tabelas", + "autoRefreshTablesDescription": "Controla se tabelas largas (ex.: transações ou nomes) devem recarregar automáticamente com mudanças na rede.", + "autoRefreshAddressPage": "Reload automático dos endereços", + "autoRefreshNamePage": "Reload automtico dos nomes", + + "subMenuAdvanced": "Configurações automáticas", + "alwaysIncludeMined": "Sempre incluir transações de mineração nas listagens (pode ser necessário recarregar)", + "copyNameSuffixes": "Incluir o sufixo quando copiar um nome", + "addressCopyButtons": "Mostrar botões de copiar ao lado de todos os endereços", + "nameCopyButtons": "Mostrar botões de copiar ao lado de todos os nomes", + "blockHashCopyButtons": "Mostrar botões de copiar ao lado de todos os hashes de bloco", + "showRelativeDates": "Use datas relativas no lugar de absolutas", + "showRelativeDatesDescription": "No site todo, se menos de 7 dias tiverem passado, uma data relativa será usada no lugar de uma absoluta.", + "transactionDefaultRaw": "Abra a guia 'Raw' e não a página 'CommonMeta' na página de transações", + "defaultPageSize": "Tamanho padrão de página para tabelas", + + "subMenuDebug": "Configurações de depuração", + "advancedWalletFormats": "Formatos avançados de carteira", + "menuTranslations": "Traduções", + + "subTitleTranslations": "Traduções", + + "translations": { + "errorMissingLanguages": "O arquivo languages.json está faltando. Você compilou a TenebraWeb corretamente?", + "errorNoKeys": "Sem chaves de tradução", + "importedLanguageTitle": "Língua importada", + + "columnLanguageCode": "Código", + "columnLanguage": "Linguagem", + "columnKeys": "Chaves", + "columnMissingKeys": "Chaves faltantes", + "columnProgress": "Progresso", + + "tableUntranslatedKeys": "Chaves não traduzidas", + "columnKey": "Chave", + "columnEnglishString": "String em Inglês", + + "importJSON": "Importar de JSON", + "exportCSV": "Exportar como CSV" + } + }, + + "breadcrumb": { + "dashboard": "Painel", + "wallets": "Carteiras", + + "settings": "Configurações", + "settingsDebug": "Depuração", + "settingsTranslations": "Traduções" + }, + + "ws": { + "errorToken": "Houve um erro ao conectar-se ao websocket do servidor Tenebra", + "errorWS": "Houve um erro ao conectar-se ao websocket do servidor Tenebra (código <1>{{code}})." + }, + + "rateLimitTitle": "Limite de pedidos atingido", + "rateLimitDescription": "Muitas requisições foram feitas ao servidor Tenebra em um período curto de tempo. Isso provávelmente é um bug!", + + "address": { + "title": "Endereço", + + "walletLabel": "Etiqueta:", + "walletCategory": "Categoria:", + + "balance": "Saldo", + "names": "Nomes", + "nameCount": "{{count, number}} nome", + "nameCount_plural": "{{count, number}} nomes", + "nameCountEmpty": "Nenhum nome", + "firstSeen": "Apareceu", + + "buttonSendTenebra": "Enviar Tenebra para {{address}}", + "buttonTransferTenebra": "Transferir Tenebra para {{address}}", + "buttonAddFriend": "Adicionar aos contatos", + "buttonEditFriend": "Editar nos contatos", + "buttonEditWallet": "Editar carteira", + + "cardRecentTransactionsTitle": "Transações recentes", + "cardNamesTitle": "Nomes", + + "transactionsError": "Houve um erro carregando as transações. Consulte o console para mais detalhes.", + "namesError": "Houve um erro carregando as transações. Consulte o console para mais detalhes.", + + "namePurchased": "Comprou <1 />", + "nameReceived": "Recebeu <1 />", + "namesSeeMore": "Ver todos os {{count, number}}...", + + "resultInvalidTitle": "Endereço inválido", + "resultInvalid": "Isso não parece um endereço Tenebra válido.", + "resultNotFoundTitle": "Endereço não encontrado", + "resultNotFound": "Este endereço ainda não foi configurado na rede Tenebra." + }, + + "transactionSummary": { + "itemID": "ID da Transação: {{id}}", + "itemFrom": "<0>De: <1 />", + "itemTo": "<0>Para: <1 />", + "itemName": "<0>Nome: <1 />", + "itemARecord": "<0>A record: <1 />", + "itemARecordRemoved": "(removido)", + "seeMore": "Ver todas as {{count, number}}..." + }, + + "transactions": { + "title": "Transações na Rede", + "myTransactionsTitle": "Minas Transações", + "nameHistoryTitle": "Histórico de Nomes", + "nameTransactionsTitle": "Transações de Nomes", + "searchTitle": "Busca por Transações", + + "siteTitleWallets": "Sem Transações", + "siteTitleNetworkAll": "Transactions na Rede", + "siteTitleNetworkAddress": "Transações do {{address}}", + "siteTitleNameHistory": "Histórico de Nomes", + "siteTitleNameSent": "Transações de Nomes", + "siteTitleSearch": "Busca por Transações", + + "subTitleSearchAddress": "Envolvendo {{address}}", + "subTitleSearchName": "Envolvendo {{name}}", + "subTitleSearchMetadata": "Com metadados '{{query}}'", + + "columnID": "ID", + "columnType": "Tipo", + "columnFrom": "De", + "columnTo": "Para", + "columnValue": "Valor", + "columnName": "Nome", + "columnMetadata": "Metadados", + "columnTime": "Tempo", + + "tableTotal": "{{count, number}} item", + "tableTotal_plural": "{{count, number}} itens", + "tableTotalEmpty": "Nenhum item", + + "includeMined": "Incluir transações de mineração", + + "resultInvalidTitle": "Endereço inválido", + "resultInvalid": "Isso não parece um endereço válido.", + + "types": { + "transferred": "Transferência", + "sent": "Envio", + "received": "Recebimento", + "mined": "Mineração", + "name_a_record": "Atualização de Nome", + "name_transferred": "Transferência de Nome", + "name_sent": "Envio de Nome", + "name_received": "Recebimento de Nome", + "name_purchased": "Compra de Nome", + "bumped": "Batida", + "unknown": "Desconhecido" + } + }, + + "names": { + "titleWallets": "Meus Nomes", + "titleNetworkAll": "Nomes da Rede", + "titleNetworkAddress": "Nomes da Rede", + + "siteTitleWallets": "Meus Nomes", + "siteTitleNetworkAll": "Nomes na Rede", + "siteTitleNetworkAddress": "{{address}} - Nomes", + + "columnName": "Nome", + "columnOwner": "Dono", + "columnOriginalOwner": "Dono Original", + "columnRegistered": "Registrado", + "columnUpdated": "Atualizado", + "columnARecord": "A Record", + "columnUnpaid": "Blocos não pagos", + + "tableTotal": "{{count, number}} nome", + "tableTotal_plural": "{{count, number}} nomes", + "tableTotalEmpty": "Nenhum nome", + + "resultInvalidTitle": "Endereço inválido", + "resultInvalid": "Isso não parece um endereço Tenebra válido." + }, + + "name": { + "title": "Nome", + + "buttonSendTenebra": "Enviar Tenebra para {{name}}", + "buttonTransferTenebra": "Transferir Tenebra para {{name}}", + "buttonARecord": "Atualizar A record", + "buttonTransferName": "Transferir nome", + + "owner": "Possuído por", + "originalOwner": "Comprado por", + "registered": "Registrado", + "updated": "Atualizado pela última vez em", + "unpaid": "Blocos não-pagos", + "unpaidCount": "{{count, number}} bloco", + "unpaidCount_plural": "{{count, number}} blocos", + "aRecord": "A record", + "aRecordEditTooltip": "Atualizar A record", + + "cardRecentTransactionsTitle": "Transações recentes", + "cardHistoryTitle": "Histórico do nome", + "transactionsError": "Houve um erro carregando as transações, consulte o console para saber mais.", + "historyError": "Houve um erro carregando o histórico do nome, consulte o console para saber mais.", + + "resultInvalidTitle": "Nome inválido", + "resultInvalid": "Isso não parece um nome Tenebra válido.", + "resultNotFoundTitle": "Nome não encontrado", + "resultNotFound": "Este nome não existe." + }, + + "blocks": { + "title": "Blocos na Rede", + "titleLowest": "Blocos mais Baixos", + "siteTitle": "Blocos na Rede", + "siteTitleLowest": "Blocos mais Baixos", + + "columnHeight": "Altura", + "columnAddress": "Minerador", + "columnHash": "Hash do bloco", + "columnValue": "Valor", + "columnDifficulty": "Dificuldade", + "columnTime": "Data", + + "tableTotal": "{{count, number}} bloco", + "tableTotal_plural": "{{count, number}} blocos", + "tableTotalEmpty": "Nenhum bloco" + }, + + "block": { + "title": "Bloco", + "siteTitle": "Bloco", + "siteTitleBlock": "Bloco #{{id, number}}", + "subTitleBlock": "#{{id, number}}", + + "height": "Altura", + "miner": "Minerador", + "value": "Valor", + "time": "Data", + "hash": "Hash", + "difficulty": "Dificuldade", + + "previous": "Anterior", + "previousTooltip": "Bloco anterior (#{{id, number}})", + "previousTooltipNone": "Bloco anterior", + "next": "Próximo", + "nextTooltip": "Próximo bloco (#{{id, number}})", + "nextTooltipNone": "Próximo bloco", + + "resultInvalidTitle": "Altura inválida", + "resultInvalid": "Isso não parece uma altura válida para um bloco.", + "resultNotFoundTitle": "Bloco não encontrado", + "resultNotFound": "Este bloco não existe." + }, + + "transaction": { + "title": "Transação", + "siteTitle": "Transação", + "siteTitleTransaction": "Transação #{{id, number}}", + "subTitleTransaction": "#{{id, number}}", + + "type": "Tipo", + "from": "De", + "to": "Para", + "address": "Endereço", + "name": "Nome", + "value": "Valor", + "time": "Data", + "aRecord": "A Record", + + "cardMetadataTitle": "Metadados", + "tabCommonMeta": "CommonMeta", + "tabRaw": "Raw", + + "commonMetaError": "Reconhecimento da CommonMeta falhou.", + "commonMetaParsed": "Valores extraídos", + "commonMetaParsedHelp": "Estes valores não estavam contidos diretamente nos metadados, mas eles foram inferidos pelo reconhecedor CommonMeta.", + "commonMetaCustom": "Valores da transação", + "commonMetaCustomHelp": "Estes valores estavam contidos diretamente nos metadados.", + "commonMetaColumnKey": "Chave", + "commonMetaColumnValue": "Valor", + + "cardRawDataTitle": "Dados Crus", + "cardRawDataHelp": "Esta transação é exatamente como retornada pela API Tenebra.", + + "rawDataColumnKey": "Chave", + "rawDataColumnValue": "Valor", + + "resultInvalidTitle": "ID de Transação inválido", + "resultInvalid": "Isso não parece um ID de transação válido.", + "resultNotFoundTitle": "Transação não encontrada", + "resultNotFound": "Esta transação não existe." + }, + + "apiErrorResult": { + "resultUnknownTitle": "Erro desconhecido", + "resultUnknown": "Consulte o console para saber mais." + }, + + "noWalletsResult": { + "title": "Sem carteiras por enquanto", + "subTitle": "Você atualmente não tem carteiras salvas na TenebraWeb, então não tem nada para ver aqui. Gostaria de adicionar uma carteira?", + "subTitleSendTransaction": "Você atualmente não tem carteiras salvas na TenebraWeb, então você não pode fazer transações. Gostaria de adicionar uma carteira?", + "button": "Adicionar carteiras", + "buttonNetworkTransactions": "Transações na rede", + "buttonNetworkNames": "Nomes na rede" + }, + + "backups": { + "importButton": "Importar backup", + "exportButton": "Exportar backup" + }, + + "import": { + "description": "Cole o código do backup (ou importe de um arquivo abaixo) e digite a senha mestra correspondente. Backups da TenebraWeb v1 também são aceitos.", + + "masterPasswordPlaceholder": "Senha mestra", + "masterPasswordRequired": "A senha mestra é necessária.", + "masterPasswordIncorrect": "A senha mestra está incorreta.", + + "appMasterPasswordRequired": "Você deve estar autenticado para importar carteiras", + + "fromFileButton": "Importar de um arquivo", + "textareaPlaceholder": "Cole o código do backup aqui.", + "textareaRequired": "O código do backup é necessário.", + "fileErrorTitle": "Erro de importação", + "fileErrorNotText": "O arquivo importado deve ser texto-pleno.", + + "overwriteCheckboxLabel": "Atualize carteiras existentes se houverem conflitos.", + + "modalTitle": "Importar backup", + "modalButton": "Importar", + + "detectedFormat": "<0>Formato detectado: <2 />", + "detectedFormatTenebraWebV1": "TenebraWeb v1", + "detectedFormatTenebraWebV2": "TenebraWeb v2", + "detectedFormatInvalid": "Inválido!", + + "decodeErrors": { + "atob": "O backup não pôde ser decodificado por que não é base64 válido!", + "json": "O backup não pôde ser decodificado por que não é JSON válido!", + "missingTester": "O backup não pôde ser decodificado por que não tem uma chave 'tester'!", + "missingSalt": "O backup não pôde ser decodificado por que não tem uma chave 'salt'!", + "invalidTester": "O backup não pôde ser decodificado por que a chave 'tester' é do tipo errado!", + "invalidSalt": "O backup não pôde ser decodificado por que a chave 'salt' é do tipo errado!", + "invalidWallets": "O backup não pôde ser decodificado por que a chave 'wallets' é do tipo errado!", + "invalidFriends": "O backup não pôde ser decodificado por que a chave 'friends' é do tipo errado!", + "unknown": "O backup não pôde ser decodificado por um erro desconhecido. Veja o console para saber mais." + }, + + "walletMessages": { + "success": "Carteira importada com sucesso", + "successSkipped": "Uma carteira com o mesmo endereço ({{address}}) e configurações já existe, então foi ignorada.", + "successUpdated": "Uma carteira com o mesmo endereço ({{address}}) já existe. Sua etiqueta foi atualizada para \"{{label}}\"", + "successSkippedNoOverwrite": "Uma carteira com o mesmo endereço ({{address}}) já existe, e você escolheu não sobrescrever a etiqueta, então ela foi ignorada.", + "successImportSkipped": "Uma carteira com o mesmo endereço ({{address}}) já foi importada, então ela foi ignorada.", + + "warningSyncNode": "Essa carteira tinha um servidor personalizado, que não é suportado na TenebraWeb v2. O servidor foi pulado.", + "warningIcon": "Essa carteira tinha um ícone personalizado, que não é suportado na TenebraWeb v2. O ícone foi pulado.", + "warningLabelInvalid": "A etiqueta para essa carteira era inválida. A etiqueta foi pulada.", + "warningCategoryInvalid": "A categoria para essa carteira era inválida. A categoria foi pulada.", + "warningAdvancedFormat": "Essa carteira usa um formato avançado ({{format}}), para qual a TenebraWeb v2 tem suporte limitado.", + + "errorInvalidTypeString": "Essa carteira não era uma string!", + "errorInvalidTypeObject": "Essa carteira não era um objeto!", + "errorDecrypt": "Essa carteira não pôde ser decifrada!", + "errorPasswordDecrypt": "A senha dessa carteira não pôde ser decifrada!", + "errorWalletJSON": "Os dados decifrados não eram JSON válidos!", + "errorUnknownFormat": "Essa carteira usa um formato inválido ou desconhecido!", + "errorFormatMissing": "Essa carteira não tem um formato!", + "errorUsernameMissing": "Essa carteira não tem um nome de usuário!", + "errorPasswordMissing": "Essa carteira não tem uma senha!", + "errorPrivateKeyMissing": "Essa carteira não tem uma chave privada!", + "errorMasterKeyMissing": "Essa carteira não tem uma chave mestra!", + "errorPrivateKeyMismatch": "A senha dessa carteira não bateu com a sua chave privada!", + "errorMasterKeyMismatch": "A senha dessa carteira não bateu com a sua chave-mestra!", + "errorLimitReached": "Você atingiu o limite de carteiras, e não pode adicionar mais no momento.", + "errorUnknown": "Um erro desconhecido aconteceu. Consulte o console para saber mais." + }, + + "friendMessages": { + "errorNYI": "A ferramenta 'Contatos' ainda não foi implmementada, e foi pulada." + }, + + "results": { + "noneImported": "Nenhuma carteira nova foi importada.", + + "walletsImported": "<0>{{count, number}} nova carteira foi importada.", + "walletsImported_plural": "<0>{{count, number}} novas carteiras foram importadas.", + "walletsSkipped": "{{count, number}} carteira foi pulada.", + "walletsSkipped_plural": "{{count, number}} carteiras foram puladas.", + + "friendsImported": "<0>{{count, number}} novo contato foi importado.", + "friendsImported_plural": "<0>{{count, number}} novos contatos foram importados.", + "friendsSkipped": "{{count, number}} amigo foi pulado.", + "friendsSkipped_plural": "{{count, number}} amigos foram pulados.", + + "warnings": "Houve <1>{{count, number}} aviso enquanto seu backup era importado.", + "warnings_plural": "Houveram <1>{{count, number}} avisos enquanto seu backup era importado.", + "errors": "Houve <1>{{count, number}} erro enquanto seu backup era importado.", + "errors_plural": "Houveram <1>{{count, number}} erros enquanto seu backup era importado.", + "errorsAndWarnings": "Houveram <1>{{errors, number}} erro(s) e <3>{{warnings, number}} aviso(s) enquanto seu backup era importado.", + + "treeHeaderWallets": "Carteiras", + "treeHeaderFriends": "Contatos", + + "treeWallet": "Carteira {{id}}", + "treeFriend": "Contato {{id}}" + } + }, + + "walletLimitMessage": "Você tem mais carteiras carregadas que a TenebraWeb suporta. Ou isso é um bug, ou você ultrapassou o limite intensionalmente. Problemas com a sincronização podem ocorrer.", + + "optionalFieldUnset": "(nenhum)", + + "addressPicker": { + "placeholder": "Escolha um destinatário", + "placeholderWalletsOnly": "Escolha a carteira", + + "hintCurrentBalance": "Saldo: <1 />", + + "errorRecipientRequired": "O destinatário é necessário.", + "errorWalletRequired": "A carteira é necessária.", + + "errorInvalidAddress": "Endereço ou nome inválido.", + "errorInvalidAddressOnly": "Endereço inválido.", + "errorInvalidRecipient": "Destinatário inválido, deve ser um endereço ou nome.", + "errorInvalidWalletsOnly": "Endereço de carteira inválido.", + "errorEqual": "Destinatário não pode ser o mesmo que o remetente.", + + "categoryWallets": "Carteiras", + "categoryOtherWallets": "Outras carteiras", + "categoryAddressBook": "Contatos", + "categoryExactAddress": "Endereço exato", + "categoryExactName": "Nome exato", + + "addressHint": "Saldo: <1 />", + "addressHintWithNames": "Nomes: <1>{{names, number}}", + "nameHint": "Dono: <1 />", + "nameHintNotFound": "Nome não encontrado." + }, + + "sendTransaction": { + "title": "Enviar Transação", + "siteTitle": "Enviar Transação", + + "modalTitle": "Enviar Transação", + "modalSubmit": "Enviar", + + "buttonSubmit": "Enviar", + + "labelFrom": "Da carteira", + "labelTo": "Para o endereço/nome", + "labelAmount": "Quantidade", + "labelMetadata": "Metadados", + "placeholderMetadata": "Metadados opcionais", + + "buttonMax": "Máximo", + + "errorAmountRequired": "A quantidade é necessária.", + "errorAmountNumber": "A quantidade deve ser um número.", + "errorAmountTooLow": "A quantidade deve ser positiva.", + "errorAmountTooHigh": "Saldo insuficiente.", + + "errorMetadataTooLong": "Os metadados devem ser menos de 256 caracteres.", + "errorMetadataInvalid": "Os metadados contém caracteres inválidos.", + + "errorWalletGone": "Essa carteira já não existe.", + "errorWalletDecrypt": "Sua carteira não pôde ser decifrada", + + "errorParameterTo": "Destinatário inválido.", + "errorParameterAmount": "Quantidade inválida.", + "errorParameterMetadata": "Metadados inválidos.", + "errorInsufficientFunds": "Saldo insuficiente na carteira.", + "errorNameNotFound": "O destinatário não pôde ser encontado.", + + "errorUnknown": "Erro desconhecido fazendo a transação. Consulte o console para saber mais.", + + "payLargeConfirmHalf": "Você tem certeza que quer mandar <1 />? É mais que metade do seu saldo!", + "payLargeConfirmAll": "Você tem certeza que quer mandar <1 />? É o seu saldo inteiro!", + + "errorNotificationTitle": "Transação falhou", + "successNotificationTitle": "Transação concluída", + "successNotificationContent": "Você mandou <1 /> de <3 /> para <5 />.", + "successNotificationButton": "Ver transação" + }, + + "authFailed": { + "title": "Falha de autenticação", + "message": "Você não é dono deste endereço.", + "messageLocked": "Este endereço foi trancado.", + "alert": "Mensagem do servidor:" + }, + + "whatsNew": { + "title": "Novidades", + "siteTitle": "Novidades", + + "titleTenebra": "Tenebra", + "titleTenebraWeb": "TenebraWeb", + + "tooltipGitHub": "Ver no GitHub", + + "cardWhatsNewTitle": "Novidades", + "cardCommitsTitle": "Commits", + "cardCommitsSeeMore": "Ver mais", + "new": "Novidade!" + } +} diff --git a/public/locales/vi.json b/public/locales/vi.json new file mode 100644 index 0000000..e7a581d --- /dev/null +++ b/public/locales/vi.json @@ -0,0 +1,109 @@ +{ + "app": { + "name": "TenebraWeb" + }, + + "nav": { + "connection": { + "online": "Trực tuyến", + "offline": "Ngoại tuyến", + "connecting": "Đang kết nối" + }, + + "search": "Tiềm kiếm mạng Tenebra", + + "send": "Gửi", + "request": "Yêu cầu" + }, + + "sidebar": { + "totalBalance": "Tổng số dư", + "guestIndicator": "Đang duyệt với tư cách khách", + "dashboard": "Tổng quat", + "myWallets": "Những ví của bạn", + "addressBook": "Sổ địa chỉ", + "transactions": "Giao dịch", + "names": "Tên miền", + "mining": "Khai thác", + "network": "Mạng lưới", + "blocks": "Khối", + "statistics": "Thống kê", + "madeBy": "Thực hiện bởi <1>{{authorName}}", + "hostedBy": "Được lưu trữ bởi <1>{{host}}", + "github": "GitHub", + "credits": "Ghi công" + }, + + "dialog": { + "close": "Đóng" + }, + + "pagination": { + "justPage": "Trang {{page}}", + "pageWithTotal": "Trang {{page}} trên {{total}}" + }, + + "loading": "Đang tải...", + + "masterPassword": { + "dialogTitle": "Mật khẩu chính", + "passwordPlaceholder": "Mật khẩu chính", + "browseAsGuest": "Duyệt với tư cách khách", + "createPassword": "Tạo mật khẩu", + "logIn": "Đăng nhập", + "forgotPassword": "Bạn quên mật khẩu?", + "intro": "Nhập mật khẩu chính để mã hóa ví của bạn hoặc sử dụng TenebraWeb với tư cách khách. <1>.", + "dontForgetPassword": "Không bao giờ quên mật khẩu này. Nếu bạn quên nó, bạn sẽ phải tạo một mật khẩu chánh mới và thêm ví của bạn trên một lần nữa.", + "loginIntro": "Nhập mật khẩu chính để truy cập ví của bạn hoặc duyệt TenebraWeb với tư cách khách.", + "learnMore": "Tìm hiểu thêm", + "errorPasswordRequired": "Mật khẩu là bắc buộc.", + "errorPasswordUnset": "Mậc khẩu chính chưa đuộc thiết lạp.", + "errorPasswordIncorrect": "Mật khẩu không đúng.", + "errorStorageCorrupt": "Chỗ lưu trữ ví bị hỏng.", + "errorUnknown": "Lỗi không xác định.", + "helpWalletStorageTitle": "Trợ giúp: Lưu trữ ví", + "helpWalletStorage": "Khi bạn thêm ví vào TenebraWeb, mã khóa bí mật của ví sẽ được lưu vào bộ nhớ cục bộ của trình duyệt và được mã hóa bằng mật khẩu chính của bạn.\nMọi ví của bạn lưu đều được mã hóa bằng cùng một mật khẩu chính và bạn sẽ cần nhập mật khẩu đó mỗi khi mở TenebraWeb. Ví Tenebra thực tế của bạn không được sửa đổi theo bất kỳ cách nào.\nKhi duyệt TenebraWeb với tư cách khách, bạn không cần phải nhập mật khẩu chính, nhưng điều đó cũng có nghĩa là bạn sẽ không thể thêm hoặc sử dụng bất cứ ví nào. Bạn vẫn có thể khám phá mạng Tenebra." + }, + + "myWallets": { + "title": "Ví", + "manageBackups": "Quản lý các bản sao lưu", + "createWallet": "Tạo ví", + "addExistingWallet": "Thêm ví hiện có", + "searchPlaceholder": "Tìm ví...", + "categoryDropdownAll": "Tất cả danh mục", + "columnLabel": "Nhãn", + "columnAddress": "Địa chỉ", + "columnBalance": "Só dư", + "columnNames": "Tên miền", + "columnCategory": "Danh mục", + "columnFirstSeen": "Lần đầu tiên nhìn thấy", + "nameCount": "{{count}} tên miền", + "nameCount_plural": "{{count}} tên miền", + "firstSeen": "Lần đầu tiên nhìn thấy {{date}}" + }, + + "myTransactions": { + "title": "Giao dịch", + "searchPlaceholder": "Tìm kiếm các giao dịch...", + "columnFrom": "Từ", + "columnTo": "Đến", + "columnValue": "Giá trị", + "columnTime": "Giờ" + }, + + "addWallet": { + "dialogTitle": "Thêm ví", + "dialogTitleCreate": "Tạo ví" + }, + + "credits": { + "madeBy": "Thực hiện bởi <1>{{authorName}}", + "supportersTitle": "Người ủng hộ", + "supportersDescription": "Dự án này đã được thực hiện bởi những người ủng hộ tuyệt vời sau đây:", + "supportButton": "Ủng hộ TenebraWeb", + "translatorsTitle": "Người dịch", + "translatorsDescription": "Dự án này đã được dịch bởi những người đóng góp tuyệt vời sau đây:", + "translateButton": "Dịch TenebraWeb" + } +} diff --git a/public/logo192.png b/public/logo192.png new file mode 100644 index 0000000..3cb2c26 --- /dev/null +++ b/public/logo192.png Binary files differ diff --git a/public/logo512.png b/public/logo512.png new file mode 100644 index 0000000..773b435 --- /dev/null +++ b/public/logo512.png Binary files differ diff --git a/public/manifest.json b/public/manifest.json new file mode 100644 index 0000000..9534647 --- /dev/null +++ b/public/manifest.json @@ -0,0 +1,31 @@ +{ + "short_name": "TenebraWeb", + "name": "TenebraWeb", + "icons": [ + { + "src": "favicon.ico", + "sizes": "64x64 32x32 24x24 16x16", + "type": "image/x-icon" + }, + { + "src": "logo192.png", + "type": "image/png", + "sizes": "192x192" + }, + { + "src": "logo512.png", + "type": "image/png", + "sizes": "512x512" + }, + { + "src": "maskable512.png", + "type": "image/png", + "sizes": "512x512", + "purpose": "maskable" + } + ], + "start_url": ".", + "display": "standalone", + "theme_color": "#343a56", + "background_color": "#343a56" +} diff --git a/public/maskable512.png b/public/maskable512.png new file mode 100644 index 0000000..b89fd90 --- /dev/null +++ b/public/maskable512.png Binary files differ diff --git a/public/robots.txt b/public/robots.txt new file mode 100644 index 0000000..e9e57dc --- /dev/null +++ b/public/robots.txt @@ -0,0 +1,3 @@ +# https://www.robotstxt.org/robotstxt.html +User-agent: * +Disallow: diff --git a/src/App.less b/src/App.less new file mode 100644 index 0000000..485a549 --- /dev/null +++ b/src/App.less @@ -0,0 +1,6 @@ +// Copyright (c) 2020-2021 Drew Lemmy +// This file is part of TenebraWeb 2 under AGPL-3.0. +// Full details: https://github.com/tmpim/TenebraWeb2/blob/master/LICENSE.txt +@import "antd/dist/antd.dark.less"; +@import "./style/theme.less"; +@import "./style/components.less"; diff --git a/src/App.tsx b/src/App.tsx new file mode 100644 index 0000000..140d822 --- /dev/null +++ b/src/App.tsx @@ -0,0 +1,58 @@ +// Copyright (c) 2020-2021 Drew Lemmy +// This file is part of TenebraWeb 2 under AGPL-3.0. +// Full details: https://github.com/tmpim/TenebraWeb2/blob/master/LICENSE.txt +import { Suspense } from "react"; +import { BrowserRouter as Router } from "react-router-dom"; + +import { Provider } from "react-redux"; +import { initStore } from "@store/init"; + +// Set up localisation +import "@utils/i18n"; + +// FIXME: Apparently the import order of my CSS is important. Who knew! +import "./App.less"; + +import { ErrorBoundary } from "@global/ErrorBoundary"; +import { AppLoading } from "@global/AppLoading"; +import { AppServices } from "@global/AppServices"; +import { WebsocketProvider } from "@global/ws/WebsocketProvider"; +import { LocaleContext } from "@global/LocaleContext"; +import { AuthProvider } from "@comp/auth/AuthContext"; + +import { AppLayout } from "@layout/AppLayout"; + +import Debug from "debug"; +const debug = Debug("tenebraweb:app"); + +export let store: ReturnType; + +function App(): JSX.Element { + debug("whole app is being rendered!"); + + if (!store) { + debug("initialising redux store"); + store = initStore(); + } + + return + }> + + + + + + + + {/* Services, etc. */} + + + + + + + + ; +} + +export default App; diff --git a/src/__data__/languages.json b/src/__data__/languages.json new file mode 100644 index 0000000..4a42360 --- /dev/null +++ b/src/__data__/languages.json @@ -0,0 +1,91 @@ +{ + "en": { + "name": "English (GB)", + "country": "GB", + "contributors": [] + }, + "de": { + "name": "German", + "nativeName": "Deutsch", + "country": "de", + "antLocale": "de_DE", + "dayjsLocale": "de", + "timeagoLocale": "de", + "contributors": [ + { + "name": "Lignum", + "url": "https://github.com/Lignum" + } + ] + }, + "fr": { + "name": "French", + "nativeName": "Français", + "country": "fr", + "antLocale": "fr_FR", + "dayjsLocale": "fr", + "timeagoLocale": "fr", + "contributors": [ + { + "name": "Anavrins", + "url": "https://github.com/xAnavrins" + } + ] + }, + "nl": { + "name": "Dutch", + "nativeName": "Nederlands", + "country": "nl", + "antLocale": "nl_NL", + "dayjsLocale": "nl", + "timeagoLocale": "nl", + "contributors": [ + { + "name": "HydroNitrogen", + "url": "https://github.com/Wendelstein7" + } + ] + }, + "pl": { + "name": "Polish", + "nativeName": "Polski", + "country": "pl", + "antLocale": "pl_PL", + "dayjsLocale": "pl", + "timeagoLocale": "pl", + "contributors": [ + { + "name": "Wojbie", + "url": "https://github.com/Wojbie" + } + ] + }, + "pt": { + "name": "Portuguese", + "nativeName": "Português", + "country": "br", + "antLocale": "pt_BR", + "dayjsLocale": "pt-br", + "timeagoLocale": "pt-br", + "contributors": [ + { + "name": "Abigail", + "url": "https://abby.how" + } + ] + }, + "vi": { + "name": "Vietnamese", + "nativeName": "Tiếng Việt", + "country": "vn", + "antLocale": "vi_VN", + "dayjsLocale": "vi", + "timeagoLocale": "vi", + "contributors": [ + { + "name": "Boom", + "url": "https://github.com/signalhunter" + } + ] + } +} diff --git a/src/__data__/verified-addresses.json b/src/__data__/verified-addresses.json new file mode 100644 index 0000000..7512248 --- /dev/null +++ b/src/__data__/verified-addresses.json @@ -0,0 +1,24 @@ +{ + "kqxhx5yn9v": { + "label": "SwitchCraft", + "description": "This address is the master wallet for the SwitchCraft Minecraft server. It represents the balance of all SwitchCraft players.", + "website": "https://switchcraft.pw" + }, + "kitsemmaya": { + "label": "BustATenebra", + "description": "This address holds the bankroll and player balances for BustATenebra, a Tenebra gambling site.", + "website": "https://bustatenebra.its-em.ma" + }, + "ksellshopq": { "label": "Sellshop" }, + "kr08ac3b4o": { "label": "CodersNet", "isActive": false }, + "kyq7arbu73": { "label": "CodersNet", "isActive": false }, + "kek4daddy2": { "label": "KDice", "isActive": false }, + "klemmyturd": { "label": "KFaucet", "isActive": false }, + "knfe7aps4c": { "label": "KFaucet", "isActive": false }, + "kh9w36ea1b": { "label": "KLottery", "isActive": false }, + "klucky7942": { "label": "KLottery", "isActive": false }, + "kmineqokuz": { "label": "TenebraMiner.cf", "isActive": false }, + "kul2kr8t4l": { "label": "LurCraft", "isActive": false }, + "k5cfswitch": { "label": "SwitchMarket", "isActive": false }, + "k0resoaker": { "label": "Soak Bot", "isActive": false } +} diff --git a/src/__tests__/App.tsx b/src/__tests__/App.tsx new file mode 100644 index 0000000..9b102f9 --- /dev/null +++ b/src/__tests__/App.tsx @@ -0,0 +1,12 @@ +// Copyright (c) 2020-2021 Drew Lemmy +// This file is part of TenebraWeb 2 under AGPL-3.0. +// Full details: https://github.com/tmpim/TenebraWeb2/blob/master/LICENSE.txt +import { render, screen } from "@testing-library/react"; +import App from "@app"; + +test("renders the app", async () => { + render(); + + const appLayout = await screen.findByTestId("site-app-layout"); + expect(appLayout).toBeInTheDocument(); +}); diff --git a/src/components/ConditionalLink.tsx b/src/components/ConditionalLink.tsx new file mode 100644 index 0000000..083c53c --- /dev/null +++ b/src/components/ConditionalLink.tsx @@ -0,0 +1,65 @@ +// Copyright (c) 2020-2021 Drew Lemmy +// This file is part of TenebraWeb 2 under AGPL-3.0. +// Full details: https://github.com/tmpim/TenebraWeb2/blob/master/LICENSE.txt +import { FC } from "react"; + +import { Link, useRouteMatch } from "react-router-dom"; + +import "./styles/ConditionalLink.less"; + +interface Props { + to?: string; + condition?: boolean; + + replace?: boolean; + + matchTo?: boolean; + matchPath?: string; + matchExact?: boolean; + matchStrict?: boolean; + matchSensitive?: boolean; +} + +export const ConditionalLink: FC = ({ + to, + condition, + + replace, + + matchTo, + matchPath, + matchExact, + matchStrict, + matchSensitive, + + children, ...props +}): JSX.Element => { + // Disable the link if we're already on that route + const wantsCondition = condition !== undefined; + const wantsMatch = matchTo || !!matchPath; + + const match = useRouteMatch(wantsMatch ? { + path: matchTo && to ? to : matchPath, + exact: matchExact, + strict: matchStrict, + sensitive: matchSensitive + } : {}); + + const active = (!wantsCondition || !!condition) && (!wantsMatch || !match); + + return active && to + ? ( + + {children} + + ) + : ( + + {children} + + ); +}; diff --git a/src/components/CopyInputButton.tsx b/src/components/CopyInputButton.tsx new file mode 100644 index 0000000..85f7ff2 --- /dev/null +++ b/src/components/CopyInputButton.tsx @@ -0,0 +1,50 @@ +// Copyright (c) 2020-2021 Drew Lemmy +// This file is part of TenebraWeb 2 under AGPL-3.0. +// Full details: https://github.com/tmpim/TenebraWeb2/blob/master/LICENSE.txt +import { useState } from "react"; +import { Tooltip, Button, ButtonProps, Input } from "antd"; +import { CopyOutlined } from "@ant-design/icons"; + +import { useTranslation } from "react-i18next"; + +type Props = ButtonProps & { + targetInput: React.RefObject; + refocusButton?: boolean; + content?: React.ReactNode; +} + +export function CopyInputButton({ + targetInput, + refocusButton, + content, + ...buttonProps +}: Props): JSX.Element { + const { t } = useTranslation(); + const [showCopied, setShowCopied] = useState(false); + + function copy(e: React.MouseEvent) { + if (!targetInput.current) return; + + // targetInput.current.select(); + targetInput.current.focus({ cursor: "all" }); + document.execCommand("copy"); + + if (refocusButton === undefined || refocusButton) { + e.currentTarget.focus(); + } + + setShowCopied(true); + } + + return { + if (!visible && showCopied) setShowCopied(false); + }} + > + + ; +} diff --git a/src/components/DateTime.tsx b/src/components/DateTime.tsx new file mode 100644 index 0000000..d72b84b --- /dev/null +++ b/src/components/DateTime.tsx @@ -0,0 +1,87 @@ +// Copyright (c) 2020-2021 Drew Lemmy +// This file is part of TenebraWeb 2 under AGPL-3.0. +// Full details: https://github.com/tmpim/TenebraWeb2/blob/master/LICENSE.txt +import { useContext } from "react"; +import classNames from "classnames"; +import { Tooltip } from "antd"; + +import { TimeagoFormatterContext } from "@global/LocaleContext"; +import { useBooleanSetting } from "@utils/settings"; +import { criticalError } from "@utils"; + +import dayjs from "dayjs"; +import TimeAgo from "react-timeago"; + +import "./styles/DateTime.less"; + +import Debug from "debug"; +const debug = Debug("tenebraweb:date-time"); + +interface OwnProps { + date?: Date | string | null; + timeAgo?: boolean; + small?: boolean; + secondary?: boolean; + neverRelative?: boolean; + tooltip?: React.ReactNode | false; +} +type Props = React.HTMLProps & OwnProps; + +const RELATIVE_DATE_THRESHOLD = 1000 * 60 * 60 * 24 * 7; + +export function DateTime({ + date, + timeAgo, + small, + secondary, + neverRelative, + tooltip, + ...props +}: Props): JSX.Element | null { + // Get the locale's formatter + const formatter = useContext(TimeagoFormatterContext); + + const showRelativeDates = useBooleanSetting("showRelativeDates"); + const showNativeDates = useBooleanSetting("showNativeDates"); + + if (!date) return null; + + // Attempt to convert the date, failing safely + let realDate: Date; + try { + realDate = typeof date === "string" ? new Date(date) : date; + // Some browsers don't throw until the date is actually used + realDate.toISOString(); + } catch (err) { + debug("error parsing date %s", date); + criticalError(err); + return <>INVALID DATE; + } + + const relative = Date.now() - realDate.getTime(); + const isTimeAgo = timeAgo || (showRelativeDates && !neverRelative && relative < RELATIVE_DATE_THRESHOLD); + + const classes = classNames("date-time", props.className, { + "date-time-timeago": isTimeAgo, + "date-time-small": small, + "date-time-secondary": secondary + }); + + const contents = ( + + {isTimeAgo + ? + : dayjs(realDate).format(showNativeDates + ? "ll LTS" + : "YYYY/MM/DD HH:mm:ss")} + + ); + + return tooltip || tooltip === undefined + ? ( + + {contents} + + ) + : contents; +} diff --git a/src/components/Flag.tsx b/src/components/Flag.tsx new file mode 100644 index 0000000..65b977e --- /dev/null +++ b/src/components/Flag.tsx @@ -0,0 +1,26 @@ +// Copyright (c) 2020-2021 Drew Lemmy +// This file is part of TenebraWeb 2 under AGPL-3.0. +// Full details: https://github.com/tmpim/TenebraWeb2/blob/master/LICENSE.txt +import { HTMLProps } from "react"; +import classNames from "classnames"; + +import "./styles/Flag.css"; + +interface Props extends HTMLProps { + name?: string; + code?: string; +} + +export function Flag({ name, code, className, ...rest }: Props): JSX.Element { + const classes = classNames( + "flag", + code ? "flag-" + code.toLowerCase() : "", + className + ); + + return ; +} diff --git a/src/components/HelpIcon.tsx b/src/components/HelpIcon.tsx new file mode 100644 index 0000000..5571553 --- /dev/null +++ b/src/components/HelpIcon.tsx @@ -0,0 +1,26 @@ +// Copyright (c) 2020-2021 Drew Lemmy +// This file is part of TenebraWeb 2 under AGPL-3.0. +// Full details: https://github.com/tmpim/TenebraWeb2/blob/master/LICENSE.txt +import classNames from "classnames"; +import { Tooltip } from "antd"; +import { QuestionCircleOutlined } from "@ant-design/icons"; + +import { useTranslation } from "react-i18next"; + +import "./styles/HelpIcon.less"; + +interface Props { + text?: string; + textKey?: string; + className?: string; +} + +export function HelpIcon({ text, textKey, className }: Props): JSX.Element { + const { t } = useTranslation(); + + const classes = classNames("kw-help-icon", className); + + return + + ; +} diff --git a/src/components/OptionalField.tsx b/src/components/OptionalField.tsx new file mode 100644 index 0000000..1e14df6 --- /dev/null +++ b/src/components/OptionalField.tsx @@ -0,0 +1,39 @@ +// Copyright (c) 2020-2021 Drew Lemmy +// This file is part of TenebraWeb 2 under AGPL-3.0. +// Full details: https://github.com/tmpim/TenebraWeb2/blob/master/LICENSE.txt +import classNames from "classnames"; +import { Typography } from "antd"; +import { CopyConfig } from "./types"; + +import { useTranslation } from "react-i18next"; + +import "./styles/OptionalField.less"; + +const { Text } = Typography; + +interface Props { + value?: React.ReactNode | null | undefined; + copyable?: boolean | CopyConfig; + unsetKey?: string; + className?: string; +} + +export function OptionalField({ + value, + copyable, + unsetKey, + className +}: Props): JSX.Element { + const { t } = useTranslation(); + + const unset = value === undefined || value === null; + const classes = classNames("optional-field", className, { + "optional-field-unset": unset + }); + + return + {unset + ? t(unsetKey || "optionalFieldUnset") + : {value}} + ; +} diff --git a/src/components/SmallCopyable.tsx b/src/components/SmallCopyable.tsx new file mode 100644 index 0000000..99fa6e5 --- /dev/null +++ b/src/components/SmallCopyable.tsx @@ -0,0 +1,74 @@ +// Copyright (c) 2020-2021 Drew Lemmy +// This file is part of TenebraWeb 2 under AGPL-3.0. +// Full details: https://github.com/tmpim/TenebraWeb2/blob/master/LICENSE.txt +// ----------------------------------------------------------------------------- +// This is based on the ant Typography copyable, but with some features removed, +// and without the overhead of the Typography Base component. The ResizeObserver +// in Typography seems to add a significant amount to render times when there +// are a lot of Text elements on the screen (for example, a table listing). +// +// This file is based off of the following source code from ant-design, which is +// licensed under the MIT license: +// +// https://github.com/ant-design/ant-design/blob/077443696ba0fb708f2af81f5eb665b908d8be66/components/typography/Base.tsx +// +// For the full terms of the MIT license used by ant-design, see: +// https://github.com/ant-design/ant-design/blob/master/LICENSE +// ----------------------------------------------------------------------------- +import { useState, useEffect, useRef } from "react"; +import classNames from "classnames"; +import { Tooltip } from "antd"; +import { CopyOutlined, CheckOutlined } from "@ant-design/icons"; + +import { useTranslation } from "react-i18next"; + +import { CopyConfig } from "./types"; +import copy from "copy-to-clipboard"; + +import "./styles/SmallCopyable.less"; + +// Force 'text' to be set (don't traverse the children at all) +type Props = CopyConfig & { + text: string; + className?: string; +}; + +export function SmallCopyable({ text, onCopy, className }: Props): JSX.Element { + const { t } = useTranslation(); + + const [copied, setCopied] = useState(false); + const copyId = useRef(); + + function onCopyClick(e: React.MouseEvent) { + e.preventDefault(); + + copy(text); + + // Display the 'Copied!' tooltip for 3 secs + setCopied(true); + onCopy?.(); + copyId.current = window.setTimeout(() => setCopied(false), 3000); + } + + // Clear the timeout on unmount + useEffect(() => () => window.clearTimeout(copyId.current), []); + + const title = copied ? t("copied") : t("copy"); + const classes = classNames(className, "ant-typography-copy", "small-copyable", + copied && "ant-typography-copy-success"); + + const btn = ( +
+ {copied ? : } +
+ ); + + return copied + ? {btn} + : btn; +} diff --git a/src/components/Statistic.tsx b/src/components/Statistic.tsx new file mode 100644 index 0000000..de63a5e --- /dev/null +++ b/src/components/Statistic.tsx @@ -0,0 +1,31 @@ +// Copyright (c) 2020-2021 Drew Lemmy +// This file is part of TenebraWeb 2 under AGPL-3.0. +// Full details: https://github.com/tmpim/TenebraWeb2/blob/master/LICENSE.txt +import classNames from "classnames"; + +import { useTranslation } from "react-i18next"; + +import "./styles/Statistic.less"; + +interface Props { + title?: string; + titleKey?: string; + titleExtra?: React.ReactNode; + value?: React.ReactNode; + + className?: string; + green?: boolean; +} + +export function Statistic({ title, titleKey, titleExtra, value, className, green }: Props): JSX.Element { + const { t } = useTranslation(); + + const classes = classNames("kw-statistic", className, { + "kw-statistic-green": green + }); + + return
+ {titleKey ? t(titleKey) : title}{titleExtra} + {value} +
; +} diff --git a/src/components/addresses/ContextualAddress.less b/src/components/addresses/ContextualAddress.less new file mode 100644 index 0000000..06bba84 --- /dev/null +++ b/src/components/addresses/ContextualAddress.less @@ -0,0 +1,60 @@ +// Copyright (c) 2020-2021 Drew Lemmy +// This file is part of TenebraWeb 2 under AGPL-3.0. +// Full details: https://github.com/tmpim/TenebraWeb2/blob/master/LICENSE.txt +@import (reference) "../../App.less"; + +.contextual-address { + &:not(.contextual-address-allow-wrap) { + .address-metaname, .address-name, .address-raw-metaname, + .address-wallet { + white-space: nowrap; + } + } + + .address-address, .address-original { + white-space: nowrap; + } + + .address-original { + opacity: 0.8; + } + + &.contextual-address-non-existent { + &, span, a { + color: @text-color-secondary; + + cursor: not-allowed; + + text-decoration-line: underline; + text-decoration-style: dotted; + text-decoration-color: @text-color-secondary; + text-decoration-thickness: 1px; + } + } + + .address-verified { + white-space: nowrap; + word-break: break-word; + + &:not(.address-verified-inactive) { + &, a { + color: fade(@kw-orange, 60%); + + .address-verified-label, .kw-verified-check-icon { + color: @kw-orange; + } + } + } + + .kw-verified-check-icon { + display: inline-block; + font-size: 80%; + margin-left: 0.25em; + + svg { + position: relative; + top: -1px; + } + } + } +} diff --git a/src/components/addresses/ContextualAddress.tsx b/src/components/addresses/ContextualAddress.tsx new file mode 100644 index 0000000..386731a --- /dev/null +++ b/src/components/addresses/ContextualAddress.tsx @@ -0,0 +1,273 @@ +// Copyright (c) 2020-2021 Drew Lemmy +// This file is part of TenebraWeb 2 under AGPL-3.0. +// Full details: https://github.com/tmpim/TenebraWeb2/blob/master/LICENSE.txt +import { useMemo } from "react"; +import classNames from "classnames"; +import { Tooltip } from "antd"; + +import { useTranslation } from "react-i18next"; + +import { TenebraAddress } from "@api/types"; +import { Wallet, useWallets } from "@wallets"; +import { Contact, useContacts } from "@contacts"; +import { parseCommonMeta, CommonMeta, useNameSuffix, stripNameSuffix } from "@utils/tenebra"; +import { useBooleanSetting } from "@utils/settings"; + +import { TenebraNameLink } from "../names/TenebraNameLink"; +import { ConditionalLink } from "@comp/ConditionalLink"; +import { SmallCopyable } from "@comp/SmallCopyable"; + +import { getVerified, VerifiedAddressLink } from "./VerifiedAddress"; + +import "./ContextualAddress.less"; + +interface Props { + address: TenebraAddress | string; + wallet?: Wallet | false; + contact?: Contact | false; + metadata?: string; + source?: boolean; + hideNameAddress?: boolean; + allowWrap?: boolean; + neverCopyable?: boolean; + nonExistent?: boolean; + noLink?: boolean; + noTooltip?: boolean; + className?: string; +} + +export function ContextualAddress({ + address: origAddress, + wallet: origWallet, + contact: origContact, + metadata, + source, + hideNameAddress, + allowWrap, + neverCopyable, + nonExistent, + noLink, + noTooltip, + className +}: Props): JSX.Element { + const { t } = useTranslation(); + + const { walletAddressMap } = useWallets(); + const { contactAddressMap } = useContacts(); + + const nameSuffix = useNameSuffix(); + const addressCopyButtons = useBooleanSetting("addressCopyButtons"); + + const address = typeof origAddress === "object" + ? origAddress.address + : origAddress; + + // If we were given a wallet, use it. Otherwise, look it up, unless it was + // explicitly excluded (e.g. the Wallets table) + const walletLabel = origWallet !== false + ? (origWallet || walletAddressMap[address])?.label + : undefined; + const contactLabel = origContact !== false + ? (origContact || contactAddressMap[address])?.label + : undefined; + + // Parse the CommonMeta, if metadata was supplied, to determine whether or not + // to display a name or metaname. + const { + name: cmName, + recipient: cmRecipient, + return: cmReturn, + returnName: cmReturnName, + returnRecipient: cmReturnRecipient + } = useMemo( + () => parseCommonMeta(nameSuffix, metadata) || {} as Partial, + [nameSuffix, metadata] + ); + + // Finally, whether or not this a metaname should be displayed: + const hasMetaname = source ? !!cmReturnRecipient : !!cmRecipient; + + // Display a verified address if available + const verified = getVerified(address); + + // If the address definitely doesn't exist, show the 'not yet initialised' + // tooltip on hover instead. + const showTooltip = !noTooltip && !verified && + ((hideNameAddress && !!hasMetaname) || !!walletLabel || !!contactLabel); + const tooltipTitle = nonExistent + ? t("contextualAddressNonExistentTooltip") + : (showTooltip ? address : undefined); + + const copyable = !neverCopyable && addressCopyButtons + ? { text: address } : undefined; + + const classes = classNames("contextual-address", className, { + "contextual-address-allow-wrap": allowWrap, + "contextual-address-non-existent": nonExistent + }); + + // The main contents of the contextual address, may be wrapped in a tooltip + const mainContents = useMemo(() => hasMetaname + ? ( + // Display the metaname and link to the name if possible + + ) + : (verified + // Display the verified address if possible + ? + : ( + // Display the regular address or label + + + + ) + ), [ + hideNameAddress, nonExistent, noLink, source, nameSuffix, + address, walletLabel, contactLabel, verified, + cmName, cmRecipient, cmReturn, cmReturnName, hasMetaname, + ]); + + return + {/* Only render the tooltip component if it's actually used */} + {tooltipTitle + ? ( + + {mainContents} + + {/* This empty child here forces the Tooltip to change its hover + * behaviour. Pretty funky, needs investigating. */} + <> + + ) + : mainContents} + + {copyable && } + ; +} + +interface AddressContentProps { + walletLabel?: string; + contactLabel?: string; + address: string; +} + +/** The label of the wallet or contact, or the address itself (not a metaname) */ +function AddressContent({ + walletLabel, + contactLabel, + address, + ...props +}: AddressContentProps): JSX.Element { + return walletLabel + ? {walletLabel} + : (contactLabel + ? {contactLabel} + : {address}); +} + +interface AddressMetanameProps { + nameSuffix: string; + address: string; + source: boolean; + hideNameAddress: boolean; + noLink: boolean; + + name?: string; + recipient?: string; + return?: string; + returnName?: string; +} + +export function AddressMetaname({ + nameSuffix, + address, + source, + hideNameAddress, + noLink, + + name: cmName, + recipient: cmRecipient, + return: cmReturn, + returnName: cmReturnName +}: AddressMetanameProps): JSX.Element { + const rawMetaname = (source ? cmReturn : cmRecipient) || undefined; + const name = (source ? cmReturnName : cmName) || undefined; + const nameWithoutSuffix = name ? stripNameSuffix(nameSuffix, name) : undefined; + + const verified = getVerified(address); + + function AddressContent() { + return verified + ? ( + // Verified address + + ) + : ( + // Regular address + + + ({address}) + + + ); + } + + return name + ? <> + {/* Display the name/metaname (e.g. foo@bar.kst) */} + + + {/* Display the original address too */} + {!hideNameAddress && <> +   + } + + : ( + // Display the raw metaname, but link to the owner address + + {rawMetaname} + + ); +} diff --git a/src/components/addresses/VerifiedAddress.tsx b/src/components/addresses/VerifiedAddress.tsx new file mode 100644 index 0000000..32317ff --- /dev/null +++ b/src/components/addresses/VerifiedAddress.tsx @@ -0,0 +1,120 @@ +// Copyright (c) 2020-2021 Drew Lemmy +// This file is part of TenebraWeb 2 under AGPL-3.0. +// Full details: https://github.com/tmpim/TenebraWeb2/blob/master/LICENSE.txt +import classNames from "classnames"; +import { Row, Col, Card, Tooltip, Button, Typography } from "antd"; +import { GlobalOutlined } from "@ant-design/icons"; + +import { useTranslation } from "react-i18next"; + +import verifiedAddressesJson from "../../__data__/verified-addresses.json"; + +import { ConditionalLink } from "@comp/ConditionalLink"; +import { VerifiedCheck } from "./VerifiedCheck"; + +import Markdown from "markdown-to-jsx"; +import { useMarkdownLink } from "@comp/tenebra/MarkdownLink"; + +const { Text } = Typography; + +// A verified address is a service that transacts on behalf of its users, or +// holds a balance for its users, and is run by someone we think is trustworthy. + +export interface VerifiedAddress { + label: string; + description?: string; + website?: string; + isActive?: boolean; +} + +export type VerifiedAddresses = Record; +export const verifiedAddresses: VerifiedAddresses = verifiedAddressesJson; + +export const getVerified = (address?: string | null): VerifiedAddress | undefined => + address ? verifiedAddresses[address] : undefined; + +interface Props { + address: string; + verified: VerifiedAddress; + parens?: boolean; + noLink?: boolean; + className?: string; +} + +export function VerifiedAddressLink({ + address, + verified, + parens, + noLink, + className +}: Props): JSX.Element { + const classes = classNames("address-verified", className, { + "address-verified-inactive": verified.isActive === false + }); + + return + + + {parens && <>(} + + {verified.label} + + + {parens && <>)} + + + ; +} + +export function VerifiedDescription({ + verified +}: { verified: VerifiedAddress }): JSX.Element { + const { t } = useTranslation(); + + // Make relative links start with the sync node, and override all links to + // open in a new tab + const MarkdownLink = useMarkdownLink(); + + return + + + {/* Description (markdown) */} + {verified.description &&

+ + {verified.description} + +

} + + {/* Inactive notice */} + {verified.isActive === false &&
+ + {t("address.verifiedInactive")} + +
} + + {/* Website button */} + {verified.website && } +
+ +
; +} diff --git a/src/components/addresses/VerifiedCheck.tsx b/src/components/addresses/VerifiedCheck.tsx new file mode 100644 index 0000000..e699da6 --- /dev/null +++ b/src/components/addresses/VerifiedCheck.tsx @@ -0,0 +1,17 @@ +// Copyright (c) 2020-2021 Drew Lemmy +// This file is part of TenebraWeb 2 under AGPL-3.0. +// Full details: https://github.com/tmpim/TenebraWeb2/blob/master/LICENSE.txt +import classNames from "classnames"; +import Icon from "@ant-design/icons"; + +export const VerifiedCheckSvg = (): JSX.Element => ( + + + +); +export const VerifiedCheck = ({ className, ...props }: any): JSX.Element => + ; diff --git a/src/components/addresses/picker/AddressHint.tsx b/src/components/addresses/picker/AddressHint.tsx new file mode 100644 index 0000000..6c4d0e1 --- /dev/null +++ b/src/components/addresses/picker/AddressHint.tsx @@ -0,0 +1,33 @@ +// Copyright (c) 2020-2021 Drew Lemmy +// This file is part of TenebraWeb 2 under AGPL-3.0. +// Full details: https://github.com/tmpim/TenebraWeb2/blob/master/LICENSE.txt +import { useTranslation, Trans } from "react-i18next"; + +import { TenebraAddressWithNames } from "@api/lookup"; +import { TenebraValue } from "@comp/tenebra/TenebraValue"; + +interface Props { + address?: TenebraAddressWithNames; + nameHint?: boolean; +} + +export function AddressHint({ address, nameHint }: Props): JSX.Element { + const { t } = useTranslation(); + + return + {nameHint + ? ( + // Show the name count if this picker is relevant to a name transfer + + Balance: {{ names: address?.names || 0 }} + + ) + : ( + // Otherwise, show the balance + + Balance: + + ) + } + ; +} diff --git a/src/components/addresses/picker/AddressPicker.less b/src/components/addresses/picker/AddressPicker.less new file mode 100644 index 0000000..e533c4e --- /dev/null +++ b/src/components/addresses/picker/AddressPicker.less @@ -0,0 +1,56 @@ +// Copyright (c) 2020-2021 Drew Lemmy +// This file is part of TenebraWeb 2 under AGPL-3.0. +// Full details: https://github.com/tmpim/TenebraWeb2/blob/master/LICENSE.txt +@import (reference) "../../../App.less"; + +.address-picker { + margin-bottom: @form-item-margin-bottom; + + .ant-form-item { + margin-bottom: 0; + } +} + +.address-picker-dropdown { + .address-picker-address-item { + display: flex; + flex-direction: row; + align-items: center; + flex-wrap: wrap; + + .tenebra-value { + flex: 0; + margin-left: auto; + padding-left: @padding-sm; + } + + .address-picker-item-content { + min-width: 0; + flex: 1; + } + + .address-picker-wallet-label, .address-picker-contact-label { + white-space: normal; + word-break: break-word; + } + + .address-picker-wallet-label + .address-picker-wallet-address, + .address-picker-contact-label + .address-picker-contact-address { + color: @text-color-secondary; + } + } +} + +.address-picker-hints { + .address-picker-hint { + // Disallow wrapping within a hint, but still allow the hints themselves to + // be wrapped (see #23) + white-space: nowrap; + word-break: none; + } + + .address-picker-separator { + margin: 0 @padding-xs; + color: @text-color-secondary; + } +} diff --git a/src/components/addresses/picker/AddressPicker.tsx b/src/components/addresses/picker/AddressPicker.tsx new file mode 100644 index 0000000..c4abdac --- /dev/null +++ b/src/components/addresses/picker/AddressPicker.tsx @@ -0,0 +1,281 @@ +// Copyright (c) 2020-2021 Drew Lemmy +// This file is part of TenebraWeb 2 under AGPL-3.0. +// Full details: https://github.com/tmpim/TenebraWeb2/blob/master/LICENSE.txt +import React, { useMemo, Ref, useEffect } from "react"; +import classNames from "classnames"; +import { AutoComplete, Form, FormInstance } from "antd"; +import { Rule } from "antd/lib/form"; +import { ValidateStatus } from "antd/lib/form/FormItem"; +import { RefSelectProps } from "antd/lib/select"; + +import { useTranslation } from "react-i18next"; + +import { useWallets } from "@wallets"; +import { useContacts } from "@contacts"; +import { + useAddressPrefix, useNameSuffix, + isValidAddress, getNameParts, + getNameRegex, getAddressRegexV2 +} from "@utils/tenebra"; + +import { getCategoryHeader } from "./Header"; +import { getAddressItem } from "./Item"; +import { getOptions } from "./options"; +import { usePickerHints } from "./PickerHints"; + +import "./AddressPicker.less"; + +interface Props { + form?: FormInstance; + + name: string; + label?: string; + value?: string; + otherPickerValue?: string; + + walletsOnly?: boolean; + noWallets?: boolean; + noNames?: boolean; + nameHint?: boolean; + + validateStatus?: ValidateStatus; + help?: React.ReactNode; + + suppressUpdates?: boolean; + + className?: string; + tabIndex?: number; + inputRef?: Ref; +} + +export function AddressPicker({ + form, + + name, + label, + value, + otherPickerValue, + + walletsOnly, + noWallets, + noNames, + nameHint, + + validateStatus, + help, + + suppressUpdates, + + className, + tabIndex, + inputRef, + ...props +}: Props): JSX.Element { + const { t } = useTranslation(); + + const cleanValue = value?.toLowerCase().trim(); + + // Note that the address picker's options are memoised against the wallets + // (and soon the address book too), but to save on time and expense, the + // 'exact address' match is prepended to these options dynamically. + const { wallets, addressList } = useWallets(); + const { contacts, contactAddressList } = useContacts(); + const options = useMemo(() => getOptions(t, wallets, contacts, noNames), + [t, wallets, contacts, noNames]); + + // Check if the input text is an exact address. If it is, create an extra item + // to prepend to the list. Note that the 'exact address' item is NOT shown if + // the picker wants wallets only, or if the exact address already appears as a + // wallet (or later, an address book entry). + const addressPrefix = useAddressPrefix(); + const hasExactAddress = !!cleanValue + && !walletsOnly + && isValidAddress(addressPrefix, cleanValue) + && !addressList.includes(cleanValue) + && !contactAddressList.includes(cleanValue); + const exactAddressItem = hasExactAddress + ? { + ...getCategoryHeader(t("addressPicker.categoryExactAddress")), + options: [getAddressItem({ address: cleanValue })] + } + : undefined; + + // Check if the input text is an exact name. It may begin with a metaname, but + // must end with the name suffix. + const nameSuffix = useNameSuffix(); + const nameParts = !walletsOnly && !noNames + ? getNameParts(nameSuffix, cleanValue) : undefined; + const hasExactName = !!cleanValue + && !walletsOnly + && !noNames + && !!nameParts?.name; + const exactNameItem = hasExactName + ? { + ...getCategoryHeader(t("addressPicker.categoryExactName")), + options: [getAddressItem({ name: nameParts })] + } + : undefined; + + // Shallow copy the options if we need to prepend anything, otherwise use the + // original memoised array. Prepend the exact address or exact name if they + // are available. + const fullOptions = hasExactAddress || hasExactName + ? [ + ...(exactAddressItem ? [exactAddressItem] : []), + ...(exactNameItem ? [exactNameItem] : []), + ...(!noWallets ? options : []) + ] + : (!noWallets ? options : []); + + // Fetch an address or name hint if possible + const { pickerHints, foundName } = usePickerHints( + nameHint, cleanValue, hasExactName, suppressUpdates + ); + + // Re-validate this field if the picker hints foundName changed + useEffect(() => { + form?.validateFields([name]); + }, [form, name, foundName, otherPickerValue]); + + function getPlaceholder() { + if (walletsOnly) return t("addressPicker.placeholderWalletsOnly"); + if (noWallets) { + if (noNames) return t("addressPicker.placeholderNoWalletsNoNames"); + else return t("addressPicker.placeholderNoWallets"); + } + return t("addressPicker.placeholder"); + } + + const classes = classNames("address-picker", className, { + "address-picker-wallets-only": walletsOnly, + "address-picker-no-wallets": noWallets, + "address-picker-no-names": noNames, + "address-picker-has-exact-address": hasExactAddress, + "address-picker-has-exact-name": hasExactName, + }); + + return
+ { + const addressRegexp = getAddressRegexV2(addressPrefix); + + if (walletsOnly || noNames) { + // Only validate with addresses + if (!addressRegexp.test(value)) { + if (walletsOnly) + throw t("addressPicker.errorInvalidWalletsOnly"); + else throw t("addressPicker.errorInvalidAddressOnly"); + } + } else { + // Validate addresses and names + const nameRegexp = getNameRegex(nameSuffix); + if (!addressRegexp.test(value) && !nameRegexp.test(value)) { + if (noWallets) + throw t("addressPicker.errorInvalidAddress"); + else throw t("addressPicker.errorInvalidRecipient"); + } + } + } + }, + + // If this is walletsOnly, add an additional rule to enforce that the + // given address is a wallet we actually own + ...(walletsOnly ? [{ + type: "enum", + enum: addressList, + message: t("addressPicker.errorInvalidWalletsOnly") + } as Rule] : []), + + // If we have another address picker's value, assert that they are not + // equal (e.g. to/from in a transaction can't be equal) + ...(otherPickerValue ? [{ + async validator(_, value): Promise { + if (value === otherPickerValue) + throw t("addressPicker.errorEqual"); + + // If the value is a name, and we know the name's owner, assert that + // it's not the same as the otherPickerValue as well + if (hasExactName && foundName && foundName.owner === otherPickerValue) + throw t("addressPicker.errorEqual"); + } + } as Rule] : []) + ]} + + {...props} + > + { + // Returning false if the option contains children will allow the + // select to run filterOption for each child of that option group. + if (option?.options) return false; + // TODO: Do we want to filter categories here too? + + const address = option!.value?.toUpperCase(); + const walletLabel = option!["data-wallet-label"]?.toUpperCase(); + const contactLabel = option!["data-contact-label"]?.toUpperCase(); + + // If we have another address picker's value, hide that option from + // the list (it will always be a wallet) + // FIXME: filterOption doesn't get called at all when inputValue is + // blank, which means this option will still appear until the + // user actually starts typing. + if (otherPickerValue?.toUpperCase() === address) + return false; + + // Now that we've filtered out the other picker's value, we can allow + // every other option if there's no input + if (!inputValue) return true; + + const inp = inputValue.toUpperCase(); + + const matchedAddress = address.indexOf(inp) !== -1; + const matchedLabel = walletLabel && walletLabel.indexOf(inp) !== -1; + const matchedContactLabel = contactLabel && contactLabel.indexOf(inp) !== -1; + + return matchedAddress || matchedLabel || matchedContactLabel; + }} + + options={fullOptions} + + tabIndex={tabIndex} + /> + + + {/* Show the address/name hints if they are present */} + {pickerHints} +
; +} + diff --git a/src/components/addresses/picker/Header.tsx b/src/components/addresses/picker/Header.tsx new file mode 100644 index 0000000..6c9eac1 --- /dev/null +++ b/src/components/addresses/picker/Header.tsx @@ -0,0 +1,19 @@ +// Copyright (c) 2020-2021 Drew Lemmy +// This file is part of TenebraWeb 2 under AGPL-3.0. +// Full details: https://github.com/tmpim/TenebraWeb2/blob/master/LICENSE.txt + +import { OptionChildren } from "./options"; + +export function getCategoryHeader(category: string): Omit { + return { + label: ( +
+ {category} +
+ ), + + // Will possibly be used for filtering. See OptionValue for a comment on + // the naming of this prop. + "data-picker-category": category + }; +} diff --git a/src/components/addresses/picker/Item.tsx b/src/components/addresses/picker/Item.tsx new file mode 100644 index 0000000..d6eaefd --- /dev/null +++ b/src/components/addresses/picker/Item.tsx @@ -0,0 +1,93 @@ +// Copyright (c) 2020-2021 Drew Lemmy +// This file is part of TenebraWeb 2 under AGPL-3.0. +// Full details: https://github.com/tmpim/TenebraWeb2/blob/master/LICENSE.txt + +import { Wallet } from "@wallets"; +import { Contact } from "@contacts"; +import { NameParts } from "@utils/tenebra"; + +import { TenebraValue } from "@comp/tenebra/TenebraValue"; + +import { OptionValue } from "./options"; + +interface AddressItemProps { + address?: string; + name?: NameParts; + wallet?: Wallet; + contact?: Contact; +} + +function getPlainAddress({ + address, + name, + wallet, + contact +}: AddressItemProps): string { + if (wallet) return wallet.address; + if (contact) return contact.address; + if (name?.recipient) return name.recipient; + else return address || ""; +} + +function PickerContent({ + name, + wallet, + contact, + plainAddress +}: AddressItemProps & { plainAddress: string }): JSX.Element { + if (wallet?.label) { + // Show the wallet label if possible + return <> + {wallet.label}  + ({wallet.address}) + ; + } else if (contact?.label) { + // Show the contact label if possible + return <> + {contact.label}  + ({contact.address}) + ; + } else if (name?.recipient) { + // Show a formatted name if possible + const { metaname, nameWithSuffix } = name; + return <> + {metaname && {metaname}@} + {nameWithSuffix} + ; + } else { + // Just show a plain address + return {plainAddress}; + } +} + +/** Autocompletion option for the address picker. */ +export function getAddressItem(props: AddressItemProps, type?: string): OptionValue { + // The address to use as a value + const plainAddress = getPlainAddress(props); + const { wallet, contact } = props; + + return { + key: `${type || "item"}-${plainAddress}`, + + label: ( +
+ {/* Address, wallet label, contact label, or name */} +
+ +
+ + {/* Wallet balance, if available */} + {wallet && } +
+ ), + + // The wallet label is used for filtering the options + "data-wallet-label": wallet?.label, + "data-contact-label": contact?.label, + // The wallet itself is used for sorting the options + "data-wallet": wallet, + "data-contact": contact, + + value: plainAddress + }; +} diff --git a/src/components/addresses/picker/NameHint.tsx b/src/components/addresses/picker/NameHint.tsx new file mode 100644 index 0000000..0648fec --- /dev/null +++ b/src/components/addresses/picker/NameHint.tsx @@ -0,0 +1,29 @@ +// Copyright (c) 2020-2021 Drew Lemmy +// This file is part of TenebraWeb 2 under AGPL-3.0. +// Full details: https://github.com/tmpim/TenebraWeb2/blob/master/LICENSE.txt +import { Typography } from "antd"; + +import { useTranslation, Trans } from "react-i18next"; + +import { TenebraName } from "@api/types"; +import { ContextualAddress } from "@comp/addresses/ContextualAddress"; + +const { Text } = Typography; + +interface Props { + name?: TenebraName; +} + +export function NameHint({ name }: Props): JSX.Element { + const { t } = useTranslation(); + + return + {name + ? ( + + Owner: + + ) + : {t("addressPicker.nameHintNotFound")}} + ; +} diff --git a/src/components/addresses/picker/PickerHints.tsx b/src/components/addresses/picker/PickerHints.tsx new file mode 100644 index 0000000..b8bee93 --- /dev/null +++ b/src/components/addresses/picker/PickerHints.tsx @@ -0,0 +1,203 @@ +// Copyright (c) 2020-2021 Drew Lemmy +// This file is part of TenebraWeb 2 under AGPL-3.0. +// Full details: https://github.com/tmpim/TenebraWeb2/blob/master/LICENSE.txt +import { useState, useEffect, useMemo, useRef } from "react"; + +import { + isValidAddress, getNameParts, + useAddressPrefix, useNameSuffix +} from "@utils/tenebra"; +import { useWallets } from "@wallets"; + +import * as api from "@api"; +import { TenebraAddressWithNames, lookupAddress } from "@api/lookup"; +import { TenebraName } from "@api/types"; + +import { WalletHint } from "./WalletHint"; +import { VerifiedHint } from "./VerifiedHint"; +import { AddressHint } from "./AddressHint"; +import { NameHint } from "./NameHint"; + +import { getVerified } from "@comp/addresses/VerifiedAddress"; +import { useSubscription } from "@global/ws/WebsocketSubscription"; + +import { debounce } from "lodash-es"; + +import Debug from "debug"; +const debug = Debug("tenebraweb:address-picker-hints"); + +const HINT_LOOKUP_DEBOUNCE = 250; + +interface PickerHintsRes { + pickerHints: JSX.Element | null; + foundAddress?: TenebraAddressWithNames | false; + foundName?: TenebraName | false; +} + +export function usePickerHints( + nameHint?: boolean, + value?: string, + hasExactName?: boolean, + suppressUpdates?: boolean +): PickerHintsRes { + // Used for clean-up + const isMounted = useRef(true); + + const addressPrefix = useAddressPrefix(); + const nameSuffix = useNameSuffix(); + + // Handle showing an address or name hint if the value is valid + const [foundAddress, setFoundAddress] = useState(); + const [foundName, setFoundName] = useState(); + + // To auto-refresh address balances, we need to subscribe to the address. + // This is the address to subscribe to: + const [validAddress, setValidAddress] = useState(); + const lastTransactionID = useSubscription({ address: validAddress }); + + // Used to show a wallet hint + const { walletAddressMap, joinedAddressList } = useWallets(); + const foundWallet = validAddress && value + ? walletAddressMap[validAddress] : undefined; + + // Used to show a verified hint + const foundVerified = validAddress ? getVerified(validAddress) : undefined; + + // The actual lookup function (debounced) + const lookupHint = useMemo(() => debounce(async ( + nameSuffix: string, + value: string, + hasAddress?: boolean, + hasName?: boolean, + nameHint?: boolean + ) => { + // Skip doing anything when unmounted to avoid illegal state updates + if (!isMounted.current) return debug("unmounted skipped lookupHint"); + + debug("looking up hint for %s (address: %b) (name: %b)", + value, hasAddress, hasName); + + if (hasAddress) { + // Lookup an address + setFoundName(undefined); + + try { + const address = await lookupAddress(value, nameHint); + + if (!isMounted.current) + return debug("unmounted skipped lookupHint hasAddress try"); + setFoundAddress(address); + } catch (ignored) { + if (!isMounted.current) + return debug("unmounted skipped lookupHint hasAddress catch"); + setFoundAddress(false); + } + } else if (hasName) { + // Lookup a name + setFoundAddress(undefined); + + try { + const nameParts = getNameParts(nameSuffix, value); + const res = await api.get<{ name: TenebraName }>( + "names/" + encodeURIComponent(nameParts!.name!) + ); + + if (!isMounted.current) + return debug("unmounted skipped lookupHint hasName try"); + setFoundName(res.name); + } catch (ignored) { + if (!isMounted.current) + return debug("unmounted skipped lookupHint hasName catch"); + setFoundName(false); + } + } + }, HINT_LOOKUP_DEBOUNCE), []); + + // Look up the address/name if it is valid (debounced to 250ms) + useEffect(() => { + // Skip doing anything when unmounted to avoid illegal state updates + if (!isMounted.current) return debug("unmounted skipped lookup useEffect"); + if (suppressUpdates) return debug("picker hint lookup check suppressed"); + + if (!value) { + setFoundAddress(undefined); + setFoundName(undefined); + setValidAddress(undefined); + return; + } + + // hasExactAddress fails for walletsOnly, so use this variant instead + const hasValidAddress = !!value + && isValidAddress(addressPrefix, value); + + if (!hasValidAddress && !hasExactName) { + setFoundAddress(undefined); + setFoundName(undefined); + return; + } + + // Update the subscription if necessary + if (hasValidAddress && validAddress !== value) { + debug("updating valid address from %s to %s", validAddress, value); + setValidAddress(value); + } + + // Perform the lookup (debounced) + lookupHint(nameSuffix, value, hasValidAddress, hasExactName, nameHint); + }, [ + lookupHint, nameSuffix, value, addressPrefix, hasExactName, nameHint, + validAddress, lastTransactionID, joinedAddressList, suppressUpdates + ]); + + // Clean up the debounced function when unmounting + useEffect(() => { + isMounted.current = true; + + return () => { + debug("unmounting address picker hint"); + isMounted.current = false; + lookupHint?.cancel(); + }; + }, [lookupHint]); + + // Whether or not to show certain hints/anything at all + const showWalletHint = !!foundWallet; + const showVerifiedHint = !!foundVerified && !showWalletHint; + const showAddressHint = foundAddress !== undefined; + const showNameHint = foundName !== undefined; + const foundAnything = showWalletHint || showVerifiedHint + || showAddressHint || showNameHint; + + // Whether or not to show a separator between the wallet hint and address or + // name hint (i.e. if two hints are shown) + const showSep = (showWalletHint || showVerifiedHint) + && (showAddressHint || showNameHint); + + const pickerHints = foundAnything + ?
+ {/* Show a wallet hint if possible */} + {foundWallet && } + + {/* Show a verified hint if possible */} + {foundVerified && ( + // Make it look like a contextual address to inherit the styles + + + + )} + + {/* Show a separator if there are two hints */} + {showSep && } + + {/* Show an address hint if possible */} + {showAddressHint && ( + + )} + + {/* Show a name hint if possible */} + {showNameHint && } +
+ : null; + + return { pickerHints, foundAddress, foundName }; +} diff --git a/src/components/addresses/picker/VerifiedHint.tsx b/src/components/addresses/picker/VerifiedHint.tsx new file mode 100644 index 0000000..352928f --- /dev/null +++ b/src/components/addresses/picker/VerifiedHint.tsx @@ -0,0 +1,15 @@ +// Copyright (c) 2020-2021 Drew Lemmy +// This file is part of TenebraWeb 2 under AGPL-3.0. +// Full details: https://github.com/tmpim/TenebraWeb2/blob/master/LICENSE.txt +import { VerifiedAddress, VerifiedAddressLink } from "@comp/addresses/VerifiedAddress"; + +interface Props { + address: string; + verified: VerifiedAddress; +} + +export function VerifiedHint({ address, verified }: Props): JSX.Element { + return + + ; +} diff --git a/src/components/addresses/picker/WalletHint.tsx b/src/components/addresses/picker/WalletHint.tsx new file mode 100644 index 0000000..160e972 --- /dev/null +++ b/src/components/addresses/picker/WalletHint.tsx @@ -0,0 +1,21 @@ +// Copyright (c) 2020-2021 Drew Lemmy +// This file is part of TenebraWeb 2 under AGPL-3.0. +// Full details: https://github.com/tmpim/TenebraWeb2/blob/master/LICENSE.txt +import { useTranslation, Trans } from "react-i18next"; + +import { Wallet } from "@wallets"; +import { ContextualAddress } from "@comp/addresses/ContextualAddress"; + +interface Props { + wallet: Wallet; +} + +export function WalletHint({ wallet }: Props): JSX.Element { + const { t } = useTranslation(); + + return + + Owner: + + ; +} diff --git a/src/components/addresses/picker/options.ts b/src/components/addresses/picker/options.ts new file mode 100644 index 0000000..59e0bd2 --- /dev/null +++ b/src/components/addresses/picker/options.ts @@ -0,0 +1,170 @@ +// Copyright (c) 2020-2021 Drew Lemmy +// This file is part of TenebraWeb 2 under AGPL-3.0. +// Full details: https://github.com/tmpim/TenebraWeb2/blob/master/LICENSE.txt +import { TFunction } from "react-i18next"; + +import { WalletMap, Wallet } from "@wallets"; +import { ContactMap, Contact } from "@contacts"; + +import { getCategoryHeader } from "./Header"; +import { getAddressItem } from "./Item"; + +import { keyedNullSort } from "@utils"; + +// Ant design's autocomplete/select/rc-select components don't seem to return +// the proper types for these, so just provide our own types that are 'good +// enough'. I have a feeling the AutoComplete/Select components just accept +// basically anything for options, and passes the full objects down as props. +// The documentation on the topic is very limited. +export interface OptionValue { + key: string; + label: React.ReactNode; + + // For some reason, all these props get passed all the way to the DOM element! + // Make this a 'valid' DOM prop + "data-wallet-label"?: string; + "data-contact-label"?: string; + "data-wallet"?: Wallet; + "data-contact"?: Contact; + value: string; +} + +export interface OptionChildren { + label: React.ReactNode; + "data-picker-category": string; + options: OptionValue[]; +} + +export type Option = OptionValue | OptionChildren; + +// ----------------------------------------------------------------------------- +// WALLET OPTIONS +// ----------------------------------------------------------------------------- +interface WalletOptions { + categorised: Record; + uncategorised: OptionValue[]; + contacts: OptionValue[]; + categoryCount: number; +} + +// Sort by balance descending, address ascending. Undefined values are pushed to +// the bottom by using keyedNullSort. Addresses are sorted ascending, though +// because of the implicit reversing behaviour of keyedNullSort, they need to +// be swapped here (i.e. sort with `b`, `a`). +const sortBalance = keyedNullSort<{ balance?: number }>("balance"); +const sortLabel = keyedNullSort<{ label?: string }>("label", true); +const sortAddress = keyedNullSort<{ address: string }>("address", true); + +const sortFn = (a: Wallet, b: Wallet): number => + sortBalance(a, b, "descend") || sortAddress(b, a); +const optionSortFn = (a: OptionValue, b: OptionValue): number => + sortFn(a["data-wallet"]!, b["data-wallet"]!); + +const contactSortFn = (a: Contact, b: Contact): number => + sortLabel(b, a) || sortAddress(b, a); +const contactOptionSortFn = (a: OptionValue, b: OptionValue): number => + contactSortFn(a["data-contact"]!, b["data-contact"]!); + +/** Groups the wallets by category for autocompletion and generates their select + * options. */ +function getWalletOptions( + wallets: WalletMap, + contacts: ContactMap, + noNames?: boolean +): WalletOptions { + const categorised: Record = {}; + const uncategorised: OptionValue[] = []; + + // Go through all wallets and group them + for (const id in wallets) { + const wallet = wallets[id]; + const { category } = wallet; + + // Generate the autocomplete option for this wallet + const item = getAddressItem({ wallet }, "wallet"); + + // Group it by category if possible + if (category) { + if (categorised[category]) categorised[category].push(item); + else categorised[category] = [item]; + } else { + uncategorised.push(item); + } + } + + // Add the contacts too, filtering out names if noNames is set + const contactValues: OptionValue[] = Object.values(contacts) + .filter(c => noNames ? !c.isName : true) + .map(contact => getAddressItem({ contact }, "contact")); + + // Sort the wallets by balance descending, and then by address ascending. + // Since this uses keyedNullSort, which depends on ant-design's implicit + // reversing behaviour, the array is reversed after sorting here. As such, + // undefined balances will be pushed to the bottom. + for (const category in categorised) { + categorised[category].sort(optionSortFn); + categorised[category].reverse(); + } + + uncategorised.sort(optionSortFn); + uncategorised.reverse(); + + contactValues.sort(contactOptionSortFn); + contactValues.reverse(); + + return { + categorised, + uncategorised, + contacts: contactValues, + categoryCount: Object.keys(categorised).length + }; +} + +// ----------------------------------------------------------------------------- +// FULL OPTIONS +// ----------------------------------------------------------------------------- +/** Gets the base options to show for autocompletion, including the wallets, + * grouped by category if possible. Will include the address book soon too. */ +export function getOptions( + t: TFunction, + wallets: WalletMap, + contactMap: ContactMap, + noNames?: boolean +): Option[] { + // Wallet options + const { categorised, uncategorised, categoryCount, contacts } + = getWalletOptions(wallets, contactMap, noNames); + + // Sort the wallet categories in a human-friendly manner + const sortedCategories = Object.keys(categorised); + sortedCategories.sort((a, b) => a.localeCompare(b, undefined, { + sensitivity: "base", + numeric: true + })); + + // Generate the option groups for each category, along with the corresponding + // wallet entries. + const categoryItems = sortedCategories.map(c => ({ + ...getCategoryHeader(c), + options: categorised[c] + })); + + return [ + // Categorised wallets + ...categoryItems, + + // Uncategorised wallets + { + ...getCategoryHeader(categoryCount > 0 + ? t("addressPicker.categoryOtherWallets") + : t("addressPicker.categoryWallets")), + options: uncategorised + }, + + // Address book + { + ...getCategoryHeader(t("addressPicker.categoryAddressBook")), + options: contacts + }, + ]; +} diff --git a/src/components/auth/AuthContext.tsx b/src/components/auth/AuthContext.tsx new file mode 100644 index 0000000..2cb4cf5 --- /dev/null +++ b/src/components/auth/AuthContext.tsx @@ -0,0 +1,64 @@ +// Copyright (c) 2020-2021 Drew Lemmy +// This file is part of TenebraWeb 2 under AGPL-3.0. +// Full details: https://github.com/tmpim/TenebraWeb2/blob/master/LICENSE.txt +import { useState, useCallback, createContext, FC } from "react"; + +import { useMasterPassword } from "@wallets"; + +import { AuthMasterPasswordModal } from "./AuthMasterPasswordModal"; +import { SetMasterPasswordModal } from "./SetMasterPasswordModal"; + +export type PromptAuthFn = (encrypt: boolean | undefined, onAuthed?: () => void) => void; +export const AuthContext = createContext(undefined); + +interface ModalProps { + // Whether the modal text should say 'encrypt wallets' or 'decrypt wallets' + encrypt: boolean | undefined; + onAuthed?: () => void; +} + +export const AuthProvider: FC = ({ children }) => { + const { isAuthed, hasMasterPassword } = useMasterPassword(); + + // Don't render the modal unless we absolutely have to + const [clicked, setClicked] = useState(false); + const [modalVisible, setModalVisible] = useState(false); + + const [modalProps, setModalProps] = useState({ encrypt: false }); + + const promptAuth: PromptAuthFn = useCallback((encrypt, onAuthed) => { + if (isAuthed) { + // Pass-through auth directly if already authed. + onAuthed?.(); + return; + } + + setModalProps({ encrypt, onAuthed }); + setModalVisible(true); + setClicked(true); + }, [isAuthed]); + + const submit = useCallback(() => { + setModalVisible(false); + modalProps.onAuthed?.(); + }, [modalProps]); + + return + {children} + + {clicked && !isAuthed && <> + {hasMasterPassword + ? setModalVisible(false)} + onSubmit={submit} + /> + : setModalVisible(false)} + onSubmit={submit} + />} + } + ; +}; diff --git a/src/components/auth/AuthForm.tsx b/src/components/auth/AuthForm.tsx new file mode 100644 index 0000000..ef1706a --- /dev/null +++ b/src/components/auth/AuthForm.tsx @@ -0,0 +1,90 @@ +// Copyright (c) 2020-2021 Drew Lemmy +// This file is part of TenebraWeb 2 under AGPL-3.0. +// Full details: https://github.com/tmpim/TenebraWeb2/blob/master/LICENSE.txt +import { useState, useMemo, useCallback, Ref } from "react"; +import { Button, Input, Form } from "antd"; + +import { useTFns, translateError } from "@utils/i18n"; + +import { FakeUsernameInput } from "./FakeUsernameInput"; +import { getMasterPasswordInput } from "./MasterPasswordInput"; + +import { authMasterPassword, useMasterPassword } from "@wallets"; + +interface FormValues { + masterPassword: string; +} + +interface AuthFormRes { + form: JSX.Element; + submit: () => Promise; + reset: () => void; +} + +export function useAuthForm( + encrypt: boolean | undefined, + onSubmit: () => void, + inputRef: Ref +): AuthFormRes { + const { t, tStr, tKey } = useTFns("masterPassword."); + + const { salt, tester } = useMasterPassword(); + + const [form] = Form.useForm(); + const [passwordError, setPasswordError] = useState(); + + const reset = useCallback(() => { + form.resetFields(); + }, [form]); + + const onFinish = useCallback(async function() { + const values = await form.validateFields(); + + try { + await authMasterPassword(salt, tester, values.masterPassword); + onSubmit(); + } catch (err) { + setPasswordError(translateError(t, err, tKey("errorUnknown"))); + } + }, [t, tKey, onSubmit, salt, tester, form]); + + const formEl = useMemo(() => <> +

{tStr(encrypt ? "popoverDescriptionEncrypt" : "popoverDescription")}

+ +
+ + + {/* Password input */} + + {getMasterPasswordInput({ + inputRef, + placeholder: tStr("passwordPlaceholder"), + autoFocus: true + })} + + + {/* Fake submit button to allow the enter key to submit in modal */} + + + ); + } else if (type === "names") { + return ( + // Network names + + + + ); + } else { + return null; + } +} + +function getSubTitleKey(type?: ResultType): string { + switch (type) { + case "sendTransaction": + return "noWalletsResult.subTitleSendTransaction"; + default: + return "noWalletsResult.subTitle"; + } +} + +export function NoWalletsResult({ type, className }: Props): JSX.Element { + const { t } = useTranslation(); + + const classes = classNames("kw-no-wallets-result", className); + + return } + + title={t("noWalletsResult.title")} + subTitle={t(getSubTitleKey(type))} + extra={<> + {/* Other helpful buttons (e.g. 'Network transactions') */} + {} + + {/* 'Add wallets' button that links to the 'My wallets' page */} + + + + } + + fullPage + />; +} + +interface ModalProps extends Props { + visible?: boolean; + setVisible?: Dispatch>; +} + +export function NoWalletsModal({ + type, + className, + visible, + setVisible +}: ModalProps): JSX.Element { + const { t } = useTranslation(); + const history = useHistory(); + + const classes = classNames("kw-no-wallets-modal", className); + + return { + setVisible?.(false); + history.push("/wallets"); + }} + okText={t("noWalletsResult.button")} + + onCancel={() => setVisible?.(false)} + cancelText={t("dialog.cancel")} + > + {t(getSubTitleKey(type))} + ; +} diff --git a/src/components/results/SmallResult.tsx b/src/components/results/SmallResult.tsx new file mode 100644 index 0000000..f6a58fd --- /dev/null +++ b/src/components/results/SmallResult.tsx @@ -0,0 +1,78 @@ +// Copyright (c) 2020-2021 Drew Lemmy +// This file is part of TenebraWeb 2 under AGPL-3.0. +// Full details: https://github.com/tmpim/TenebraWeb2/blob/master/LICENSE.txt +// ----------------------------------------------------------------------------- +// This is ant-design's Result component, but without importing 54 kB of images +// that we don't even use. +// +// This file is based off of the following source code from ant-design, which is +// licensed under the MIT license: +// +// https://github.com/ant-design/ant-design/blob/077443696ba0fb708f2af81f5eb665b908d8be66/components/result/index.tsx +// +// For the full terms of the MIT license used by ant-design, see: +// https://github.com/ant-design/ant-design/blob/master/LICENSE +// ----------------------------------------------------------------------------- +import React from "react"; +import classNames from "classnames"; + +import CheckCircleFilled from "@ant-design/icons/CheckCircleFilled"; +import CloseCircleFilled from "@ant-design/icons/CloseCircleFilled"; +import ExclamationCircleFilled from "@ant-design/icons/ExclamationCircleFilled"; +import WarningFilled from "@ant-design/icons/WarningFilled"; + +export const IconMap = { + success: CheckCircleFilled, + error: CloseCircleFilled, + info: ExclamationCircleFilled, + warning: WarningFilled, +}; +export type ResultStatusType = keyof typeof IconMap; + +export interface ResultProps { + icon?: React.ReactNode; + status?: ResultStatusType; + title?: React.ReactNode; + subTitle?: React.ReactNode; + extra?: React.ReactNode; + className?: string; + style?: React.CSSProperties; + fullPage?: boolean; +} + +/** + * Render icon if ExceptionStatus includes ,render svg image else render iconNode + */ +const renderIcon = ({ status, icon }: ResultProps) => { + const iconNode = React.createElement(IconMap[status as ResultStatusType],); + return
{icon || iconNode}
; +}; + +const renderExtra = ({ extra }: ResultProps) => + extra &&
{extra}
; + +export const SmallResult: React.FC = ({ + className: customizeClassName, + subTitle, + title, + style, + children, + status = "info", + icon, + extra, + fullPage, +}) => { + const classes = classNames("ant-result", "ant-result-" + status, customizeClassName, { + "full-page-result": fullPage + }); + + return ( +
+ {renderIcon({ status, icon })} +
{title}
+ {subTitle &&
{subTitle}
} + {renderExtra({ extra })} + {children &&
{children}
} +
+ ); +}; diff --git a/src/components/styles/ConditionalLink.less b/src/components/styles/ConditionalLink.less new file mode 100644 index 0000000..92b346f --- /dev/null +++ b/src/components/styles/ConditionalLink.less @@ -0,0 +1,9 @@ +// Copyright (c) 2020-2021 Drew Lemmy +// This file is part of TenebraWeb 2 under AGPL-3.0. +// Full details: https://github.com/tmpim/TenebraWeb2/blob/master/LICENSE.txt +@import (reference) "../../App.less"; + +.conditional-link-disabled { + color: @primary-color; + cursor: pointer; +} diff --git a/src/components/styles/DateTime.less b/src/components/styles/DateTime.less new file mode 100644 index 0000000..a86f387 --- /dev/null +++ b/src/components/styles/DateTime.less @@ -0,0 +1,18 @@ +// Copyright (c) 2020-2021 Drew Lemmy +// This file is part of TenebraWeb 2 under AGPL-3.0. +// Full details: https://github.com/tmpim/TenebraWeb2/blob/master/LICENSE.txt +@import (reference) "../../App.less"; + +.date-time { + &-secondary, &-secondary a, &-secondary time { + color: @text-color-secondary; + } + + &-small, &-small a, &-small time { + font-size: 90%; + + @media (max-width: @screen-xl) { + font-size: 85%; + } + } +} diff --git a/src/components/styles/Flag.css b/src/components/styles/Flag.css new file mode 100644 index 0000000..be629f0 --- /dev/null +++ b/src/components/styles/Flag.css @@ -0,0 +1 @@ +span.flag{width:44px;height:30px;display:inline-block}img.flag{width:30px}.flag{background:url(/img/flags_responsive.png) no-repeat;background-size:100%}.flag-ad{background-position:0 .413223%}.flag-ae{background-position:0 .826446%}.flag-af{background-position:0 1.239669%}.flag-ag{background-position:0 1.652893%}.flag-ai{background-position:0 2.066116%}.flag-al{background-position:0 2.479339%}.flag-am{background-position:0 2.892562%}.flag-an{background-position:0 3.305785%}.flag-ao{background-position:0 3.719008%}.flag-aq{background-position:0 4.132231%}.flag-ar{background-position:0 4.545455%}.flag-as{background-position:0 4.958678%}.flag-at{background-position:0 5.371901%}.flag-au{background-position:0 5.785124%}.flag-aw{background-position:0 6.198347%}.flag-az{background-position:0 6.61157%}.flag-ba{background-position:0 7.024793%}.flag-bb{background-position:0 7.438017%}.flag-bd{background-position:0 7.85124%}.flag-be{background-position:0 8.264463%}.flag-bf{background-position:0 8.677686%}.flag-bg{background-position:0 9.090909%}.flag-bh{background-position:0 9.504132%}.flag-bi{background-position:0 9.917355%}.flag-bj{background-position:0 10.330579%}.flag-bm{background-position:0 10.743802%}.flag-bn{background-position:0 11.157025%}.flag-bo{background-position:0 11.570248%}.flag-br{background-position:0 11.983471%}.flag-bs{background-position:0 12.396694%}.flag-bt{background-position:0 12.809917%}.flag-bv{background-position:0 13.22314%}.flag-bw{background-position:0 13.636364%}.flag-by{background-position:0 14.049587%}.flag-bz{background-position:0 14.46281%}.flag-ca{background-position:0 14.876033%}.flag-cc{background-position:0 15.289256%}.flag-cd{background-position:0 15.702479%}.flag-cf{background-position:0 16.115702%}.flag-cg{background-position:0 16.528926%}.flag-ch{background-position:0 16.942149%}.flag-ci{background-position:0 17.355372%}.flag-ck{background-position:0 17.768595%}.flag-cl{background-position:0 18.181818%}.flag-cm{background-position:0 18.595041%}.flag-cn{background-position:0 19.008264%}.flag-co{background-position:0 19.421488%}.flag-cr{background-position:0 19.834711%}.flag-cu{background-position:0 20.247934%}.flag-cv{background-position:0 20.661157%}.flag-cx{background-position:0 21.07438%}.flag-cy{background-position:0 21.487603%}.flag-cz{background-position:0 21.900826%}.flag-de{background-position:0 22.31405%}.flag-dj{background-position:0 22.727273%}.flag-dk{background-position:0 23.140496%}.flag-dm{background-position:0 23.553719%}.flag-do{background-position:0 23.966942%}.flag-dz{background-position:0 24.380165%}.flag-ec{background-position:0 24.793388%}.flag-ee{background-position:0 25.206612%}.flag-eg{background-position:0 25.619835%}.flag-eh{background-position:0 26.033058%}.flag-er{background-position:0 26.446281%}.flag-es{background-position:0 26.859504%}.flag-et{background-position:0 27.272727%}.flag-fi{background-position:0 27.68595%}.flag-fj{background-position:0 28.099174%}.flag-fk{background-position:0 28.512397%}.flag-fm{background-position:0 28.92562%}.flag-fo{background-position:0 29.338843%}.flag-fr{background-position:0 29.752066%}.flag-ga{background-position:0 30.165289%}.flag-gd{background-position:0 30.578512%}.flag-ge{background-position:0 30.991736%}.flag-gf{background-position:0 31.404959%}.flag-gh{background-position:0 31.818182%}.flag-gi{background-position:0 32.231405%}.flag-gl{background-position:0 32.644628%}.flag-gm{background-position:0 33.057851%}.flag-gn{background-position:0 33.471074%}.flag-gp{background-position:0 33.884298%}.flag-gq{background-position:0 34.297521%}.flag-gr{background-position:0 34.710744%}.flag-gs{background-position:0 35.123967%}.flag-gt{background-position:0 35.53719%}.flag-gu{background-position:0 35.950413%}.flag-gw{background-position:0 36.363636%}.flag-gy{background-position:0 36.77686%}.flag-hk{background-position:0 37.190083%}.flag-hm{background-position:0 37.603306%}.flag-hn{background-position:0 38.016529%}.flag-hr{background-position:0 38.429752%}.flag-ht{background-position:0 38.842975%}.flag-hu{background-position:0 39.256198%}.flag-id{background-position:0 39.669421%}.flag-ie{background-position:0 40.082645%}.flag-il{background-position:0 40.495868%}.flag-in{background-position:0 40.909091%}.flag-io{background-position:0 41.322314%}.flag-iq{background-position:0 41.735537%}.flag-ir{background-position:0 42.14876%}.flag-is{background-position:0 42.561983%}.flag-it{background-position:0 42.975207%}.flag-jm{background-position:0 43.38843%}.flag-jo{background-position:0 43.801653%}.flag-jp{background-position:0 44.214876%}.flag-ke{background-position:0 44.628099%}.flag-kg{background-position:0 45.041322%}.flag-kh{background-position:0 45.454545%}.flag-ki{background-position:0 45.867769%}.flag-km{background-position:0 46.280992%}.flag-kn{background-position:0 46.694215%}.flag-kp{background-position:0 47.107438%}.flag-kr{background-position:0 47.520661%}.flag-kw{background-position:0 47.933884%}.flag-ky{background-position:0 48.347107%}.flag-kz{background-position:0 48.760331%}.flag-la{background-position:0 49.173554%}.flag-lb{background-position:0 49.586777%}.flag-lc{background-position:0 50%}.flag-li{background-position:0 50.413223%}.flag-lk{background-position:0 50.826446%}.flag-lr{background-position:0 51.239669%}.flag-ls{background-position:0 51.652893%}.flag-lt{background-position:0 52.066116%}.flag-lu{background-position:0 52.479339%}.flag-lv{background-position:0 52.892562%}.flag-ly{background-position:0 53.305785%}.flag-ma{background-position:0 53.719008%}.flag-mc{background-position:0 54.132231%}.flag-md{background-position:0 54.545455%}.flag-me{background-position:0 54.958678%}.flag-mg{background-position:0 55.371901%}.flag-mh{background-position:0 55.785124%}.flag-mk{background-position:0 56.198347%}.flag-ml{background-position:0 56.61157%}.flag-mm{background-position:0 57.024793%}.flag-mn{background-position:0 57.438017%}.flag-mo{background-position:0 57.85124%}.flag-mp{background-position:0 58.264463%}.flag-mq{background-position:0 58.677686%}.flag-mr{background-position:0 59.090909%}.flag-ms{background-position:0 59.504132%}.flag-mt{background-position:0 59.917355%}.flag-mu{background-position:0 60.330579%}.flag-mv{background-position:0 60.743802%}.flag-mw{background-position:0 61.157025%}.flag-mx{background-position:0 61.570248%}.flag-my{background-position:0 61.983471%}.flag-mz{background-position:0 62.396694%}.flag-na{background-position:0 62.809917%}.flag-nc{background-position:0 63.22314%}.flag-ne{background-position:0 63.636364%}.flag-nf{background-position:0 64.049587%}.flag-ng{background-position:0 64.46281%}.flag-ni{background-position:0 64.876033%}.flag-nl{background-position:0 65.289256%}.flag-no{background-position:0 65.702479%}.flag-np{background-position:0 66.115702%}.flag-nr{background-position:0 66.528926%}.flag-nu{background-position:0 66.942149%}.flag-nz{background-position:0 67.355372%}.flag-om{background-position:0 67.768595%}.flag-pa{background-position:0 68.181818%}.flag-pe{background-position:0 68.595041%}.flag-pf{background-position:0 69.008264%}.flag-pg{background-position:0 69.421488%}.flag-ph{background-position:0 69.834711%}.flag-pk{background-position:0 70.247934%}.flag-pl{background-position:0 70.661157%}.flag-pm{background-position:0 71.07438%}.flag-pn{background-position:0 71.487603%}.flag-pr{background-position:0 71.900826%}.flag-pt{background-position:0 72.31405%}.flag-pw{background-position:0 72.727273%}.flag-py{background-position:0 73.140496%}.flag-qa{background-position:0 73.553719%}.flag-re{background-position:0 73.966942%}.flag-ro{background-position:0 74.380165%}.flag-rs{background-position:0 74.793388%}.flag-ru{background-position:0 75.206612%}.flag-rw{background-position:0 75.619835%}.flag-sa{background-position:0 76.033058%}.flag-sb{background-position:0 76.446281%}.flag-sc{background-position:0 76.859504%}.flag-sd{background-position:0 77.272727%}.flag-se{background-position:0 77.68595%}.flag-sg{background-position:0 78.099174%}.flag-sh{background-position:0 78.512397%}.flag-si{background-position:0 78.92562%}.flag-sj{background-position:0 79.338843%}.flag-sk{background-position:0 79.752066%}.flag-sl{background-position:0 80.165289%}.flag-sm{background-position:0 80.578512%}.flag-sn{background-position:0 80.991736%}.flag-so{background-position:0 81.404959%}.flag-sr{background-position:0 81.818182%}.flag-ss{background-position:0 82.231405%}.flag-st{background-position:0 82.644628%}.flag-sv{background-position:0 83.057851%}.flag-sy{background-position:0 83.471074%}.flag-sz{background-position:0 83.884298%}.flag-tc{background-position:0 84.297521%}.flag-td{background-position:0 84.710744%}.flag-tf{background-position:0 85.123967%}.flag-tg{background-position:0 85.53719%}.flag-th{background-position:0 85.950413%}.flag-tj{background-position:0 86.363636%}.flag-tk{background-position:0 86.77686%}.flag-tl{background-position:0 87.190083%}.flag-tm{background-position:0 87.603306%}.flag-tn{background-position:0 88.016529%}.flag-to{background-position:0 88.429752%}.flag-tp{background-position:0 88.842975%}.flag-tr{background-position:0 89.256198%}.flag-tt{background-position:0 89.669421%}.flag-tv{background-position:0 90.082645%}.flag-tw{background-position:0 90.495868%}.flag-ty{background-position:0 90.909091%}.flag-tz{background-position:0 91.322314%}.flag-ua{background-position:0 91.735537%}.flag-ug{background-position:0 92.14876%}.flag-gb,.flag-uk{background-position:0 92.561983%}.flag-um{background-position:0 92.975207%}.flag-us{background-position:0 93.38843%}.flag-uy{background-position:0 93.801653%}.flag-uz{background-position:0 94.214876%}.flag-va{background-position:0 94.628099%}.flag-vc{background-position:0 95.041322%}.flag-ve{background-position:0 95.454545%}.flag-vg{background-position:0 95.867769%}.flag-vi{background-position:0 96.280992%}.flag-vn{background-position:0 96.694215%}.flag-vu{background-position:0 97.107438%}.flag-wf{background-position:0 97.520661%}.flag-ws{background-position:0 97.933884%}.flag-ye{background-position:0 98.347107%}.flag-za{background-position:0 98.760331%}.flag-zm{background-position:0 99.173554%}.flag-zr{background-position:0 99.586777%}.flag-zw{background-position:0 100%} diff --git a/src/components/styles/HelpIcon.less b/src/components/styles/HelpIcon.less new file mode 100644 index 0000000..2078139 --- /dev/null +++ b/src/components/styles/HelpIcon.less @@ -0,0 +1,14 @@ +// Copyright (c) 2020-2021 Drew Lemmy +// This file is part of TenebraWeb 2 under AGPL-3.0. +// Full details: https://github.com/tmpim/TenebraWeb2/blob/master/LICENSE.txt +@import (reference) "../../App.less"; + +.kw-help-icon { + display: inline-block; + margin-left: @padding-xs; + + font-size: 90%; + + color: @text-color-secondary; + cursor: pointer; +} diff --git a/src/components/styles/OptionalField.less b/src/components/styles/OptionalField.less new file mode 100644 index 0000000..cd0c641 --- /dev/null +++ b/src/components/styles/OptionalField.less @@ -0,0 +1,11 @@ +// Copyright (c) 2020-2021 Drew Lemmy +// This file is part of TenebraWeb 2 under AGPL-3.0. +// Full details: https://github.com/tmpim/TenebraWeb2/blob/master/LICENSE.txt +@import (reference) "../../App.less"; + +.optional-field { + &.optional-field-unset { + color: @text-color-secondary; + font-style: italic; + } +} diff --git a/src/components/styles/SmallCopyable.less b/src/components/styles/SmallCopyable.less new file mode 100644 index 0000000..f9140d2 --- /dev/null +++ b/src/components/styles/SmallCopyable.less @@ -0,0 +1,10 @@ +// Copyright (c) 2020-2021 Drew Lemmy +// This file is part of TenebraWeb 2 under AGPL-3.0. +// Full details: https://github.com/tmpim/TenebraWeb2/blob/master/LICENSE.txt +.small-copyable { + border: 0; + background: transparent; + padding: 0; + line-height: inherit; + display: inline-block; +} diff --git a/src/components/styles/Statistic.less b/src/components/styles/Statistic.less new file mode 100644 index 0000000..2e88b94 --- /dev/null +++ b/src/components/styles/Statistic.less @@ -0,0 +1,29 @@ +// Copyright (c) 2020-2021 Drew Lemmy +// This file is part of TenebraWeb 2 under AGPL-3.0. +// Full details: https://github.com/tmpim/TenebraWeb2/blob/master/LICENSE.txt +@import (reference) "../../App.less"; + +.kw-statistic { + &-title { + color: @kw-text-secondary; + display: block; + } + + &-value { + font-size: @heading-3-size; + + .ant-typography-copy { + line-height: 1 !important; + margin-left: @padding-xs; + + .anticon { + font-size: @font-size-base; + vertical-align: 0; + } + } + } + + &-green &-value { + color: @kw-green; + } +} diff --git a/src/components/tenebra/MarkdownLink.tsx b/src/components/tenebra/MarkdownLink.tsx new file mode 100644 index 0000000..84ad2b0 --- /dev/null +++ b/src/components/tenebra/MarkdownLink.tsx @@ -0,0 +1,38 @@ +// Copyright (c) 2020-2021 Drew Lemmy +// This file is part of TenebraWeb 2 under AGPL-3.0. +// Full details: https://github.com/tmpim/TenebraWeb2/blob/master/LICENSE.txt +import { FC } from "react"; +import { Link } from "react-router-dom"; + +import { useSyncNode } from "@api"; + +// Allow overriding a link to make it open in a new tab and start with baseURL. +// This is usually used by the markdown renderers. +export function useMarkdownLink(baseURL?: string): FC { + // Default for baseURL if not specified + const syncNode = useSyncNode(); + const base = baseURL || syncNode; + + return ({ title, href, children }) => { + // Force the link to start with baseURL/syncNode if it's relative + const absLink = href.startsWith("/") + ? base + href + : href; + + return + {children} + ; + }; +} + +export function useRelativeMarkdownLink(): FC { + return ({ title, href, children }) => { + return + {children} + ; + }; +} diff --git a/src/components/tenebra/TenebraSymbol.tsx b/src/components/tenebra/TenebraSymbol.tsx new file mode 100644 index 0000000..6d0e972 --- /dev/null +++ b/src/components/tenebra/TenebraSymbol.tsx @@ -0,0 +1,12 @@ +// Copyright (c) 2020-2021 Drew Lemmy +// This file is part of TenebraWeb 2 under AGPL-3.0. +// Full details: https://github.com/tmpim/TenebraWeb2/blob/master/LICENSE.txt +import Icon from "@ant-design/icons"; + +export const TenebraSymbolSvg = (): JSX.Element => ( + + + +); +export const TenebraSymbol = (props: any): JSX.Element => + ; diff --git a/src/components/tenebra/TenebraValue.less b/src/components/tenebra/TenebraValue.less new file mode 100644 index 0000000..45667b9 --- /dev/null +++ b/src/components/tenebra/TenebraValue.less @@ -0,0 +1,51 @@ +// Copyright (c) 2020-2021 Drew Lemmy +// This file is part of TenebraWeb 2 under AGPL-3.0. +// Full details: https://github.com/tmpim/TenebraWeb2/blob/master/LICENSE.txt +@import (reference) "../../App.less"; + +.tenebra-value { + font-size: 100%; + + white-space: nowrap; + + .anticon svg { + /* Hack to make it consistent with Lato */ + position: relative; + bottom: 0.125em; + font-size: 0.75em; + color: @text-color-secondary; + } + + .tenebra-value-amount { + font-weight: bold; + } + + .tenebra-currency-long { + color: @text-color-secondary; + + &::before { + content: " "; + } + } + + &.tenebra-value-green { + color: @kw-green; + + .anticon svg, .tenebra-currency-long { + color: fade(@kw-green, 75%); + } + } + + &.tenebra-value-zero { + color: @text-color-secondary; + + .anticon svg, .tenebra-currency-long { + color: fade(@text-color-secondary, 60%); + } + } +} + +// The currency symbol appears too dark when inside a button +.ant-btn.ant-btn-primary .tenebra-value .anticon svg { + color: fade(@text-color, 70%); +} diff --git a/src/components/tenebra/TenebraValue.tsx b/src/components/tenebra/TenebraValue.tsx new file mode 100644 index 0000000..bb72425 --- /dev/null +++ b/src/components/tenebra/TenebraValue.tsx @@ -0,0 +1,49 @@ +// Copyright (c) 2020-2021 Drew Lemmy +// This file is part of TenebraWeb 2 under AGPL-3.0. +// Full details: https://github.com/tmpim/TenebraWeb2/blob/master/LICENSE.txt +import React from "react"; +import classNames from "classnames"; + +import { useSelector } from "react-redux"; +import { RootState } from "@store"; + +import { TenebraSymbol } from "./TenebraSymbol"; + +import "./TenebraValue.less"; + +interface OwnProps { + icon?: React.ReactNode; + value?: number; + long?: boolean; + hideNullish?: boolean; + green?: boolean; + highlightZero?: boolean; +} +type Props = React.HTMLProps & OwnProps; + +export const TenebraValue = ({ + icon, + value, + long, + hideNullish, + green, + highlightZero, + ...props +}: Props): JSX.Element | null => { + const currencySymbol = useSelector((s: RootState) => s.node.currency.currency_symbol); + + if (hideNullish && (value === undefined || value === null)) return null; + + const classes = classNames("tenebra-value", props.className, { + "tenebra-value-green": green, + "tenebra-value-zero": highlightZero && value === 0 + }); + + return ( + + {icon || ((currencySymbol || "KST") === "KST" && )} + {(value || 0).toLocaleString()} + {long && {currencySymbol || "KST"}} + + ); +}; diff --git a/src/components/transactions/AmountInput.tsx b/src/components/transactions/AmountInput.tsx new file mode 100644 index 0000000..0fe1012 --- /dev/null +++ b/src/components/transactions/AmountInput.tsx @@ -0,0 +1,120 @@ +// Copyright (c) 2020-2021 Drew Lemmy +// This file is part of TenebraWeb 2 under AGPL-3.0. +// Full details: https://github.com/tmpim/TenebraWeb2/blob/master/LICENSE.txt +import { ReactNode } from "react"; +import { Form, Input, InputNumber, Button } from "antd"; + +import { useTranslation } from "react-i18next"; + +import { useWallets } from "@wallets"; +import { useCurrency } from "@utils/tenebra"; + +import { TenebraSymbol } from "@comp/tenebra/TenebraSymbol"; + +interface Props { + from?: string; + setAmount: (amount: number) => void; + + label?: ReactNode; + required?: boolean; + disabled?: boolean; + + tabIndex?: number; +} + +export function AmountInput({ + from, + setAmount, + + label, + required, + disabled, + + tabIndex, + ...props +}: Props): JSX.Element { + const { t } = useTranslation(); + + // Used to populate 'Max' + const { walletAddressMap } = useWallets(); + + // Used to format the currency prefix/suffix + const { currency_symbol } = useCurrency(); + + function onClickMax() { + if (!from) return; + const currentWallet = walletAddressMap[from]; + setAmount(currentWallet?.balance || 0); + } + + const amountRequired = required === undefined || !!required; + + return + + {/* Prepend the Tenebra symbol if possible. Note that ant's InputNumber + * doesn't support addons, so this has to be done manually. */} + {(currency_symbol || "KST") === "KST" && ( + + + + )} + + {/* Amount number input */} + { + // If the field isn't required, don't complain if it's empty + if (!required && typeof value !== "number") + return; + + if (value < 1) + throw t("sendTransaction.errorAmountTooLow"); + + // Nothing to validate if there's no `from` field (request screen) + if (!from) return; + + const currentWallet = walletAddressMap[from]; + if (!currentWallet) return; + if (value > (currentWallet.balance || 0)) + throw t("sendTransaction.errorAmountTooHigh"); + } + }, + ]} + > + + + + {/* Currency suffix */} + + {currency_symbol || "KST"} + + + {/* Max value button */} + {from && } + + ; +} diff --git a/src/components/transactions/SendTransactionModalLink.tsx b/src/components/transactions/SendTransactionModalLink.tsx new file mode 100644 index 0000000..fd3b112 --- /dev/null +++ b/src/components/transactions/SendTransactionModalLink.tsx @@ -0,0 +1,69 @@ +// Copyright (c) 2020-2021 Drew Lemmy +// This file is part of TenebraWeb 2 under AGPL-3.0. +// Full details: https://github.com/tmpim/TenebraWeb2/blob/master/LICENSE.txt +import { FC, useState, useCallback } from "react"; + +import { AuthorisedAction } from "@comp/auth/AuthorisedAction"; +import { SendTransactionModal } from "@pages/transactions/send/SendTransactionModal"; + +import { Wallet } from "@wallets"; + +interface Props { + from?: Wallet | string; + to?: string; +} + +export const SendTransactionModalLink: FC = ({ + from, + to, + children +}): JSX.Element => { + const [modalVisible, setModalVisible] = useState(false); + + return <> + setModalVisible(true)}> + {children} + + + + ; +}; + +export type OpenSendTxFn = (from?: Wallet | string, to?: string) => void; +export type SendTxHookRes = [ + OpenSendTxFn, + JSX.Element | null, + (visible: boolean) => void +]; + +interface FromTo { + from?: Wallet | string; + to?: string; +} + +export function useSendTransactionModal(): SendTxHookRes { + const [opened, setOpened] = useState(false); + const [visible, setVisible] = useState(false); + const [fromTo, setFromTo] = useState({}); + + const open = useCallback((from?: Wallet | string, to?: string) => { + setFromTo({ from, to }); + setVisible(true); + setOpened(true); + }, []); + + const modal = opened + ? + : null; + + return [open, modal, setVisible]; +} diff --git a/src/components/transactions/TransactionConciseMetadata.less b/src/components/transactions/TransactionConciseMetadata.less new file mode 100644 index 0000000..c42b066 --- /dev/null +++ b/src/components/transactions/TransactionConciseMetadata.less @@ -0,0 +1,22 @@ +// Copyright (c) 2020-2021 Drew Lemmy +// This file is part of TenebraWeb 2 under AGPL-3.0. +// Full details: https://github.com/tmpim/TenebraWeb2/blob/master/LICENSE.txt +@import (reference) "../../App.less"; + +.transaction-concise-metadata { + color: @kw-text-tertiary; + font-family: monospace; + font-size: 85%; + + &-truncated::after { + content: "\2026"; + color: @text-color-secondary; + user-select: none; + } +} + +a.transaction-concise-metadata { + &:hover, &:active, &:focus { + color: @text-color; + } +} diff --git a/src/components/transactions/TransactionConciseMetadata.tsx b/src/components/transactions/TransactionConciseMetadata.tsx new file mode 100644 index 0000000..fa06328 --- /dev/null +++ b/src/components/transactions/TransactionConciseMetadata.tsx @@ -0,0 +1,61 @@ +// Copyright (c) 2020-2021 Drew Lemmy +// This file is part of TenebraWeb 2 under AGPL-3.0. +// Full details: https://github.com/tmpim/TenebraWeb2/blob/master/LICENSE.txt +import classNames from "classnames"; + +import { Link } from "react-router-dom"; + +import { TenebraTransaction } from "@api/types"; +import { useNameSuffix, stripNameFromMetadata } from "@utils/tenebra"; + +import "./TransactionConciseMetadata.less"; + +interface Props { + transaction?: TenebraTransaction; + metadata?: string; + limit?: number; + className?: string; +} + +/** + * Trims the name and metaname from the start of metadata, and truncates it + * to a specified amount of characters. + */ +export function TransactionConciseMetadata({ + transaction: tx, + metadata, + limit = 30, + className +}: Props): JSX.Element | null { + const nameSuffix = useNameSuffix(); + + // Don't render anything if there's no metadata (after the hooks) + const meta = metadata || tx?.metadata; + if (!meta) return null; + + // Strip the name from the start of the transaction metadata, if it is present + const hasName = tx && (tx.sent_name || tx.sent_metaname); + const withoutName = hasName + ? stripNameFromMetadata(nameSuffix, meta) + : meta; + + // Trim it down to the limit if necessary + const wasTruncated = withoutName.length > limit; + const truncated = wasTruncated ? withoutName.substr(0, limit) : withoutName; + + const classes = classNames("transaction-concise-metadata", className, { + "transaction-concise-metadata-truncated": wasTruncated + }); + + // Link to the transaction if it is available + return tx + ? ( + + {truncated} + + ) + : {truncated}; +} diff --git a/src/components/transactions/TransactionItem.tsx b/src/components/transactions/TransactionItem.tsx new file mode 100644 index 0000000..8eefca1 --- /dev/null +++ b/src/components/transactions/TransactionItem.tsx @@ -0,0 +1,170 @@ +// Copyright (c) 2020-2021 Drew Lemmy +// This file is part of TenebraWeb 2 under AGPL-3.0. +// Full details: https://github.com/tmpim/TenebraWeb2/blob/master/LICENSE.txt +import { Row, Col, Tooltip, } from "antd"; +import { RightOutlined } from "@ant-design/icons"; + +import { useTFns } from "@utils/i18n"; + +import { Link } from "react-router-dom"; + +import { TenebraTransaction } from "@api/types"; +import { WalletAddressMap, Wallet } from "@wallets"; +import { useBreakpoint } from "@utils/hooks"; + +import { DateTime } from "../DateTime"; + +import * as Parts from "./TransactionItemParts"; + +import { + getTransactionType, TransactionType, InternalTransactionType +} from "./TransactionType"; + +interface Props { + transaction: TenebraTransaction; + + /** [address]: Wallet */ + wallets: WalletAddressMap; +} + +interface ItemProps { + type: InternalTransactionType; + + tx: TenebraTransaction; + txTime: Date; + txLink: string; + + fromWallet?: Wallet; + toWallet?: Wallet; + + hideNameAddress: boolean; +} + +export function TransactionItem({ + transaction: tx, + wallets +}: Props): JSX.Element | null { + const bps = useBreakpoint(); + + // Whether or not the from/to addresses are a wallet we own + const fromWallet = tx.from ? wallets[tx.from] : undefined; + const toWallet = tx.to ? wallets[tx.to] : undefined; + + const type = getTransactionType(tx, fromWallet, toWallet); + + const txTime = new Date(tx.time); + const txLink = "/network/transactions/" + encodeURIComponent(tx.id); + + const hideNameAddress = !bps.xl; + + // Return a different element (same data, different layout) depending on + // whether this is mobile or desktop + return bps.sm || bps.sm === undefined // bps can be undefined sometimes + ? + : ; +} + +function TransactionItemDesktop({ + type, + tx, txTime, txLink, + fromWallet, toWallet, + hideNameAddress +}: ItemProps): JSX.Element { + const { t, tKey } = useTFns("transactionSummary."); + + return + + {/* Transaction type and link to transaction */} + + + + + {/* Transaction time */} + + + + + + + {/* Name and A record */} + + + + {/* To */} + + + {/* From */} + + + + + {/* Value / name */} + + + ; +} + +function TransactionItemMobile({ + type, + tx, txTime, txLink, + fromWallet, toWallet, + hideNameAddress +}: ItemProps): JSX.Element { + const { tKey } = useTFns("transactionSummary."); + + return + {/* Type and primary value */} +
+ + +
+ + {/* Name and A record */} + + + + {/* To */} + + + {/* From */} + + + {/* Time */} + + + {/* Right chevron */} + + ; +} diff --git a/src/components/transactions/TransactionItemParts.tsx b/src/components/transactions/TransactionItemParts.tsx new file mode 100644 index 0000000..9743d8d --- /dev/null +++ b/src/components/transactions/TransactionItemParts.tsx @@ -0,0 +1,178 @@ +// Copyright (c) 2020-2021 Drew Lemmy +// This file is part of TenebraWeb 2 under AGPL-3.0. +// Full details: https://github.com/tmpim/TenebraWeb2/blob/master/LICENSE.txt +import { Tooltip } from "antd"; + +import { Trans } from "react-i18next"; +import { useTFns, TKeyFn } from "@utils/i18n"; + +import { TenebraTransaction } from "@api/types"; +import { Wallet } from "@wallets"; + +import { TenebraNameLink } from "../names/TenebraNameLink"; +import { ContextualAddress } from "../addresses/ContextualAddress"; +import { TenebraValue } from "../tenebra/TenebraValue"; + +import { + InternalTransactionType, INTERNAL_TYPES_SHOW_VALUE +} from "./TransactionType"; + +const MAX_A_LENGTH = 24; + +interface PartBaseProps { + tKey: TKeyFn; + type: InternalTransactionType; + noLink?: boolean; +} + +interface PartTxProps extends PartBaseProps { + tx: TenebraTransaction; +} + +interface PartAddressProps extends PartTxProps { + fromWallet?: Wallet; + toWallet?: Wallet; + hideNameAddress: boolean; +} + +// ----------------------------------------------------------------------------- +// NAME +// ----------------------------------------------------------------------------- +export function TransactionName({ tKey, type, name, noLink }: PartBaseProps & { + name?: string; +}): JSX.Element | null { + if (type !== "name_a_record" && type !== "name_purchased") return null; + + return + + Name: + + + ; +} + +// ----------------------------------------------------------------------------- +// A RECORD +// ----------------------------------------------------------------------------- +export function TransactionARecordContent({ metadata }: { + metadata: string | undefined | null; +}): JSX.Element { + const { tStr } = useTFns("transactionSummary."); + + return metadata + ? + + {metadata.length > MAX_A_LENGTH + ? <>{metadata.substring(0, MAX_A_LENGTH)}… + : metadata} + + + : ( + + {tStr("itemARecordRemoved")} + + ); +} + +export function TransactionARecord({ tKey, type, metadata }: PartBaseProps & { + metadata: string | undefined | null; +}): JSX.Element | null { + if (type !== "name_a_record") return null; + + return + + A record: + + + ; +} + +// ----------------------------------------------------------------------------- +// TO +// ----------------------------------------------------------------------------- +export function TransactionTo({ + tKey, + type, tx, + fromWallet, toWallet, + hideNameAddress, noLink +}: PartAddressProps): JSX.Element | null { + if (type === "name_a_record") return null; + + return + + To: + {type === "name_purchased" + ? + : } + + ; +} + +// ----------------------------------------------------------------------------- +// FROM +// ----------------------------------------------------------------------------- +export function TransactionFrom({ + tKey, + type, tx, + fromWallet, + hideNameAddress, noLink +}: Omit): JSX.Element | null { + if (type === "name_a_record" || type === "name_purchased" || type === "mined") + return null; + + return + + From: + + + ; +} + +// ----------------------------------------------------------------------------- +// VALUE / NAME +// ----------------------------------------------------------------------------- +export function TransactionPrimaryValue({ + type, + tx +}: Omit): JSX.Element | null { + return + {INTERNAL_TYPES_SHOW_VALUE.includes(type) + ? ( + // Transaction value + + ) + : (tx.type === "name_transfer" + ? ( + // Transaction name + + ) + : null + )} + ; +} diff --git a/src/components/transactions/TransactionSummary.less b/src/components/transactions/TransactionSummary.less new file mode 100644 index 0000000..7c014e8 --- /dev/null +++ b/src/components/transactions/TransactionSummary.less @@ -0,0 +1,112 @@ +// Copyright (c) 2020-2021 Drew Lemmy +// This file is part of TenebraWeb 2 under AGPL-3.0. +// Full details: https://github.com/tmpim/TenebraWeb2/blob/master/LICENSE.txt +@import (reference) "../../App.less"; + +.transaction-summary-item { + flex-flow: nowrap; + + .date-time { + color: @text-color-secondary; + font-size: 90%; + + @media (max-width: @screen-xl) { + font-size: 85%; + } + } + + .transaction-field { + font-weight: bold; + white-space: nowrap; + color: @text-color-secondary; + } + + .transaction-a-record-value { + font-family: monospace; + font-size: 90%; + color: @text-color-secondary; + } + + .transaction-a-record-removed { + font-style: italic; + font-size: 90%; + color: @text-color-secondary; + + white-space: nowrap; + text-overflow: ellipsis; + } + + .transaction-name { + font-weight: bold; + } + + .transaction-left { + display: flex; + flex-direction: column; + justify-content: center; + } + + .transaction-middle { + flex: 1; + + display: flex; + flex-direction: column; + justify-content: center; + + overflow: hidden; + } + + .transaction-right { + flex: 0; + font-size: @font-size-lg; + + display: flex; + justify-content: center; + align-items: center; + } + + &.transaction-summary-item-mobile { + display: block; + margin-bottom: @padding-xs; + + color: @text-color; + font-size: @font-size-sm; + + position: relative; + + .transaction-mobile-top { + font-size: @font-size-base; + margin-bottom: @padding-xss; + + .transaction-type { + font-size: @font-size-base; + } + + .transaction-primary-value { + display: inline-block; + margin-left: @padding-xs; + } + } + + .transaction-to, .transaction-from, .transaction-a-record { + display: block; + } + + .date-time { + font-size: @font-size-sm; + } + + .transaction-mobile-right { + display: flex; + align-items: center; + + position: absolute; + top: 0; + bottom: 0; + right: @padding-md; + + font-size: 32px; + color: fade(@text-color-secondary, 50%); + } + } +} diff --git a/src/components/transactions/TransactionSummary.tsx b/src/components/transactions/TransactionSummary.tsx new file mode 100644 index 0000000..53e2268 --- /dev/null +++ b/src/components/transactions/TransactionSummary.tsx @@ -0,0 +1,43 @@ +// Copyright (c) 2020-2021 Drew Lemmy +// This file is part of TenebraWeb 2 under AGPL-3.0. +// Full details: https://github.com/tmpim/TenebraWeb2/blob/master/LICENSE.txt +import { Row } from "antd"; + +import { useTranslation } from "react-i18next"; +import { Link } from "react-router-dom"; + +import { useWallets } from "@wallets"; + +import { TenebraTransaction } from "@api/types"; +import { TransactionItem } from "./TransactionItem"; + +import "./TransactionSummary.less"; + +interface Props { + transactions?: TenebraTransaction[]; + + seeMoreCount?: number; + seeMoreKey?: string; + seeMoreLink?: string; +} + +export function TransactionSummary({ transactions, seeMoreCount, seeMoreKey, seeMoreLink }: Props): JSX.Element { + const { t } = useTranslation(); + const { walletAddressMap } = useWallets(); + + return <> + {transactions && transactions.map(t => ( + + ))} + + {seeMoreCount !== undefined && + + {t(seeMoreKey || "transactionSummary.seeMore", { count: seeMoreCount })} + + } + ; +} diff --git a/src/components/transactions/TransactionType.less b/src/components/transactions/TransactionType.less new file mode 100644 index 0000000..29d8a00 --- /dev/null +++ b/src/components/transactions/TransactionType.less @@ -0,0 +1,37 @@ +// Copyright (c) 2020-2021 Drew Lemmy +// This file is part of TenebraWeb 2 under AGPL-3.0. +// Full details: https://github.com/tmpim/TenebraWeb2/blob/master/LICENSE.txt +@import (reference) "../../App.less"; + +.transaction-type { + &, a { + user-select: none; + + font-weight: bold; + color: @text-color-secondary; + } + + &-transferred, &-name_transferred { + &, a { color: @kw-primary; } + } + &-sent, &-name_sent, &-name_purchased { + &, a { color: @kw-orange; } + } + &-received, &-mined, &-name_received { + &, a { color: @kw-green; } + } + &-name_a_record { + &, a { color: @kw-purple; } + } + &-bumped { + &, a { color: @kw-text-tertiary; } + } + + &-no-link { + &, a { cursor: default; } + } + + @media (max-width: @screen-xl) { + font-size: 90%; + } +} diff --git a/src/components/transactions/TransactionType.tsx b/src/components/transactions/TransactionType.tsx new file mode 100644 index 0000000..cdef345 --- /dev/null +++ b/src/components/transactions/TransactionType.tsx @@ -0,0 +1,100 @@ +// Copyright (c) 2020-2021 Drew Lemmy +// This file is part of TenebraWeb 2 under AGPL-3.0. +// Full details: https://github.com/tmpim/TenebraWeb2/blob/master/LICENSE.txt +import classNames from "classnames"; + +import { useTranslation } from "react-i18next"; + +import { ConditionalLink } from "@comp/ConditionalLink"; + +import { TenebraTransaction, TenebraTransactionType } from "@api/types"; +import { Wallet, useWallets } from "@wallets"; + +import "./TransactionType.less"; + +export type InternalTransactionType = "transferred" | "sent" | "received" | "mined" | + "name_a_record" | "name_transferred" | "name_sent" | "name_received" | + "name_purchased" | "bumped" | "unknown"; +export const INTERNAL_TYPES_SHOW_VALUE: InternalTransactionType[] = [ + "transferred", "sent", "received", "mined", "name_purchased", "bumped" +]; + +export const TYPES_SHOW_VALUE: TenebraTransactionType[] = [ + "transfer", "mined", "name_purchase" +]; + +export function getTransactionType( + tx: TenebraTransaction, + from?: Wallet, + to?: Wallet +): InternalTransactionType { + switch (tx.type) { + case "transfer": + if (tx.from && tx.to && tx.from === tx.to) return "bumped"; + if (from && to) return "transferred"; + if (from) return "sent"; + if (to) return "received"; + return "transferred"; + + case "name_transfer": + if (from && to) return "name_transferred"; + if (from) return "name_sent"; + if (to) return "name_received"; + return "name_transferred"; + + case "name_a_record": return "name_a_record"; + case "name_purchase": return "name_purchased"; + + case "mined": return "mined"; + + default: return "unknown"; + } +} + +interface OwnProps { + type?: InternalTransactionType; + transaction?: TenebraTransaction; + from?: Wallet; + to?: Wallet; + link?: string; +} +type Props = React.HTMLProps & OwnProps; + +export function TransactionType({ + type, + transaction, + from, + to, + link, + className +}: Props): JSX.Element { + const { t } = useTranslation(); + const { walletAddressMap } = useWallets(); + + // If we weren't already given the wallets (and we need them to calculate the + // type), get them + const fromWallet = !type && transaction?.from ? (from || walletAddressMap[transaction.from]) : undefined; + const toWallet = !type && transaction?.to ? (to || walletAddressMap[transaction.to]) : undefined; + + // If we weren't already given the type, calculate it + const finalType = type || (transaction ? getTransactionType(transaction, fromWallet, toWallet) : "unknown"); + + const contents = t("transactions.types." + finalType); + const classes = classNames("transaction-type", "transaction-type-" + finalType, className, { + "transaction-type-no-link": !link + }); + + return + {link + ? ( + + {contents} + + ) + : contents} + ; +} diff --git a/src/components/types.ts b/src/components/types.ts new file mode 100644 index 0000000..0bd61be --- /dev/null +++ b/src/components/types.ts @@ -0,0 +1,11 @@ +// Copyright (c) 2020-2021 Drew Lemmy +// This file is part of TenebraWeb 2 under AGPL-3.0. +// Full details: https://github.com/tmpim/TenebraWeb2/blob/master/LICENSE.txt + +/** CopyConfig from ant-design (antd/lib/typography/Base.d.ts) */ +export interface CopyConfig { + text?: string; + onCopy?: () => void; + icon?: React.ReactNode; + tooltips?: boolean | React.ReactNode; +} diff --git a/src/components/wallets/SelectWalletCategory.tsx b/src/components/wallets/SelectWalletCategory.tsx new file mode 100644 index 0000000..6a29742 --- /dev/null +++ b/src/components/wallets/SelectWalletCategory.tsx @@ -0,0 +1,132 @@ +// Copyright (c) 2020-2021 Drew Lemmy +// This file is part of TenebraWeb 2 under AGPL-3.0. +// Full details: https://github.com/tmpim/TenebraWeb2/blob/master/LICENSE.txt +import { FC, useState, useMemo } from "react"; +import { Select, Input, Button, Typography, Divider } from "antd"; +import { PlusOutlined } from "@ant-design/icons"; + +import { useTranslation } from "react-i18next"; + +import { localeSort } from "@utils"; +import { useWallets } from "@wallets"; + +const { Text } = Typography; + +interface Props { + onNewCategory?: (name: string) => void; +} + +export const SelectWalletCategory: FC = ({ onNewCategory, ...props }): JSX.Element => { + const { t } = useTranslation(); + const [customCategory, setCustomCategory] = useState(); + + // Required to fetch existing categories + const { wallets } = useWallets(); + const categories = useMemo(() => { + // Get all the non-empty wallet categories and deduplicate them + const categorySet = new Set(Object.values(wallets) + .filter(w => w.category !== undefined && w.category !== "") + .map(w => w.category) as string[]); + + // Add the custom category, if it exists, to our set of categories + if (customCategory) categorySet.add(customCategory); + + // Convert the categories to an array and sort in a human-readable manner + const cats = [...categorySet]; + localeSort(cats); + return cats; + }, [wallets, customCategory]); + + /** Adds a category. Returns whether or not the category input should be + * wiped (i.e. whether or not it was added successfully). */ + function addCategory(input?: string): boolean { + // Ignore invalid category names. Don't wipe the input. + const categoryName = input?.trim(); + if (!categoryName + || categoryName.length > 32 + || categories.includes(categoryName)) + return false; + + setCustomCategory(categoryName); + + // FIXME: hitting enter will _sometimes_ not set the right category name on + // the form + if (onNewCategory) onNewCategory(categoryName); + + return true; + } + + return ; +}; + +interface AddCategoryInputProps { + addCategory: (input: string | undefined) => boolean; +} + +/** The textbox/button to input and add a new category. */ +function AddCategoryInput({ addCategory }: AddCategoryInputProps): JSX.Element { + const { t } = useTranslation(); + const [input, setInput] = useState(); + + return ( +
+ + setInput(e.target.value)} + onPressEnter={e => { + e.preventDefault(); + + // Wipe the input if the category was added successfully + if (addCategory(input)) setInput(undefined); + }} + + placeholder={t("addWallet.walletCategoryDropdownNewPlaceholder")} + + size="small" + style={{ flex: 1, height: 24 }} + /> + + + +
+ ); +} diff --git a/src/components/wallets/SelectWalletFormat.tsx b/src/components/wallets/SelectWalletFormat.tsx new file mode 100644 index 0000000..60c89a3 --- /dev/null +++ b/src/components/wallets/SelectWalletFormat.tsx @@ -0,0 +1,30 @@ +// Copyright (c) 2020-2021 Drew Lemmy +// This file is part of TenebraWeb 2 under AGPL-3.0. +// Full details: https://github.com/tmpim/TenebraWeb2/blob/master/LICENSE.txt +import { Select } from "antd"; + +import { useTranslation } from "react-i18next"; + +import { WalletFormatName, ADVANCED_FORMATS } from "@wallets"; +import { useBooleanSetting } from "@utils/settings"; + +interface Props { + initialFormat: WalletFormatName; +} + +export function SelectWalletFormat({ initialFormat }: Props): JSX.Element { + const advancedWalletFormats = useBooleanSetting("walletFormats"); + const { t } = useTranslation(); + + return ; +} diff --git a/src/components/wallets/SyncWallets.tsx b/src/components/wallets/SyncWallets.tsx new file mode 100644 index 0000000..40fb9a4 --- /dev/null +++ b/src/components/wallets/SyncWallets.tsx @@ -0,0 +1,66 @@ +// Copyright (c) 2020-2021 Drew Lemmy +// This file is part of TenebraWeb 2 under AGPL-3.0. +// Full details: https://github.com/tmpim/TenebraWeb2/blob/master/LICENSE.txt +import { useEffect } from "react"; +import { message, notification } from "antd"; + +import { useSelector } from "react-redux"; +import { RootState } from "@store"; +import { useTranslation } from "react-i18next"; + +import { syncWallets, useWallets, ADDRESS_LIST_LIMIT } from "@wallets"; +import { useContacts } from "@contacts"; + +import { criticalError } from "@utils"; + +import Debug from "debug"; +const debug = Debug("tenebraweb:sync-wallets"); + +/** Sync the wallets with the Tenebra node when connected. */ +export function SyncWallets(): JSX.Element | null { + const { t } = useTranslation(); + + const connectionState = useSelector((s: RootState) => s.websocket.connectionState); + + // When the websocket connects (usually just on startup), perform the initial + // sync. This replaces `WebsocketConnection.refreshBalances`. + useEffect(() => { + if (connectionState !== "connected") return; + debug("syncing wallets (ws is %s)", connectionState); + syncWallets() + .then(() => debug("synced")) + .catch(err => { + criticalError(err); + notification.error({ + message: t("syncWallets.errorMessage"), + description: t("syncWallets.errorDescription"), + }); + }); + }, [t, connectionState]); + + // This is an appropriate place to perform the wallet limit check too. Warn + // the user if they have more wallets than ADDRESS_LIST_LIMIT; bypassing this + // limit will generally result in issues with syncing/fetching. + const { addressList } = useWallets(); + const { contactAddressList } = useContacts(); + + useEffect(() => { + const warningStyle = { style: { maxWidth: 512, marginLeft: "auto", marginRight: "auto" }}; + + if (addressList.length > ADDRESS_LIST_LIMIT) { + message.warning({ + content: t("walletLimitMessage"), + ...warningStyle + }); + } + + if (contactAddressList.length > ADDRESS_LIST_LIMIT) { + message.warning({ + content: t("contactLimitMessage"), + ...warningStyle + }); + } + }, [t, addressList.length, contactAddressList.length]); + + return null; +} diff --git a/src/global/AppHotkeys.tsx b/src/global/AppHotkeys.tsx new file mode 100644 index 0000000..9cd3b7b --- /dev/null +++ b/src/global/AppHotkeys.tsx @@ -0,0 +1,17 @@ +// Copyright (c) 2020-2021 Drew Lemmy +// This file is part of TenebraWeb 2 under AGPL-3.0. +// Full details: https://github.com/tmpim/TenebraWeb2/blob/master/LICENSE.txt + +import { useHistory } from "react-router-dom"; +import { GlobalHotKeys } from "react-hotkeys"; + +export function AppHotkeys(): JSX.Element { + const history = useHistory(); + + return history.push("/dev") + }} + />; +} diff --git a/src/global/AppLoading.tsx b/src/global/AppLoading.tsx new file mode 100644 index 0000000..374cdf1 --- /dev/null +++ b/src/global/AppLoading.tsx @@ -0,0 +1,15 @@ +// Copyright (c) 2020-2021 Drew Lemmy +// This file is part of TenebraWeb 2 under AGPL-3.0. +// Full details: https://github.com/tmpim/TenebraWeb2/blob/master/LICENSE.txt + +export function AppLoading(): JSX.Element { + return
+ {/* Spinner */} +
+ + {/* Loading hint */} + {/* NOTE: This is not translated, as usually this component is shown when + the translations are being loaded! */} + setsetstsetsetestLoading TenebraWeb... +
; +} diff --git a/src/global/AppRouter.tsx b/src/global/AppRouter.tsx new file mode 100644 index 0000000..3ac3ffc --- /dev/null +++ b/src/global/AppRouter.tsx @@ -0,0 +1,150 @@ +// Copyright (c) 2020-2021 Drew Lemmy +// This file is part of TenebraWeb 2 under AGPL-3.0. +// Full details: https://github.com/tmpim/TenebraWeb2/blob/master/LICENSE.txt +import { Switch, Route, Redirect } from "react-router-dom"; +import { ErrorBoundary } from "@global/ErrorBoundary"; + +import { DashboardPage } from "@pages/dashboard/DashboardPage"; +import { WalletsPage } from "@pages/wallets/WalletsPage"; +import { ContactsPage } from "@pages/contacts/ContactsPage"; + +import { SendTransactionPage } from "@pages/transactions/send/SendTransactionPage"; +import { RequestPage } from "@pages/transactions/request/RequestPage"; + +import { AddressPage } from "@pages/addresses/AddressPage"; +import { BlocksPage } from "@pages/blocks/BlocksPage"; +import { BlockPage } from "@pages/blocks/BlockPage"; +import { TransactionsPage, ListingType as TXListing } from "@pages/transactions/TransactionsPage"; +import { TransactionPage } from "@pages/transactions/TransactionPage"; +import { NamesPage, ListingType as NamesListing } from "@pages/names/NamesPage"; +import { NamePage } from "@pages/names/NamePage"; + +import { SettingsPage } from "@pages/settings/SettingsPage"; +import { SettingsTranslations } from "@pages/settings/translations/SettingsTranslations"; + +import { WhatsNewPage } from "@pages/whatsnew/WhatsNewPage"; +import { CreditsPage } from "@pages/credits/CreditsPage"; + +import { DevPage } from "@pages/dev/DevPage"; + +import { NotFoundPage } from "@pages/NotFoundPage"; + +interface AppRoute { + path: string; + name: string; + component?: React.ReactNode; +} + +export const APP_ROUTES: AppRoute[] = [ + { path: "/", name: "dashboard", component: }, + + // My wallets, etc + { path: "/wallets", name: "wallets", component: }, + { path: "/contacts", name: "contacts", component: }, + { path: "/me/transactions", name: "myTransactions", + component: }, + { path: "/me/names", name: "myNames", + component: }, + + // Payments + { path: "/send", name: "sendTransaction", component: }, + { path: "/request", name: "request", component: }, + + // Network explorer + { path: "/network/addresses/:address", name: "address", component: }, + { path: "/network/addresses/:address/transactions", name: "addressTransactions", + component: }, + { path: "/network/addresses/:address/names", name: "addressNames", + component: }, + { path: "/network/blocks", name: "blocks", component: }, + { path: "/network/blocks/lowest", name: "blocksLowest", component: }, + { path: "/network/blocks/:id", name: "block", component: }, + { path: "/network/transactions", name: "transactions", + component: }, + { path: "/network/transactions/:id", name: "transaction", + component: }, + { path: "/network/names", name: "networkNames", + component: }, + { path: "/network/names/new", name: "networkNamesNew", + component: }, + { path: "/network/names/:name", name: "networkName", + component: }, + { path: "/network/names/:name/history", name: "nameHistory", + component: }, + { path: "/network/names/:name/transactions", name: "nameTransactions", + component: }, + { path: "/network/search/transactions/address", name: "searchTransactionsAddress", + component: }, + { path: "/network/search/transactions/name", name: "searchTransactionsName", + component: }, + { path: "/network/search/transactions/metadata", name: "searchTransactionsMetadata", + component: }, + + // Settings + { path: "/settings", name: "settings", component: }, + { path: "/settings/debug", name: "settingsDebug" }, + { path: "/settings/debug/translations", name: "settings", component: }, + + { path: "/whatsnew", name: "whatsNew", component: }, + { path: "/credits", name: "credits", component: }, + + // NYI and dev + { path: "/mining", name: "mining", component: }, + { path: "/network/statistics", name: "statistics", component: }, + { path: "/dev", name: "dev", component: } +]; + +export function AppRouter(): JSX.Element { + return + {/* Render the matched route's page component */} + {APP_ROUTES.map(({ path, component }, key) => ( + component && ( + + {/* Try to catch errors on a route without crashing everything */} + + {component} + + + ) + ))} + + {/* Redirects from TenebraWeb v1 router */} + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ; +} diff --git a/src/global/AppServices.tsx b/src/global/AppServices.tsx new file mode 100644 index 0000000..73c6449 --- /dev/null +++ b/src/global/AppServices.tsx @@ -0,0 +1,26 @@ +// Copyright (c) 2020-2021 Drew Lemmy +// This file is part of TenebraWeb 2 under AGPL-3.0. +// Full details: https://github.com/tmpim/TenebraWeb2/blob/master/LICENSE.txt +import { StorageBroadcast } from "./StorageBroadcast"; +import { LegacyMigration } from "./legacy/LegacyMigration"; +import { SyncWallets } from "@comp/wallets/SyncWallets"; +import { ForcedAuth } from "./ForcedAuth"; +import { WebsocketService } from "./ws/WebsocketService"; +import { SyncMOTD } from "./ws/SyncMOTD"; +import { AppHotkeys } from "./AppHotkeys"; +import { PurchaseTenebraHandler } from "./PurchaseTenebra"; +import { AdvanceTip } from "@pages/dashboard/TipsCard"; + +export function AppServices(): JSX.Element { + return <> + + + + + + + + + + ; +} diff --git a/src/global/ErrorBoundary.tsx b/src/global/ErrorBoundary.tsx new file mode 100644 index 0000000..28bc236 --- /dev/null +++ b/src/global/ErrorBoundary.tsx @@ -0,0 +1,48 @@ +// Copyright (c) 2020-2021 Drew Lemmy +// This file is part of TenebraWeb 2 under AGPL-3.0. +// Full details: https://github.com/tmpim/TenebraWeb2/blob/master/LICENSE.txt +import { FC } from "react"; +import { Alert } from "antd"; + +import { useTFns } from "@utils/i18n"; + +import * as Sentry from "@sentry/react"; +import { errorReporting } from "@utils"; + +interface Props { + name: string; +} + +export const ErrorBoundary: FC = ({ name, children }) => { + return } + onError={console.error} + + // Add the boundary name to the scope + beforeCapture={scope => { + scope.setTag("error-boundary", name); + }} + > + {children} + ; +}; + +function ErrorFallback(): JSX.Element { + const { tStr } = useTFns("errorBoundary."); + + return +

{tStr("description")}

+ + {/* If Sentry error reporting is enabled, add a message saying the error + * was automatically reported. */} + {errorReporting && ( +

{tStr("sentryNote")}

+ )} + } + />; +} diff --git a/src/global/ForcedAuth.tsx b/src/global/ForcedAuth.tsx new file mode 100644 index 0000000..9c76f58 --- /dev/null +++ b/src/global/ForcedAuth.tsx @@ -0,0 +1,38 @@ +// Copyright (c) 2020-2021 Drew Lemmy +// This file is part of TenebraWeb 2 under AGPL-3.0. +// Full details: https://github.com/tmpim/TenebraWeb2/blob/master/LICENSE.txt +import { message } from "antd"; +import { useTranslation, TFunction } from "react-i18next"; + +import { authMasterPassword, useMasterPassword } from "@wallets"; + +import { useMountEffect } from "@utils/hooks"; +import { criticalError } from "@utils"; + +async function forceAuth(t: TFunction, salt: string, tester: string): Promise { + try { + const password = localStorage.getItem("forcedAuth"); + if (!password) return; + + await authMasterPassword(salt, tester, password); + message.warning(t("masterPassword.forcedAuthWarning")); + } catch (e) { + criticalError(e); + } +} + +/** For development purposes, check the presence of a local storage key + * containing the master password, and automatically authenticate with it. */ +export function ForcedAuth(): JSX.Element | null { + const { isAuthed, hasMasterPassword, salt, tester } + = useMasterPassword(); + + const { t } = useTranslation(); + + useMountEffect(() => { + if (isAuthed || !hasMasterPassword || !salt || !tester) return; + forceAuth(t, salt, tester); + }); + + return null; +} diff --git a/src/global/LocaleContext.tsx b/src/global/LocaleContext.tsx new file mode 100644 index 0000000..c77f9e6 --- /dev/null +++ b/src/global/LocaleContext.tsx @@ -0,0 +1,118 @@ +// Copyright (c) 2020-2021 Drew Lemmy +// This file is part of TenebraWeb 2 under AGPL-3.0. +// Full details: https://github.com/tmpim/TenebraWeb2/blob/master/LICENSE.txt +import { FC, createContext, useEffect, useState } from "react"; +import { ConfigProvider } from "antd"; +import { Locale } from "antd/lib/locale-provider"; + +import { useTranslation } from "react-i18next"; +import { getLanguages } from "@utils/i18n"; + +import dayjs from "dayjs"; + +import { Formatter } from "react-timeago"; +import buildFormatter from "react-timeago/lib/formatters/buildFormatter"; + +import { criticalError } from "@utils"; + +import Debug from "debug"; +const debug = Debug("tenebraweb:locale-context"); + +export const TimeagoFormatterContext = createContext(undefined); + +export const LocaleContext: FC = ({ children }): JSX.Element => { + const { i18n } = useTranslation(); + const langCode = i18n.language; + const languages = getLanguages(); + const lang = languages?.[langCode]; + + // These are wrapped in objects due to some bizarre issues where React was + // attempting to call the timeagoFormatter at some point?? + const [timeagoFormatter, setTimeagoFormatter] = useState<{ formatter: Formatter }>(); + const [antLocale, setAntLocale] = useState<{ locale: Locale }>(); + + // Load the day.js locale if available + useEffect(() => { + // See if the language has a dayjs locale set. If not, revert to `en` + const dayjsLocale = lang?.dayjsLocale; + if (!dayjsLocale) { + debug("language %s doesn't have a dayjs locale, reverting to `en`", langCode); + dayjs.locale("en"); + return; + } + + // Attempt to import the locale asynchronously. This will usually incur a + // network request, but it should be cached by the service worker. + debug("loading dayjs locale %s for language %s", dayjsLocale, langCode); + // Including only `.js` files here ensures that it doesn't attempt to load + // the TypeScript typings, which causes build warnings due to a missing + // loader for those files. + import( + /* webpackInclude: /\b(dayjsLocale|de|fr|nl|pl|pt-br|vi)\.js$/ */ + /* webpackMode: "lazy" */ + /* webpackChunkName: "locale-dayjs-[request]" */ + `dayjs/locale/${dayjsLocale}` + ) + .then(() => { + debug("got dayjs locale %s", dayjsLocale); + dayjs.locale(dayjsLocale); + }) + .catch(criticalError); + }, [lang, langCode, languages]); + + // Load the timeago locale if available + useEffect(() => { + // See if the language has a timeago locale set. If not, revert to default + const timeagoLocale = lang?.timeagoLocale; + if (!timeagoLocale) { + debug("language %s doesn't have a timeago locale, reverting to default", langCode); + setTimeagoFormatter(undefined); + return; + } + + // Load the locale + debug("loading timeago locale %s for language %s", timeagoLocale, langCode); + import( + /* webpackInclude: /\b(timeagoLocale|de|fr|nl|pl|pt-br|vi)\.js$/ */ + /* webpackMode: "lazy" */ + /* webpackChunkName: "locale-timeago-[request]" */ + `react-timeago/lib/language-strings/${timeagoLocale}` + ) + .then(strings => { + debug("got timeago locale %s", timeagoLocale); + setTimeagoFormatter({ formatter: buildFormatter(strings.default) }); + }) + .catch(criticalError); + }, [lang, langCode, languages]); + + // Load the antd locale if available + useEffect(() => { + // See if the language has an antd locale set. If not, revert to default + const antLocaleCode = lang?.antLocale; + if (!antLocaleCode) { + debug("language %s doesn't have an antd locale, reverting to default", langCode); + setAntLocale(undefined); + return; + } + + // Load the locale + debug("loading antd locale %s for language %s", antLocaleCode, langCode); + import( + /* webpackInclude: /\b(antLocale|de_DE|fr_FR|nl_NL|pl_PL|pt_BR|vi_VN)\.js$/ */ + /* webpackMode: "lazy" */ + /* webpackChunkName: "locale-antd-[request]" */ + `antd/lib/locale/${antLocaleCode}` + ) + .then(locale => { + debug("got antd locale %s", antLocaleCode); + setAntLocale({ locale: locale.default }); + }) + .catch(criticalError); + }, [lang, langCode, languages]); + + return + + {children} + + ; +}; diff --git a/src/global/PurchaseTenebra.tsx b/src/global/PurchaseTenebra.tsx new file mode 100644 index 0000000..3cd2f11 --- /dev/null +++ b/src/global/PurchaseTenebra.tsx @@ -0,0 +1,89 @@ +// Copyright (c) 2020-2021 Drew Lemmy +// This file is part of TenebraWeb 2 under AGPL-3.0. +// Full details: https://github.com/tmpim/TenebraWeb2/blob/master/LICENSE.txt +import { useState, Dispatch, SetStateAction } from "react"; +import { Modal, Row, Col, Button } from "antd"; + +import { useTFns } from "@utils/i18n"; + +import { TenebraValue } from "@comp/tenebra/TenebraValue"; + +import { GlobalHotKeys } from "react-hotkeys"; + +interface Props { + visible: boolean; + setVisible: Dispatch>; +} + +interface PurchaseOption { + image?: string; + source: number; + tenebra: number; +} + +const VALUES: PurchaseOption[][] = [ + [ + { source: 5000, tenebra: 50 }, { source: 10000, tenebra: 100 }, + { source: 25000, tenebra: 250 }, { source: 50000, tenebra: 500 } + ], + [ + { source: 100000, tenebra: 1000 }, { source: 250000, tenebra: 2500 }, + { source: 500000, tenebra: 5000 }, { source: 500000000, tenebra: 5000000 } + ] +]; + +export function PurchaseTenebra({ + visible, + setVisible +}: Props): JSX.Element { + const { t, tStr } = useTFns("purchaseTenebra."); + + return setVisible(false)}> + {t("dialog.close")} + } + onCancel={() => setVisible(false)} + > + {VALUES.map((row, i) => + {row.map((option, i) => ( + +
+ +

+ +
+ + ))} +
)} +
; +} + +export function PurchaseTenebraHandler(): JSX.Element { + const [visible, setVisible] = useState(false); + + return <> + + + setVisible(true) }} + /> + ; +} diff --git a/src/global/StorageBroadcast.tsx b/src/global/StorageBroadcast.tsx new file mode 100644 index 0000000..4be15db --- /dev/null +++ b/src/global/StorageBroadcast.tsx @@ -0,0 +1,112 @@ +// Copyright (c) 2020-2021 Drew Lemmy +// This file is part of TenebraWeb 2 under AGPL-3.0. +// Full details: https://github.com/tmpim/TenebraWeb2/blob/master/LICENSE.txt +import { store } from "@app"; +import * as actions from "@actions/WalletsActions"; +import * as contactActions from "@actions/ContactsActions"; + +import { getWalletKey, parseWallet, syncWallet } from "@wallets"; +import { getContactKey, parseContact } from "@contacts"; + +// Required for Safari +import "broadcastchannel-polyfill"; + +import Debug from "debug"; +const debug = Debug("tenebraweb:storage-broadcast"); + +export const channel = new BroadcastChannel("tenebraweb:storage"); + +export function broadcastAddWallet(id: string): void { + debug("broadcasting addWallet event for wallet id %s", id); + channel.postMessage(["addWallet", id]); +} + +export function broadcastEditWallet(id: string): void { + debug("broadcasting editWallet event for wallet id %s", id); + channel.postMessage(["editWallet", id]); +} + +export function broadcastDeleteWallet(id: string): void { + debug("broadcasting deleteWallet event for wallet id %s", id); + channel.postMessage(["deleteWallet", id]); +} + +export function broadcastAddContact(id: string): void { + debug("broadcasting deleteContact event for contact id %s", id); + channel.postMessage(["deleteContact", id]); +} + +export function broadcastEditContact(id: string): void { + debug("broadcasting editContact event for contact id %s", id); + channel.postMessage(["editContact", id]); +} + +export function broadcastDeleteContact(id: string): void { + debug("broadcasting deleteContact event for contact id %s", id); + channel.postMessage(["deleteContact", id]); +} + +/** Component that manages a BroadcastChannel responsible for dispatching wallet + * storage events (add, edit, delete) across tabs. */ +export function StorageBroadcast(): JSX.Element | null { + debug("registering storage broadcast event listener"); + channel.onmessage = e => { + debug("received storage broadcast:", e); + + if (Array.isArray(e.data)) { + const [type, ...data] = e.data; + + if (type === "addWallet" || type === "editWallet") { + // --------------------------------------------------------------------- + // addWallet, editWallet + // --------------------------------------------------------------------- + const id: string = data[0]; + const key = getWalletKey(id); + + // Load the wallet from localStorage (the update should've been + // synchronous) + const wallet = parseWallet(id, localStorage.getItem(key)); + debug("%s broadcast %s", type, id); + + // Dispatch the new/updated wallet to the Redux store + if (type === "addWallet") store.dispatch(actions.addWallet(wallet)); + else store.dispatch(actions.updateWallet(id, wallet)); + + syncWallet(wallet, true); + } else if (type === "deleteWallet") { + // --------------------------------------------------------------------- + // deleteWallet + // --------------------------------------------------------------------- + const id: string = data[0]; + debug("addWallet broadcast %s", id); + store.dispatch(actions.removeWallet(id)); + } else if (type === "addContact" || type === "editContact") { + // --------------------------------------------------------------------- + // addContact, editContact + // --------------------------------------------------------------------- + const id: string = data[0]; + const key = getContactKey(id); + + // Load the contact from localStorage (the update should've been + // synchronous) + const contact = parseContact(id, localStorage.getItem(key)); + debug("%s broadcast %s", type, id); + + // Dispatch the new/updated contact to the Redux store + if (type === "addContact") store.dispatch(contactActions.addContact(contact)); + else store.dispatch(contactActions.updateContact({ id, contact })); + } else if (type === "deleteContact") { + // --------------------------------------------------------------------- + // deleteContact + // --------------------------------------------------------------------- + const id: string = data[0]; + debug("deleteContact broadcast %s", id); + store.dispatch(contactActions.removeContact(id)); + } else { + debug("received unknown broadcast msg type %s", type); + } + } + }; + + return null; +} diff --git a/src/global/compat/CompatCheckModal.less b/src/global/compat/CompatCheckModal.less new file mode 100644 index 0000000..8c3a178 --- /dev/null +++ b/src/global/compat/CompatCheckModal.less @@ -0,0 +1,43 @@ +// Copyright (c) 2020-2021 Drew Lemmy +// This file is part of TenebraWeb 2 under AGPL-3.0. +// Full details: https://github.com/tmpim/TenebraWeb2/blob/master/LICENSE.txt +@import (reference) "../../App.less"; + +.compat-check-modal { + .ant-modal-confirm-btns { + display: none; + } + + .browser-choices { + display: flex; + flex-direction: row; + + // Remove the offset from the confirm modal body padding + margin-left: -38px; + padding-top: @margin-sm; + + a { + flex: 1; + + display: flex; + flex-direction: column; + + padding: @margin-sm; + + color: @text-color; + text-align: center; + background: transparent; + border-radius: @border-radius-base; + transition: all @animation-duration-base ease; + + &:hover { + background: @kw-lighter; + } + + img { + width: 96px; + margin: 0 auto @margin-md auto; + } + } + } +} diff --git a/src/global/compat/CompatCheckModal.tsx b/src/global/compat/CompatCheckModal.tsx new file mode 100644 index 0000000..e68a9b6 --- /dev/null +++ b/src/global/compat/CompatCheckModal.tsx @@ -0,0 +1,69 @@ +// Copyright (c) 2020-2021 Drew Lemmy +// This file is part of TenebraWeb 2 under AGPL-3.0. +// Full details: https://github.com/tmpim/TenebraWeb2/blob/master/LICENSE.txt +import { Modal, Typography } from "antd"; + +import { CompatCheck } from "."; + +import "./CompatCheckModal.less"; + +const { Text } = Typography; + +interface Props { + failedChecks: CompatCheck[]; +} + +function CompatCheckModalContent({ failedChecks }: Props): JSX.Element { + // Note that this modal is not translated, as `fetch` is one of the APIs that + // may be unavailable. + + return <> +

Your browser is missing features required by TenebraWeb. +
Please upgrade your web browser.

+ + {/* Missing feature list */} +

+ Missing feature{failedChecks.length > 1 && <>s}:  + {failedChecks.map((c, i, a) => ( + + {c.url + ? + {c.name} + + : {c.name}} + + {i < a.length - 1 && <>, } + + ))} +

+ +

Please upgrade to the latest version of one of these recommended browsers:

+ + {/* Browser choices */} + + ; +} + +export function openCompatCheckModal(failedChecks: CompatCheck[]): void { + Modal.error({ + title: "Unsupported browser", + + width: 640, + className: "compat-check-modal", + + okButtonProps: { style: { display: "none" }}, + closable: false, + + content: + }); +} diff --git a/src/global/compat/index.ts b/src/global/compat/index.ts new file mode 100644 index 0000000..ad2701e --- /dev/null +++ b/src/global/compat/index.ts @@ -0,0 +1,63 @@ +// Copyright (c) 2020-2021 Drew Lemmy +// This file is part of TenebraWeb 2 under AGPL-3.0. +// Full details: https://github.com/tmpim/TenebraWeb2/blob/master/LICENSE.txt +import { localStorageAvailable } from "./localStorage"; + +import { openCompatCheckModal } from "./CompatCheckModal"; + +import { criticalError } from "@utils"; + +import Debug from "debug"; +const debug = Debug("tenebraweb:compat-check"); + +export interface CompatCheck { + name: string; + url?: string; + check: () => Promise | boolean; +} + +const CHECKS: CompatCheck[] = [ + { name: "Local Storage", url: "https://developer.mozilla.org/en-US/docs/Web/API/Window/localStorage", + check: localStorageAvailable }, + { name: "IndexedDB", url: "https://developer.mozilla.org/en-US/docs/Web/API/IndexedDB_API", + check: () => !!window.indexedDB }, + { name: "Fetch", url: "https://developer.mozilla.org/en-US/docs/Web/API/Fetch_API", + check: () => !!window.fetch }, + { name: "Crypto", url: "https://developer.mozilla.org/en-US/docs/Web/API/Window/crypto", + check: () => !!window.crypto }, + { name: "SubtleCrypto", url: "https://developer.mozilla.org/en-US/docs/Web/API/SubtleCrypto", + check: () => !!window.crypto && !!window.crypto.subtle }, + { name: "Broadcast Channel", url: "https://developer.mozilla.org/en-US/docs/Web/API/Broadcast_Channel_API", + check: () => !!BroadcastChannel }, + { name: "Web Workers", url: "https://developer.mozilla.org/en-US/docs/Web/API/Web_Workers_API/Using_web_workers", + check: () => !!window.Worker } + // NOTE: Service workers are not checked here, because they are disabled in + // Firefox private browsing. +]; + +/** Checks if the browser has all the required APIs, and returns an array of + * failed compatibility checks. */ +async function runCompatChecks(): Promise { + const failed: CompatCheck[] = []; + for (const check of CHECKS) { + try { + if (!(await check.check())) + throw new Error("check returned false"); + } catch (err) { + debug("compatibility check %s failed", check.name); + criticalError(err); + failed.push(check); + } + } + return failed; +} + +/** Runs the compatibility checks, displaying the "Unsupported browser" modal + * and throwing if any of them fail. */ +export async function compatCheck(): Promise { + const failedChecks = await runCompatChecks(); + if (failedChecks.length) { + openCompatCheckModal(failedChecks); + throw new Error("compat checks failed"); + } +} diff --git a/src/global/compat/localStorage.ts b/src/global/compat/localStorage.ts new file mode 100644 index 0000000..6716a96 --- /dev/null +++ b/src/global/compat/localStorage.ts @@ -0,0 +1,31 @@ +// Copyright (c) 2020-2021 Drew Lemmy +// This file is part of TenebraWeb 2 under AGPL-3.0. +// Full details: https://github.com/tmpim/TenebraWeb2/blob/master/LICENSE.txt + +// Implementation sourced from MDN: +// https://developer.mozilla.org/en-US/docs/Web/API/Web_Storage_API/Using_the_Web_Storage_API#feature-detecting_localstorage +export function localStorageAvailable( + type: "localStorage" | "sessionStorage" = "localStorage" +): boolean { + let storage; + try { + storage = window[type]; + const x = "__storage_test__"; + storage.setItem(x, x); + storage.removeItem(x); + return true; + } catch(e) { + return e instanceof DOMException && ( + // everything except Firefox + e.code === 22 || + // Firefox + e.code === 1014 || + // test name field too, because code might not be present + // everything except Firefox + e.name === "QuotaExceededError" || + // Firefox + e.name === "NS_ERROR_DOM_QUOTA_REACHED") && + // acknowledge QuotaExceededError only if there's something already stored + (!!storage && storage.length !== 0); + } +} diff --git a/src/global/legacy/LegacyMigration.tsx b/src/global/legacy/LegacyMigration.tsx new file mode 100644 index 0000000..c186421 --- /dev/null +++ b/src/global/legacy/LegacyMigration.tsx @@ -0,0 +1,121 @@ +// Copyright (c) 2020-2021 Drew Lemmy +// This file is part of TenebraWeb 2 under AGPL-3.0. +// Full details: https://github.com/tmpim/TenebraWeb2/blob/master/LICENSE.txt +import { useState, useEffect } from "react"; + +import { BackupFormatType, BackupTenebraWebV1 } from "@pages/backup/backupFormats"; +import { LegacyMigrationModal } from "./LegacyMigrationModal"; + +import Debug from "debug"; +const debug = Debug("tenebraweb:legacy-migration"); + +const MIGRATION_CLEANUP_THRESHOLD = 7 * 24 * 60 * 60 * 1000; +const LEGACY_KEY_RE = /^(?:(?:Wallet|Friend)(?:-.+)?|salt|tester)$/; + +// 7 days after a legacy migration, the old data can most likely now be removed +// from local storage. +function removeOldData() { + debug("checking to remove old data"); + + const migratedTime = localStorage.getItem("legacyMigratedTime"); + if (!migratedTime) { + debug("no migrated time, done"); + return; + } + + const t = new Date(migratedTime); + const now = new Date(); + const diff = now.getTime() - t.getTime(); + debug("%d ms since migration", diff); + + if (diff <= MIGRATION_CLEANUP_THRESHOLD) { + debug("migration threshold not enough time, done"); + return; + } + + // Remove all the old data from local storage, including the old salt and + // tester, the wallets, and the friends. + const keysToRemove = []; + for (let i = 0; i < localStorage.length; i++) { + const key = localStorage.key(i); + if (key && LEGACY_KEY_RE.test(key)) { + // Push the keys to a queue to remove after looping, because otherwise + // localStorage.length would change during iteration. + debug("removing key %s", key); + keysToRemove.push(key); + } + } + + // Now actually remove the keys + keysToRemove.forEach(k => localStorage.removeItem(k)); + + // Clean up legacyMigratedTime so this doesn't happen again + localStorage.removeItem("legacyMigratedTime"); + + debug("legacy migration cleanup done"); +} + +export function LegacyMigration(): JSX.Element | null { + const [backup, setBackup] = useState(); + + // Check if a legacy migration needs to be performed + useEffect(() => { + debug("checking legacy migration status"); + + // Check if legacy migration has already been handled + const legacyMigrated = localStorage.getItem("migrated"); + if (legacyMigrated === "2") { + debug("migration already at 2, done"); + removeOldData(); + return; + } + + // Check if there is a v1 master password in local storage + const salt = localStorage.getItem("salt") || undefined; + const tester = localStorage.getItem("tester") || undefined; + if (!salt || !tester) { + debug("no legacy master password, done"); + return; + } + + // Check if there are any v1 wallets or contacts in local storage + const walletIndex = localStorage.getItem("Wallet") || undefined; + const contactIndex = localStorage.getItem("Friend") || undefined; + if (!walletIndex && !contactIndex) { + debug("no wallets or contacts, done"); + return; + } + + // Fetch all the wallets and contacts, skipping over any that are missing + const wallets = Object.fromEntries((walletIndex || "") + .split(",") + .map(id => [`Wallet-${id}`, localStorage.getItem(`Wallet-${id}`)]) + .filter(([_, v]) => !!v)); + const contacts = Object.fromEntries((contactIndex || "") + .split(",") + .map(id => [`Friend-${id}`, localStorage.getItem(`Friend-${id}`)]) + .filter(([_, v]) => !!v)); + debug("found %d wallets and %d contacts", + Object.keys(wallets).length, Object.keys(contacts).length); + + // Construct the backup object prior to showing the modal + const backup: BackupTenebraWebV1 = { + type: BackupFormatType.KRISTWEB_V1, + + salt, + tester, + + wallets, + friends: contacts + }; + + setBackup(backup); + }, []); + + return backup + ? setBackup(undefined)} + /> + : null; +} diff --git a/src/global/legacy/LegacyMigrationForm.tsx b/src/global/legacy/LegacyMigrationForm.tsx new file mode 100644 index 0000000..3decb5a --- /dev/null +++ b/src/global/legacy/LegacyMigrationForm.tsx @@ -0,0 +1,171 @@ +// Copyright (c) 2020-2021 Drew Lemmy +// This file is part of TenebraWeb 2 under AGPL-3.0. +// Full details: https://github.com/tmpim/TenebraWeb2/blob/master/LICENSE.txt +import { useState, Dispatch, SetStateAction} from "react"; +import { Form, notification } from "antd"; + +import { Trans } from "react-i18next"; +import { useTFns, translateError } from "@utils/i18n"; + +import { store } from "@app"; + +import { getMasterPasswordInput } from "@comp/auth/MasterPasswordInput"; +import { useAuth } from "@comp/auth/AuthorisedAction"; +import { setMasterPassword } from "@wallets"; + +import { BackupTenebraWebV1 } from "@pages/backup/backupFormats"; +import { IncrProgressFn, InitProgressFn } from "@pages/backup/ImportProgress"; +import { backupVerifyPassword, backupImport } from "@pages/backup/backupImport"; +import { BackupResults } from "@pages/backup/backupResults"; + +import { criticalError } from "@utils"; + +import Debug from "debug"; +const debug = Debug("tenebraweb:legacy-migration-form"); + +interface LegacyMigrationFormHookRes { + form: JSX.Element; + + triggerSubmit: () => Promise; +} + +export function useLegacyMigrationForm( + setLoading: Dispatch>, + setResults: Dispatch>, + + onProgress: IncrProgressFn, + initProgress: InitProgressFn, + + backup: BackupTenebraWebV1, +): LegacyMigrationFormHookRes { + const { t, tStr, tKey } = useTFns("legacyMigration."); + + const [form] = Form.useForm(); + const promptAuth = useAuth(); + + const [masterPasswordError, setMasterPasswordError] = useState(); + + const foundWalletCount = Object.keys(backup.wallets).length; + const foundContactCount = Object.keys(backup.friends).length; + + async function onFinish() { + const values = await form.validateFields(); + const { masterPassword } = values; + if (!masterPassword) return; + + setLoading(true); + + try { + // Attempt to verify the master password + await backupVerifyPassword(backup, masterPassword); + setMasterPasswordError(undefined); + + // Initialise the app's master password to this one, if it's not already + // set up + const hasMP = store.getState().masterPassword.hasMasterPassword; + if (!hasMP) { + debug("no existing master password, initialising with provided one"); + await setMasterPassword(masterPassword); + await beginImport(masterPassword); + } else { + // A master password is already set up. In case they are different, the + // old wallets will need to be re-encrypted with the new password. The + // backupImport function grabs the master password from the store, so we + // must prompt for auth first before continuing. + debug("existing master password, prompting for auth"); + promptAuth(true, () => beginImport(masterPassword)); + } + } catch (err) { + debug("legacy migration error block 1"); + console.error(err); + if (err.message === "import.masterPasswordRequired" + || err.message === "import.masterPasswordIncorrect") { + // Master password incorrect error + setMasterPasswordError(translateError(t, err)); + } else { + // Any other import error + criticalError(err); + notification.error({ message: tStr("errorUnknown") }); + } + + setLoading(false); + } + } + + async function beginImport(masterPassword: string) { + try { + // Perform the import + const results = await backupImport( + backup, masterPassword, false, + onProgress, initProgress + ); + + // Mark the legacy migration as performed, so the modal doesn't appear + // again. Also store the date, so the old data can be removed after an + // appropriate amount of time has passed. + localStorage.setItem("migrated", "2"); + localStorage.setItem("legacyMigratedTime", new Date().toISOString()); + + setResults(results); + } catch (err) { + debug("legacy migration error block 2"); + criticalError(err); + notification.error({ message: tStr("errorUnknown") }); + } finally { + debug("finally setLoading false"); + setLoading(false); + } + } + + const formEl =
+ {/* Description */} +

+ +

+ + {/* Found wallet and contact count */} + {foundWalletCount > 0 &&

+ + Detected {{ count: foundWalletCount }} wallets + +

} + {foundContactCount > 0 &&

+ + Detected {{ count: foundContactCount }} contacts + +

} + + {/* Password input */} + + {getMasterPasswordInput({ + placeholder: tStr("masterPasswordPlaceholder"), + autoFocus: true + })} + +
; + + return { + form: formEl, + triggerSubmit: onFinish + }; +} diff --git a/src/global/legacy/LegacyMigrationModal.tsx b/src/global/legacy/LegacyMigrationModal.tsx new file mode 100644 index 0000000..24cb336 --- /dev/null +++ b/src/global/legacy/LegacyMigrationModal.tsx @@ -0,0 +1,108 @@ +// Copyright (c) 2020-2021 Drew Lemmy +// This file is part of TenebraWeb 2 under AGPL-3.0. +// Full details: https://github.com/tmpim/TenebraWeb2/blob/master/LICENSE.txt +import { useState } from "react"; +import { Modal, Button } from "antd"; +import { ExclamationCircleOutlined } from "@ant-design/icons"; + +import { useTFns } from "@utils/i18n"; + +import { useLegacyMigrationForm } from "./LegacyMigrationForm"; + +import { BackupTenebraWebV1 } from "@pages/backup/backupFormats"; +import { useImportProgress } from "@pages/backup/ImportProgress"; +import { BackupResults } from "@pages/backup/backupResults"; +import { BackupResultsSummary } from "@pages/backup/BackupResultsSummary"; +import { BackupResultsTree } from "@pages/backup/BackupResultsTree"; + +import Debug from "debug"; +const debug = Debug("tenebraweb:legacy-migration-modal"); + +interface Props { + backup: BackupTenebraWebV1; + setVisible: (visible: boolean) => void; +} + +export function LegacyMigrationModal({ + backup, + setVisible +}: Props): JSX.Element { + const { t, tStr } = useTFns("legacyMigration."); + + const [loading, setLoading] = useState(false); + const [results, setResults] = useState(); + + const { progressBar, onProgress, initProgress } = useImportProgress(); + + const { form, triggerSubmit } = useLegacyMigrationForm( + setLoading, setResults, onProgress, initProgress, backup + ); + + function closeModal() { + setVisible(false); + } + + function openForgotPasswordModal() { + Modal.confirm({ + title: tStr("forgotPassword.modalTitle"), + icon: , + + content: tStr("forgotPassword.modalContent"), + + cancelText: t("dialog.cancel"), + + okText: tStr("forgotPassword.buttonSkip"), + okType: "danger", + onOk() { + debug("skipping v1 legacy migration"); + + // Mark the migration as completed, so the modal won't appear again. + // The data will be deleted after 7 days. + localStorage.setItem("migrated", "2"); + localStorage.setItem("legacyMigratedTime", new Date().toISOString()); + + closeModal(); + } + }); + } + + return + {/* Forgot password button */} + {!loading && !results && } + + {/* Submit button */} + + } + > + {/* Show the results screen, progress bar, or backup form */} + {results + ? <> + + + + : (loading + ? progressBar + : form)} + ; +} diff --git a/src/global/ws/SyncDetailedWork.tsx b/src/global/ws/SyncDetailedWork.tsx new file mode 100644 index 0000000..0be92de --- /dev/null +++ b/src/global/ws/SyncDetailedWork.tsx @@ -0,0 +1,37 @@ +// Copyright (c) 2020-2021 Drew Lemmy +// This file is part of TenebraWeb 2 under AGPL-3.0. +// Full details: https://github.com/tmpim/TenebraWeb2/blob/master/LICENSE.txt +import { useEffect } from "react"; + +import { useSelector } from "react-redux"; +import { RootState } from "@store"; +import * as nodeActions from "@actions/NodeActions"; + +import { store } from "@app"; + +import * as api from "@api"; +import { TenebraWorkDetailed } from "@api/types"; + +import { criticalError } from "@utils"; + +import Debug from "debug"; +const debug = Debug("tenebraweb:sync-work"); + +export async function updateDetailedWork(): Promise { + debug("updating detailed work"); + const data = await api.get("work/detailed"); + + debug("work: %d", data.work); + store.dispatch(nodeActions.setDetailedWork(data)); +} + +/** Sync the detailed work with the Tenebra node on startup. */ +export function SyncDetailedWork(): JSX.Element | null { + const { lastBlockID } = useSelector((s: RootState) => s.node); + + useEffect(() => { + updateDetailedWork().catch(criticalError); + }, [lastBlockID]); + + return null; +} diff --git a/src/global/ws/SyncMOTD.tsx b/src/global/ws/SyncMOTD.tsx new file mode 100644 index 0000000..9f2ad80 --- /dev/null +++ b/src/global/ws/SyncMOTD.tsx @@ -0,0 +1,82 @@ +// Copyright (c) 2020-2021 Drew Lemmy +// This file is part of TenebraWeb 2 under AGPL-3.0. +// Full details: https://github.com/tmpim/TenebraWeb2/blob/master/LICENSE.txt +import { useEffect } from "react"; +import { message } from "antd"; + +import { useSelector } from "react-redux"; +import { RootState } from "@store"; +import * as nodeActions from "@actions/NodeActions"; + +import { store } from "@app"; + +import * as api from "@api"; +import { TenebraMOTD, TenebraMOTDBase } from "@api/types"; + +import { + recalculateWallets, useWallets, useMasterPasswordOnly +} from "@wallets"; +import { useAddressPrefix } from "@utils/tenebra"; + +import { criticalError } from "@utils"; + +import Debug from "debug"; +const debug = Debug("tenebraweb:sync-motd"); + +export async function updateMOTD(): Promise { + debug("updating motd"); + const data = await api.get("motd"); + + debug("motd: %s", data.motd); + store.dispatch(nodeActions.setPackage(data.package)); + store.dispatch(nodeActions.setCurrency(data.currency)); + store.dispatch(nodeActions.setConstants(data.constants)); + + if (data.last_block) { + debug("motd last block id: %d", data.last_block.height); + store.dispatch(nodeActions.setLastBlockID(data.last_block.height)); + } + + const motdBase: TenebraMOTDBase = { + motd: data.motd, + motdSet: new Date(data.motd_set), + endpoint: data.public_url, + debugMode: data.debug_mode, + miningEnabled: data.mining_enabled + }; + store.dispatch(nodeActions.setMOTD(motdBase)); + + if (motdBase.debugMode) { + setTimeout(() => { + message.warning("This server is an unofficial server. Your passwords or K" + + "rist may be stolen. Proceed with caution.", 20); + }, 60000); + } +} + +/** Sync the MOTD with the Tenebra node on startup. */ +export function SyncMOTD(): JSX.Element | null { + const syncNode = api.useSyncNode(); + const connectionState = useSelector((s: RootState) => s.websocket.connectionState); + + // All these are used to determine if we need to recalculate the addresses + const addressPrefix = useAddressPrefix(); + const masterPassword = useMasterPasswordOnly(); + const { wallets } = useWallets(); + + // Update the MOTD when the sync node changes, and on startup + useEffect(() => { + if (connectionState !== "connected") return; + updateMOTD().catch(criticalError); + }, [syncNode, connectionState]); + + // When the currency's address prefix changes, or our master password appears, + // recalculate the addresses if necessary + useEffect(() => { + if (!addressPrefix || !masterPassword) return; + recalculateWallets(masterPassword, wallets, addressPrefix) + .catch(criticalError); + }, [addressPrefix, masterPassword, wallets]); + + return null; +} diff --git a/src/global/ws/WebsocketConnection.ts b/src/global/ws/WebsocketConnection.ts new file mode 100644 index 0000000..682106b --- /dev/null +++ b/src/global/ws/WebsocketConnection.ts @@ -0,0 +1,288 @@ +// Copyright (c) 2020-2021 Drew Lemmy +// This file is part of TenebraWeb 2 under AGPL-3.0. +// Full details: https://github.com/tmpim/TenebraWeb2/blob/master/LICENSE.txt +import { message } from "antd"; +import i18n from "@utils/i18n"; + +import { store } from "@app"; +import * as wsActions from "@actions/WebsocketActions"; +import * as nodeActions from "@actions/NodeActions"; + +import * as api from "@api"; +import { TenebraAddress, TenebraBlock, TenebraTransaction, WSConnectionState, WSIncomingMessage, WSSubscriptionLevel } from "@api/types"; +import { Wallet, WalletMap, findWalletByAddress, syncWalletUpdate } from "@wallets"; +import WebSocketAsPromised from "websocket-as-promised"; + +import { WSSubscription } from "./WebsocketSubscription"; + +import { throttle } from "lodash-es"; + +import Debug from "debug"; +const debug = Debug("tenebraweb:websocket-connection"); + +const REFRESH_THROTTLE_MS = 500; +const DEFAULT_CONNECT_DEBOUNCE_MS = 1000; +const MAX_CONNECT_DEBOUNCE_MS = 360000; + +export class WebsocketConnection { + private wallets?: WalletMap; + private ws?: WebSocketAsPromised; + private reconnectionTimer?: number; + + private messageID = 1; + private connectDebounce = DEFAULT_CONNECT_DEBOUNCE_MS; + + private forceClosing = false; + + private refreshThrottles: Record void> = {}; + + private subscriptions: Record = {}; + + constructor(public syncNode: string) { + debug("WS component init"); + this.attemptConnect(); + } + + setWallets(wallets: WalletMap): void { + this.wallets = wallets; + } + + private async connect() { + debug("attempting connection to server..."); + this.setConnectionState("disconnected"); + + // Get a websocket token + const { url } = await api.post<{ url: string }>("ws/start"); + if (!url.startsWith("wss://tenebra.lil.gay/")) + message.warning(i18n.t("purchaseTenebra.connection"), 20); + + this.setConnectionState("connecting"); + + // Connect to the websocket server + this.ws = new WebSocketAsPromised(url, { + packMessage: data => JSON.stringify(data), + unpackMessage: data => JSON.parse(data.toString()) + }); + + this.ws.onUnpackedMessage.addListener(this.handleMessage.bind(this)); + this.ws.onClose.addListener(this.handleClose.bind(this)); + + this.messageID = 1; + await this.ws.open(); + this.connectDebounce = DEFAULT_CONNECT_DEBOUNCE_MS; + } + + async attemptConnect(): Promise { + try { + await this.connect(); + } catch (err) { + this.handleDisconnect(err); + } + } + + private handleDisconnect(err?: Error) { + if (this.reconnectionTimer) window.clearTimeout(this.reconnectionTimer); + + this.setConnectionState("disconnected"); + debug("failed to connect to server, reconnecting in %d ms", this.connectDebounce, err); + + this.reconnectionTimer = window.setTimeout(() => { + this.connectDebounce = Math.min(this.connectDebounce * 2, MAX_CONNECT_DEBOUNCE_MS); + this.attemptConnect(); + }, this.connectDebounce); + } + + handleClose(event: { code: number; reason: string }): void { + debug("ws closed with code %d reason %s", event.code, event.reason); + this.handleDisconnect(); + } + + /** Forcibly disconnect this instance from the websocket server (e.g. on + * component unmount) */ + forceClose(): void { + debug("received force close request"); + + if (this.forceClosing) return; + this.forceClosing = true; + + if (!this.ws || !this.ws.isOpened || this.ws.isClosed) return; + debug("force closing ws"); + this.ws.close(); + } + + private setConnectionState(state: WSConnectionState) { + debug("ws conn state: %s", state); + store.dispatch(wsActions.setConnectionState(state)); + } + + handleMessage(data: WSIncomingMessage): void { + if (!this.ws || !this.ws.isOpened || this.ws.isClosed) return; + + if (data.ok === false || data.error) + debug("message ERR: %d %s", data.id || -1, data.error); + + if (data.type === "hello") { + // Initial connection + debug("connected"); + + // Subscribe to all the events + this.subscribe("transactions"); + this.subscribe("blocks"); + this.subscribe("names"); + this.subscribe("motd"); + + this.setConnectionState("connected"); + } else if (data.address && this.wallets) { + // Probably a response to `refreshBalance` + const address: TenebraAddress = data.address; + const wallet = findWalletByAddress(this.wallets, address.address); + if (!wallet) return; + + debug("syncing %s to %s (balance: %d)", address.address, wallet.id, address.balance); + syncWalletUpdate(wallet, address); + } else if (data.type === "event" && data.event && this.wallets) { + // Handle events + switch (data.event) { + case "transaction": { + // If we receive a transaction relevant to any of our wallets, refresh + // the balances. + const transaction = data.transaction as TenebraTransaction; + debug("transaction [%s] from %s to %s", transaction.type, transaction.from || "null", transaction.to || "null"); + + const fromWallet = findWalletByAddress(this.wallets, transaction.from || undefined); + const toWallet = findWalletByAddress(this.wallets, transaction.to); + + this.updateTransactionIDs(transaction, fromWallet, toWallet); + + switch (transaction.type) { + // Update the name counts using the address lookup + case "name_purchase": + case "name_transfer": + if (fromWallet) this.refreshBalance(fromWallet.address, true); + if (toWallet) this.refreshBalance(toWallet.address, true); + break; + + // Any other transaction; refresh the balances via the websocket + default: + if (fromWallet) this.refreshBalance(fromWallet.address); + if (toWallet) this.refreshBalance(toWallet.address); + break; + } + + break; + } + case "block": { + // Update the last block ID, which will trigger a re-fetch for + // work-related and block value-related components. + const block = data.block as TenebraBlock; + debug("block id now %d", block.height); + + store.dispatch(nodeActions.setLastBlockID(block.height)); + + break; + } + } + } + } + + private updateTransactionIDs(transaction: TenebraTransaction, fromWallet?: Wallet | null, toWallet?: Wallet | null) { + // Updating these last IDs will trigger auto-refreshes on pages that need + // them + const { + id, + from, to, + name: txName, sent_name: txSentName, + type + } = transaction; + + // Generic "last transaction ID", used for network transaction tables + debug("lastTransactionID now %d", id); + store.dispatch(nodeActions.setLastTransactionID(id)); + + // Transactions to/from our own wallets, for the "my transactions" table + if (fromWallet || toWallet) { + debug("lastOwnTransactionID now %d", id); + store.dispatch(nodeActions.setLastOwnTransactionID(id)); + } + + // Name transactions to/from our own names and network names + if (type.startsWith("name_")) { + debug("lastNameTransactionID now %d", id); + store.dispatch(nodeActions.setLastNameTransactionID(id)); + + if (fromWallet || toWallet) { + debug("lastOwnNameTransactionID now %d", id); + store.dispatch(nodeActions.setLastOwnNameTransactionID(id)); + } + } + + // Non-mined transactions for transaction tables that exclude mined + // transactions + if (type !== "mined") { + debug("lastNonMinedTransactionID now %d", id); + store.dispatch(nodeActions.setLastNonMinedTransactionID(id)); + } + + // Find addresses/names that we are subscribed to + for (const subID in this.subscriptions) { + const { address, name } = this.subscriptions[subID]; + + // Found a subscription interested in the addresses involved in this tx + if (from && address === from || to && address === to) { + debug("[address] updating subscription %s id to %d", subID, id); + store.dispatch(wsActions.updateSubscription(subID, id)); + } + + // Found a subscription interested in the name involved in this tx + if ((txName && txName === name) || (txSentName && txSentName === name)) { + debug("[name] updating subscription %s id to %d", subID, id); + store.dispatch(wsActions.updateSubscription(subID, id)); + } + } + } + + /** Queues a command to re-fetch an address's balance. The response will be + * handled in {@link handleMessage}. This is automatically throttled to + * execute on the leading edge of 500ms (REFRESH_THROTTLE_MS). */ + refreshBalance(address: string, fetchNames?: boolean): void { + if (this.refreshThrottles[address]) { + // Use the existing throttled function if it exists + this.refreshThrottles[address](address); + } else { + // Create and cache a new throttle function for this address + const throttled = throttle( + this._refreshBalance.bind(this), + REFRESH_THROTTLE_MS, + { leading: true, trailing: false } + ); + + this.refreshThrottles[address] = throttled; + throttled(address, fetchNames); + } + } + + private _refreshBalance(address: string, fetchNames?: boolean) { + debug("refreshing balance of %s", address); + this.ws?.sendPacked({ + type: "address", + id: this.messageID++, + address, + fetchNames + }); + } + + /** Subscribe to a Tenebra WS event. */ + subscribe(event: WSSubscriptionLevel): void { + this.ws?.sendPacked({ type: "subscribe", event, id: this.messageID++ }); + } + + addSubscription(id: string, subscription: WSSubscription): void { + debug("ws received added subscription %s", id); + this.subscriptions[id] = subscription; + } + + removeSubscription(id: string): void { + debug("ws received removed subscription %s", id); + if (this.subscriptions[id]) delete this.subscriptions[id]; + } +} diff --git a/src/global/ws/WebsocketProvider.tsx b/src/global/ws/WebsocketProvider.tsx new file mode 100644 index 0000000..db97a94 --- /dev/null +++ b/src/global/ws/WebsocketProvider.tsx @@ -0,0 +1,25 @@ +// Copyright (c) 2020-2021 Drew Lemmy +// This file is part of TenebraWeb 2 under AGPL-3.0. +// Full details: https://github.com/tmpim/TenebraWeb2/blob/master/LICENSE.txt +import { FC, createContext, useState, Dispatch, SetStateAction } from "react"; + +import { WebsocketConnection } from "./WebsocketConnection"; + +import Debug from "debug"; +const debug = Debug("tenebraweb:websocket-provider"); + +export interface WSContextType { + connection?: WebsocketConnection; + setConnection?: Dispatch>; +} +export const WebsocketContext = createContext({}); + +export const WebsocketProvider: FC = ({ children }): JSX.Element => { + const [connection, setConnection] = useState(); + + debug("ws provider re-rendering"); + + return + {children} + ; +}; diff --git a/src/global/ws/WebsocketService.tsx b/src/global/ws/WebsocketService.tsx new file mode 100644 index 0000000..21213f2 --- /dev/null +++ b/src/global/ws/WebsocketService.tsx @@ -0,0 +1,52 @@ +// Copyright (c) 2020-2021 Drew Lemmy +// This file is part of TenebraWeb 2 under AGPL-3.0. +// Full details: https://github.com/tmpim/TenebraWeb2/blob/master/LICENSE.txt +import { useEffect, useContext } from "react"; + +import { WebsocketContext } from "./WebsocketProvider"; +import { WebsocketConnection } from "./WebsocketConnection"; + +import * as api from "@api"; +import { useWallets } from "@wallets"; + +import Debug from "debug"; +const debug = Debug("tenebraweb:websocket-service"); + +export function WebsocketService(): JSX.Element | null { + const { wallets } = useWallets(); + const syncNode = api.useSyncNode(); + + const { connection, setConnection } = useContext(WebsocketContext); + + // On first render, or if the sync node changes, create the websocket + // connection + useEffect(() => { + // Don't reconnect if we already have a connection and the sync node hasn't + // changed (prevents infinite loops) + if (connection && connection.syncNode === syncNode) return; + + // Close any existing connections + if (connection) connection.forceClose(); + + if (!setConnection) { + debug("ws provider setConnection is missing!"); + return; + } + + // Connect to the Tenebra websocket server + setConnection(new WebsocketConnection(syncNode)); + + // On unmount, force close the existing connection + return () => { + if (connection) connection.forceClose(); + }; + }, [syncNode, connection, setConnection]); + + // If the wallets change, let the websocket service know so that it can keep + // track of events related to any new wallets + useEffect(() => { + if (connection) connection.setWallets(wallets); + }, [wallets, connection]); + + return null; +} diff --git a/src/global/ws/WebsocketSubscription.ts b/src/global/ws/WebsocketSubscription.ts new file mode 100644 index 0000000..7915fb4 --- /dev/null +++ b/src/global/ws/WebsocketSubscription.ts @@ -0,0 +1,106 @@ +// Copyright (c) 2020-2021 Drew Lemmy +// This file is part of TenebraWeb 2 under AGPL-3.0. +// Full details: https://github.com/tmpim/TenebraWeb2/blob/master/LICENSE.txt +import { useContext, useState, useEffect } from "react"; +import { v4 as uuid } from "uuid"; + +import { useSelector } from "react-redux"; +import { RootState } from "@store"; +import * as actions from "@actions/WebsocketActions"; + +import { WebsocketContext } from "./WebsocketProvider"; + +import Debug from "debug"; +const debug = Debug("tenebraweb:websocket-subscription"); + +export interface WSSubscription { + address?: string; + name?: string; + lastTransactionID: number; +} + +export function createSubscription(address?: string, name?: string): [string, WSSubscription] { + const id = uuid(); + + // It's okay to initialise at 0, since it will still render appropriately; + // it will be updated whenever a relevant transaction comes in + const subscription = { + address, name, lastTransactionID: 0 + }; + + // Dispatch the new subscription to the Redux store + actions.initSubscription(id, subscription); + + return [id, subscription]; +} + +export function removeSubscription(id: string): void { + // Dispatch the changes subscription to the Redux store + actions.removeSubscription(id); +} + +/** Creates a subscription to an address or name's last transaction ID. + * Will return 0 unless a transaction was detected after the subscription was + * created. */ +export function useSubscription({ address, name }: { address?: string; name?: string }): number { + const { connection } = useContext(WebsocketContext); + const [subscriptionID, setSubscriptionID] = useState(); + + // Don't select anything if there's no address or name anymore + const selector = address || name ? (subscriptionID || "") : ""; + const storeSubscription = useSelector((s: RootState) => s.websocket.subscriptions[selector]); + + // Create the subscription on mount if we don't have one + useEffect(() => { + if (!connection && subscriptionID) { + debug("connection lost, wiping subscription ID"); + removeSubscription(subscriptionID); + setSubscriptionID(undefined); + return; + } else if (!connection || subscriptionID) return; + + // This hook may still get called if there's nothing the caller wants to + // subscribe to, so stop here if that's the case + if (!address && !name) return; + + debug("ws subscription has no id yet, registering one"); + const [id, subscription] = createSubscription(address, name); + connection.addSubscription(id, subscription); + setSubscriptionID(id); + debug("new subscription id is %s", id); + }, [connection, subscriptionID, address, name]); + + // If the address or name change, wipe the subscription ID + useEffect(() => { + if (subscriptionID) { + debug("address or name changed, wiping subscription"); + if (connection) connection.removeSubscription(subscriptionID); + removeSubscription(subscriptionID); + setSubscriptionID(undefined); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [address, name]); + + // Unsubscribe when unmounted + useEffect(() => { + return () => { + if (!subscriptionID) return; + debug("ws subscription %s being removed due to unmount", subscriptionID); + + if (connection) connection.removeSubscription(subscriptionID); + removeSubscription(subscriptionID); + }; + }, [connection, subscriptionID]); + + if (!connection) { + debug("ws subscription returning 0 because no connection yet"); + return 0; + } + + const out = storeSubscription + ? storeSubscription.lastTransactionID + : 0; + + // debug("ws subscription %s is %d", subscriptionID, out); + return out; +} diff --git a/src/index.css b/src/index.css new file mode 100644 index 0000000..cc394d7 --- /dev/null +++ b/src/index.css @@ -0,0 +1,16 @@ +/* Copyright (c) 2020-2021 Drew Lemmy + * This file is part of TenebraWeb 2 under AGPL-3.0. + * Full details: https://github.com/tmpim/TenebraWeb2/blob/master/LICENSE.txt */ +body { + margin: 0; + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', + 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', + sans-serif; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} + +code { + font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New', + monospace; +} diff --git a/src/index.tsx b/src/index.tsx new file mode 100644 index 0000000..844f898 --- /dev/null +++ b/src/index.tsx @@ -0,0 +1,66 @@ +// Copyright (c) 2020-2021 Drew Lemmy +// This file is part of TenebraWeb 2 under AGPL-3.0. +// Full details: https://github.com/tmpim/TenebraWeb2/blob/master/LICENSE.txt +import "@utils/errors"; +import "@utils/setup"; +import { i18nLoader } from "@utils/i18n"; +import { isLocalhost, criticalError } from "@utils"; + +import ReactDOM from "react-dom"; + +import { compatCheck } from "@global/compat"; +import { notification } from "antd"; + +import "./index.css"; +import App from "@app"; + +import Debug from "debug"; +const debug = Debug("tenebraweb:index"); + +async function main() { + debug("=========================== APP STARTING ==========================="); + debug("performing compat checks"); + await compatCheck(); + + // if (isLocalhost && !localStorage.getItem("status")) { + // // Automatically enable debug logging on localhost + // localStorage.setItem("debug", "tenebraweb:*"); + // localStorage.setItem("status", "LOCAL"); + // location.reload(); + // } + + debug("waiting for i18n"); + await i18nLoader; + + initialRender(); +} + +function initialRender() { + debug("performing initial render"); + ReactDOM.render( + // FIXME: ant-design still has a few incompatibilities with StrictMode, most + // notably in rc-menu. Keep an eye on the issue to track progress and + // prepare for React 17: + // https://github.com/ant-design/ant-design/issues/26136 + // + , + // , + document.getElementById("root") + ); +} + +main().catch(err => { + // Remove the preloader + document.getElementById("kw-preloader")?.remove(); + + // Don't show the notification if a modal is already being shown + if (err?.message === "compat checks failed") return; + + debug("critical error in index.tsx"); + criticalError(err); + + notification.error({ + message: "Critical error", + description: "A critical startup error has occurred. Please report this on GitHub. See console for details." + }); +}); diff --git a/src/layout/AppLayout.less b/src/layout/AppLayout.less new file mode 100644 index 0000000..60485a3 --- /dev/null +++ b/src/layout/AppLayout.less @@ -0,0 +1,14 @@ +// Copyright (c) 2020-2021 Drew Lemmy +// This file is part of TenebraWeb 2 under AGPL-3.0. +// Full details: https://github.com/tmpim/TenebraWeb2/blob/master/LICENSE.txt +@import (reference) "../App.less"; + +.site-layout { + min-height: calc(100vh - @layout-header-height); + + margin-left: @kw-sidebar-width; + + &.site-layout-mobile { + margin-left: 0; + } +} diff --git a/src/layout/AppLayout.tsx b/src/layout/AppLayout.tsx new file mode 100644 index 0000000..bc0d735 --- /dev/null +++ b/src/layout/AppLayout.tsx @@ -0,0 +1,46 @@ +// Copyright (c) 2020-2021 Drew Lemmy +// This file is part of TenebraWeb 2 under AGPL-3.0. +// Full details: https://github.com/tmpim/TenebraWeb2/blob/master/LICENSE.txt +import { useState } from "react"; +import { Layout, Grid } from "antd"; + +import { AppHeader } from "./nav/AppHeader"; +import { Sidebar } from "./sidebar/Sidebar"; +import { AppRouter } from "../global/AppRouter"; + +import { TopMenuProvider } from "./nav/TopMenu"; + +import "./AppLayout.less"; + +const { useBreakpoint } = Grid; + +export function AppLayout(): JSX.Element { + const [sidebarCollapsed, setSidebarCollapsed] = useState(true); + const bps = useBreakpoint(); + + return + + + + + + + {/* Fade out the background when the sidebar is open on mobile */} + {!bps.md &&
setSidebarCollapsed(true)} + />} + + + + + + + ; +} diff --git a/src/layout/PageLayout.less b/src/layout/PageLayout.less new file mode 100644 index 0000000..b92464d --- /dev/null +++ b/src/layout/PageLayout.less @@ -0,0 +1,71 @@ +// Copyright (c) 2020-2021 Drew Lemmy +// This file is part of TenebraWeb 2 under AGPL-3.0. +// Full details: https://github.com/tmpim/TenebraWeb2/blob/master/LICENSE.txt +@import (reference) "../App.less"; + +.page-layout { + height: 100%; + + .page-layout-header.ant-page-header { + min-height: @kw-page-header-height; + + padding-bottom: 0; + + .ant-page-header-heading-sub-title { + .ant-typography { + color: inherit; + } + } + + // Hide extra table pagination on mobile + @media (max-width: @screen-md) { + .ant-pagination { + display: none; + } + } + } + + .page-layout-contents { + height: calc(100% - @kw-page-header-height); + + padding: @padding-lg; + + // Make tables full-width on mobile (though most should be replaced with + // custom views) + @media (max-width: @screen-md) { + padding: @padding-md; + + >.ant-table-wrapper { + .ant-table { + margin: 0 -@padding-lg; + } + + .ant-pagination { + justify-content: center; + + .ant-pagination-total-text { + display: none; + } + + .ant-pagination-item, .ant-pagination-prev { + margin-right: 3px; + } + + .ant-pagination-next { + margin-right: 0; + } + } + } + } + + @media (max-width: @screen-sm) { + padding: @padding-sm; + } + } + + &.page-layout-no-top-padding { + .page-layout-contents { + padding-top: 0; + } + } +} diff --git a/src/layout/PageLayout.tsx b/src/layout/PageLayout.tsx new file mode 100644 index 0000000..444b0b0 --- /dev/null +++ b/src/layout/PageLayout.tsx @@ -0,0 +1,78 @@ +// Copyright (c) 2020-2021 Drew Lemmy +// This file is part of TenebraWeb 2 under AGPL-3.0. +// Full details: https://github.com/tmpim/TenebraWeb2/blob/master/LICENSE.txt +import { FC, useEffect } from "react"; +import classNames from "classnames"; +import { PageHeader } from "antd"; + +import { useTranslation } from "react-i18next"; +import { useHistory } from "react-router-dom"; + +import "./PageLayout.less"; + +export type PageLayoutProps = React.HTMLProps & { + siteTitle?: string; + siteTitleKey?: string; + title?: React.ReactNode | string; + titleKey?: string; + subTitle?: React.ReactNode | string; + subTitleKey?: string; + + extra?: React.ReactNode; + noHeader?: boolean; + + className?: string; + withoutTopPadding?: boolean; + + onBack?: () => void; + backLink?: string; +} + +export const PageLayout: FC = ({ + siteTitle, siteTitleKey, + title, titleKey, + subTitle, subTitleKey, + + extra, noHeader, + + className, + withoutTopPadding, + + onBack, backLink, + + children, ...rest +}) => { + const { t } = useTranslation(); + const history = useHistory(); + + useEffect(() => { + if (siteTitle) document.title = `${siteTitle} - TenebraWeb`; + else if (siteTitleKey) document.title = `${t(siteTitleKey)} - TenebraWeb`; + }, [t, siteTitle, siteTitleKey]); + + const classes = classNames("page-layout", className, { + "page-layout-no-top-padding": withoutTopPadding + }); + + return
+ {/* Page header */} + {!noHeader && (title || titleKey) && { + if (onBack) onBack(); + else if (backLink) history.push(backLink); + else history.goBack(); + }} + />} + + {/* Page contents */} +
+ {children} +
+
; +}; diff --git a/src/layout/nav/AppHeader.less b/src/layout/nav/AppHeader.less new file mode 100644 index 0000000..4237eb2 --- /dev/null +++ b/src/layout/nav/AppHeader.less @@ -0,0 +1,223 @@ +// Copyright (c) 2020-2021 Drew Lemmy +// This file is part of TenebraWeb 2 under AGPL-3.0. +// Full details: https://github.com/tmpim/TenebraWeb2/blob/master/LICENSE.txt +@import (reference) "../../App.less"; + +.site-header + .ant-layout { + z-index: 1; + + margin-top: @layout-header-height; +} + +.site-header { + position: fixed; + top: 0; + left: 0; + right: 0; + + display: flex; + flex-direction: row; + + border-bottom: 1px solid @kw-border-color-darker; + padding: 0; + + z-index: 2; + box-shadow: 0 0.5rem 1rem rgba(0, 0, 0, 0.15); + + .site-header-brand { + width: @kw-sidebar-width; + + display: flex; + align-items: center; + justify-content: center; + + // Make it full height + align-self: stretch; + // Force the search bar to shrink instead + flex-shrink: 0; + + font-size: 1.25rem; + user-select: none; + + border-right: 1px solid @kw-border-color-darker; + + .conditional-link-disabled, a { + color: @text-color; + text-align: center; + + &:hover { + text-decoration: none; + } + } + + &-version { + margin-left: 0.25em; + + color: @text-color-secondary; + font-size: 60%; + } + + .ant-tag { + margin-left: 0.5em; + margin-right: 0; + } + } + + > .ant-menu { + background: transparent; + flex-shrink: 0; + + .conditional-link-disabled { + color: @text-color; + } + + .ant-menu-item { + height: @layout-header-height; + + // Fix background overlapping border + border-bottom: 1px solid @kw-border-color-darker; + } + } + + .site-header-sidebar-toggle { + border-right: 1px solid @kw-border-color-darker; + } + + .site-header-search-container { + padding: 0 1rem; + width: 100%; + + display: flex; + align-items: center; + justify-content: center; + + .site-header-search { + width: 100%; + max-width: 400px; + + margin-left: auto; + + background: @kw-header-search-bg; + color: @kw-header-search-color; + border-radius: @border-radius-base; + + transition: all @animation-duration-base ease; + + &::placeholder { + color: @kw-header-search-placeholder-color; + + transition: all @animation-duration-base ease; + + font-size: @kw-header-search-placeholder-font-size; + vertical-align: middle; + } + + &:hover { + background: @kw-header-search-hover-bg; + color: @kw-header-search-hover-color; + } + + &.ant-select-focused { + background: @kw-header-search-focus-bg; + color: @kw-header-search-focus-color; + + .ant-input { + // Remove the built in focus indicator + box-shadow: none; + } + } + + .ant-input-group, .ant-input { + background: transparent; + border: none; + } + + .ant-input-group-addon { + border: none; + background: transparent; + + .ant-input-search-button { + border: none; + background: transparent; + + .anticon { + vertical-align: middle; + } + } + } + + // Make the search box full width on mobile + @media (max-width: @screen-md) { + max-width: 100%; + } + } + } + + .site-header-element { + flex: 0; + height: 50px; + + display: flex; + align-items: center; + justify-content: center; + + margin-right: 16px; + + .site-header-cymbal { + color: @text-color-secondary; + font-size: 20px; + } + } + + .site-header-settings { + border-left: 1px solid @kw-border-color-darker; + + // Make the whole button clickable, especially for mobile + >.ant-menu-item { + padding: 0; + + .anticon { + line-height: @layout-header-height; + padding: 0 20px; + margin-right: 0; + } + } + } +} + +.ant-dropdown.site-header-top-dropdown-menu { + // Make the top menu dropdown full-width and slightly larger on mobile + @media (max-width: @screen-sm) { + position: fixed; + + top: @layout-header-height !important; + left: 0 !important; + right: 0 !important; + + border-radius: 0; + + .ant-dropdown-menu-item { + padding: 8px 16px; + font-size: @font-size-base; + + // If there's a direct div child, it's probably a descendent of an + // AuthorisedAction. Grow it to be full-width, so the click trigger is + // correct. + // FIXME: This is a hack. + >div { + margin: -8px -16px; + padding: 8px 16px; + } + + .anticon:first-child { + margin-right: 16px; + font-size: @font-size-base; + vertical-align: -0.175em; + } + + .conditional-link-disabled { + color: @text-color; + } + } + } +} diff --git a/src/layout/nav/AppHeader.tsx b/src/layout/nav/AppHeader.tsx new file mode 100644 index 0000000..2649f8d --- /dev/null +++ b/src/layout/nav/AppHeader.tsx @@ -0,0 +1,75 @@ +// Copyright (c) 2020-2021 Drew Lemmy +// This file is part of TenebraWeb 2 under AGPL-3.0. +// Full details: https://github.com/tmpim/TenebraWeb2/blob/master/LICENSE.txt +import { Layout, Menu, Grid } from "antd"; +import { SendOutlined, DownloadOutlined, MenuOutlined } from "@ant-design/icons"; + +import { useTranslation } from "react-i18next"; + +import { Brand } from "./Brand"; +import { Search } from "./Search"; +import { ConnectionIndicator } from "./ConnectionIndicator"; +import { CymbalIndicator } from "./CymbalIndicator"; +import { TopMenu } from "./TopMenu"; + +import { ConditionalLink } from "@comp/ConditionalLink"; + +import "./AppHeader.less"; + +const { useBreakpoint } = Grid; + +interface Props { + sidebarCollapsed: boolean; + setSidebarCollapsed: React.Dispatch>; +} + +export function AppHeader({ sidebarCollapsed, setSidebarCollapsed }: Props): JSX.Element { + const { t } = useTranslation(); + const bps = useBreakpoint(); + + return + {/* Sidebar toggle for mobile */} + {!bps.md && ( + + setSidebarCollapsed(!sidebarCollapsed)}> + + + + )} + + {/* Logo */} + {bps.md && } + + {/* Send and request buttons */} + {bps.md && + {/* Send Tenebra */} + }> + + {t("nav.send")} + + + + {/* Request Tenebra */} + }> + + {t("nav.request")} + + + } + + {/* Spacer to push search box to the right */} + {bps.md &&
} + + {/* Search box */} + + + {/* Connection indicator */} + + + {/* Cymbal indicator */} + + + {/* Settings button, or dropdown on mobile if there are other options */} + + ; +} diff --git a/src/layout/nav/Brand.tsx b/src/layout/nav/Brand.tsx new file mode 100644 index 0000000..74f5b4e --- /dev/null +++ b/src/layout/nav/Brand.tsx @@ -0,0 +1,60 @@ +// Copyright (c) 2020-2021 Drew Lemmy +// This file is part of TenebraWeb 2 under AGPL-3.0. +// Full details: https://github.com/tmpim/TenebraWeb2/blob/master/LICENSE.txt +import { Tag } from "antd"; + +import { useTranslation } from "react-i18next"; + +import semverMajor from "semver/functions/major"; +import semverMinor from "semver/functions/minor"; +import semverPatch from "semver/functions/patch"; +import semverPrerelease from "semver/functions/prerelease"; + +import { ConditionalLink } from "@comp/ConditionalLink"; + +import packageJson from "../../../package.json"; + +declare const __GIT_VERSION__: string; + +const prereleaseTagColours: { [key: string]: string } = { + "dev": "red", + "alpha": "orange", + "beta": "blue", + "rc": "green" +}; + +const devEnvs = ["development", "local", "test"]; +const dirtyRegex = /-dirty$/; + +export function Brand(): JSX.Element { + const { t } = useTranslation(); + + const version = packageJson.version; + + const major = semverMajor(version); + const minor = semverMinor(version); + const patch = semverPatch(version); + const prerelease = semverPrerelease(version); + + // Determine if the 'dev' tag should be shown + // Replaced by webpack DefinePlugin and git-revision-webpack-plugin + const gitVersion: string = __GIT_VERSION__; + const isDirty = dirtyRegex.test(gitVersion); + const isDev = devEnvs.includes(process.env.NODE_ENV || "development"); + + // Convert semver prerelease parts to Bootstrap badge + const tagContents = isDirty || isDev ? ["dev"] : prerelease; + let tag = null; + if (tagContents && tagContents.length) { + const variant = prereleaseTagColours[tagContents[0]] || undefined; + tag = {tagContents.join(".")}; + } + + return
+ + {t("app.name")} + v{major}.{minor}.{patch} + {tag} + +
; +} diff --git a/src/layout/nav/ConnectionIndicator.less b/src/layout/nav/ConnectionIndicator.less new file mode 100644 index 0000000..3754b26 --- /dev/null +++ b/src/layout/nav/ConnectionIndicator.less @@ -0,0 +1,38 @@ +// Copyright (c) 2020-2021 Drew Lemmy +// This file is part of TenebraWeb 2 under AGPL-3.0. +// Full details: https://github.com/tmpim/TenebraWeb2/blob/master/LICENSE.txt +@import (reference) "../../App.less"; + +.connection-indicator { + vertical-align: middle; + line-height: @layout-header-height; + + &::after { + content: " "; + display: inline-block; + + width: 12px; + height: 12px; + border-radius: 50%; + + background-color: @kw-green; + box-shadow: 0 0 0 3px rgba(@kw-green, 0.3); + } + + &.connection-connecting::after { + background-color: @kw-secondary; + box-shadow: 0 0 0 3px rgba(@kw-secondary, 0.3); + } + + &.connection-disconnected::after { + background-color: @kw-red; + box-shadow: 0 0 0 3px rgba(@kw-red, 0.3); + } +} + +.site-header-element.connection-indicator-el { + // Hide the connection indicator entirely on very small screens + @media (max-width: 350px) { + display: none !important; + } +} diff --git a/src/layout/nav/ConnectionIndicator.tsx b/src/layout/nav/ConnectionIndicator.tsx new file mode 100644 index 0000000..001fc34 --- /dev/null +++ b/src/layout/nav/ConnectionIndicator.tsx @@ -0,0 +1,29 @@ +// Copyright (c) 2020-2021 Drew Lemmy +// This file is part of TenebraWeb 2 under AGPL-3.0. +// Full details: https://github.com/tmpim/TenebraWeb2/blob/master/LICENSE.txt +import { Tooltip } from "antd"; + +import { useSelector } from "react-redux"; +import { RootState } from "@store"; +import { useTranslation } from "react-i18next"; + +import { WSConnectionState } from "@api/types"; + +import "./ConnectionIndicator.less"; + +const CONN_STATE_TOOLTIPS: Record = { + "connected": "nav.connection.online", + "disconnected": "nav.connection.offline", + "connecting": "nav.connection.connecting" +}; + +export function ConnectionIndicator(): JSX.Element { + const { t } = useTranslation(); + const connectionState = useSelector((s: RootState) => s.websocket.connectionState); + + return
+ +
+ +
; +} diff --git a/src/layout/nav/CymbalIndicator.tsx b/src/layout/nav/CymbalIndicator.tsx new file mode 100644 index 0000000..e3db38c --- /dev/null +++ b/src/layout/nav/CymbalIndicator.tsx @@ -0,0 +1,46 @@ +// Copyright (c) 2020-2021 Drew Lemmy +// This file is part of TenebraWeb 2 under AGPL-3.0. +// Full details: https://github.com/tmpim/TenebraWeb2/blob/master/LICENSE.txt +import { useMemo } from "react"; +import { Typography } from "antd"; +import Icon from "@ant-design/icons"; + +import packageJson from "../../../package.json"; + +import { useSelector, shallowEqual } from "react-redux"; +import { RootState } from "@store"; +import { SettingsState } from "@utils/settings"; + +import { useWallets, ADDRESS_LIST_LIMIT } from "@wallets"; + +const { Text } = Typography; + +export const CymbalIconSvg = (): JSX.Element => ( + + + +); +export const CymbalIcon = (props: any): JSX.Element => + ; + +export function CymbalIndicator(): JSX.Element | null { + const allSettings: SettingsState = useSelector((s: RootState) => s.settings, shallowEqual); + const { addressList } = useWallets(); + const serverChanged = useMemo(() => { + const l = localStorage.getItem("syncNode"); + return l && l !== packageJson.defaultSyncNode; + }, []); + + const on = allSettings.walletFormats + || addressList.length > ADDRESS_LIST_LIMIT + || serverChanged; + + return on ?
+ + {addressList.length > ADDRESS_LIST_LIMIT && ( + + {addressList.length.toLocaleString()} + + )} +
: null; +} diff --git a/src/layout/nav/Search.tsx b/src/layout/nav/Search.tsx new file mode 100644 index 0000000..77c8b84 --- /dev/null +++ b/src/layout/nav/Search.tsx @@ -0,0 +1,419 @@ +// Copyright (c) 2020-2021 Drew Lemmy +// This file is part of TenebraWeb 2 under AGPL-3.0. +// Full details: https://github.com/tmpim/TenebraWeb2/blob/master/LICENSE.txt +import { useState, useMemo, useRef, useEffect, useCallback, MutableRefObject, Dispatch, SetStateAction, ReactNode } from "react"; +import { AutoComplete, Input } from "antd"; +import { RefSelectProps } from "antd/lib/select"; + +import { useTranslation } from "react-i18next"; +import { useHistory } from "react-router-dom"; + +import { GlobalHotKeys } from "react-hotkeys"; +import { ctrl } from "@utils"; +import { useBreakpoint } from "@utils/hooks"; + +import { RateLimitError } from "@api"; +import { SearchResult, search, searchExtended, SearchExtendedResult } from "@api/search"; +import { throttle, debounce } from "lodash-es"; +import LRU from "lru-cache"; + +import * as SearchResults from "./SearchResults"; + +import * as Sentry from "@sentry/react"; +import Debug from "debug"; +const debug = Debug("tenebraweb:search"); + +const SEARCH_THROTTLE = 500; +const SEARCH_RATE_LIMIT_WAIT = 5000; + +async function performAutocomplete( + query: string, + fetchResults: boolean, + fetchExtended: boolean, + waitingForRef: MutableRefObject, + setResults: (query: string, results: SearchResult | undefined) => void, + setExtendedResults: (query: string, results: SearchExtendedResult | undefined) => void, + onRateLimitHit: () => void +) { + debug("performing search for %s", query); + + // Store the most recent search query so that the results don't arrive out of + // order. + waitingForRef.current = query; + + try { + await Promise.all([ + fetchResults ? search(query.toLowerCase()).then(r => setResults(query, r)) : undefined, + fetchExtended ? searchExtended(query).then(r => setExtendedResults(query, r)) : undefined, + ]); + } catch (err) { + // Most likely error is `rate_limit_hit`: + if (err instanceof RateLimitError) { + onRateLimitHit(); + } else { + Sentry.withScope(scope => { + scope.setTag("search-query", query); + Sentry.captureException(err); + console.error(err); + }); + } + } +} + +export function Search(): JSX.Element { + const { t } = useTranslation(); + const history = useHistory(); + + // Used to change the placeholder depending on the screen width + const bps = useBreakpoint(); + + const [value, setValue] = useState(""); + const [results, setResults] = useState(); + const [extendedResults, setExtendedResults] = useState(); + const [loading, setLoading] = useState(false); + const [rateLimitHit, setRateLimitHit] = useState(false); + const [options, setOptions] = useState<{ value: string; label: ReactNode }[]>([]); + + // The latest input that we're waiting for a network request for; this avoids + // out of order search results due to network latency + const waitingForRef = useRef(""); + + // Used to focus the search when the hotkey is received, or de-focus it when + // a search result is selected + const autocompleteRef = useRef(null); + + const debouncedAutocomplete = useMemo(() => debounce(performAutocomplete, SEARCH_THROTTLE), []); + const throttledAutocomplete = useMemo(() => throttle(performAutocomplete, SEARCH_THROTTLE), []); + + // LRU cache used to keep track of known search results. This avoids + // re-fetching search results when the user hits backspaces several times. + const searchCache = useMemo(() => new LRU({ max: 100, maxAge : 180000 }), []); + const searchExtendedCache = useMemo(() => new LRU({ max: 100, maxAge : 180000 }), []); + + // Create a function to set the results for a given result type + const cachedSetResultsBase = + (cache: LRU, setResultsFn: Dispatch>) => + (query: string, results: T | undefined) => { + debug("setting results for %s", query, results); + + // Cowardly refuse to perform any search if the rate limit was hit + if (!results || rateLimitHit) return setResultsFn(undefined); + + // If this result isn't for the most recent search query (i.e. it + // arrived out of order), ignore it + if (query !== waitingForRef.current) { + debug("ignoring out of order query %s (we need %s)", query, waitingForRef.current); + return; + } + + cache.set(query, results); + setResultsFn(results); + setLoading(false); + }; + + const cachedSetResults = cachedSetResultsBase(searchCache, setResults); + const cachedSetExtendedResults = cachedSetResultsBase(searchExtendedCache, setExtendedResults); + + function onRateLimitHit() { + // Ignore repeated rate limit errors + if (rateLimitHit) return; + + // Lyqydate the search input and wait 5 seconds before unlocking it + debug("rate limit hit, locking input for 5 seconds"); + setRateLimitHit(true); + + setTimeout(() => { + debug("unlocking input"); + setRateLimitHit(false); + }, SEARCH_RATE_LIMIT_WAIT); + } + + function onSearch(query: string) { + debug("onSearch: %s", query); + + // Cowardly refuse to perform any search if the rate limit was hit + if (rateLimitHit) return; + + const cleanQuery = query.trim(); + if (!cleanQuery) { + setResults(undefined); + setLoading(false); + return; + } + + // Use the search cache if possible, to avoid unnecessary network requests + const cached = searchCache.get(cleanQuery); + const cachedExtended = searchExtendedCache.get(cleanQuery); + if (cached || cachedExtended) { + debug("using cached result for %s (results: %b) (extended: %b)", query, !cached, !cachedExtended, cached, cachedExtended); + + // Ensure that an out of order request doesn't overwrite our cached result + waitingForRef.current = query; + + // Cancel any existing throttled request + throttledAutocomplete.cancel(); + debouncedAutocomplete.cancel(); + + if (cached) setResults(cached); + if (cachedExtended) setExtendedResults(cachedExtended); + + setLoading(false); + } + + // If we're missing one or both of the cached result sets, fetch them + if (!cached || !cachedExtended) { + debug("nothing cached for %s, (results: %b) (extended: %b), considering a fetch", query, !cached, !cachedExtended); + + setLoading(true); + + // Based on this article: + // https://www.peterbe.com/plog/how-to-throttle-and-debounce-an-autocomplete-input-in-react + // Eagerly use `throttle` for short inputs, and patiently use `debounce` + // for longer inputs. + const fn = cleanQuery.length < 5 + ? throttledAutocomplete + : debouncedAutocomplete; + + fn( + cleanQuery, + !cached, !cachedExtended, + waitingForRef, + cachedSetResults, cachedSetExtendedResults, + onRateLimitHit + ); + } + } + + /** Navigate to the selected search result. */ + function onSelect(query: string) { + debug("onSelect %s", query); + + // Reset the search value when a result is selected. This is because, + // otherwise, the internal value (e.g. `exactAddress`) would remain in + // there, which would look pretty odd. + // REVIEW: Would be nice to avoid having to do it this way entirely. + setValue(""); + + // If we're still loading the results, don't search just yet. + // TODO: is it possible to defer this instead? + if (loading || !results) return; + + const resultsMatches = results.matches; + const { exactAddress, exactName, exactBlock, exactTransaction } = resultsMatches; + + debug("search selected value %s", query); + + // Whether or not we actually matched a value. This should pretty much + // always be true. + let matched = true; + + // Using the internal result type, navigate to the relevant page. + // FIXME: this is kinda wack + if (query === "exactAddress" && exactAddress) { + history.push(`/network/addresses/${encodeURIComponent(exactAddress.address)}`); + } else if (query === "exactName" && exactName) { + history.push(`/network/names/${encodeURIComponent(exactName.name)}`); + } else if (query === "exactBlock" && exactBlock) { + history.push(`/network/blocks/${encodeURIComponent(exactBlock.height)}`); + } else if (query === "exactTransaction" && exactTransaction) { + history.push(`/network/transactions/${encodeURIComponent(exactTransaction.id)}`); + } else if (extendedResults) { + const { originalQuery } = extendedResults.query; + const q = "?q=" + encodeURIComponent(originalQuery); + debug("extended search query: %s (?q: %s)", originalQuery, q); + + if (query === "extendedTransactionsAddress") { + history.push("/network/search/transactions/address" + q); + } else if (query === "extendedTransactionsName") { + history.push("/network/search/transactions/name" + q); + } else if (query === "extendedTransactionsMetadata") { + history.push("/network/search/transactions/metadata" + q); + } else { + matched = false; + debug("warn: unknown search type %s", query); + } + } else { + matched = false; + debug("warn: unknown search type %s", query); + } + + // De-focus the search textbox when an item is selected. + if (matched && autocompleteRef.current) + autocompleteRef.current.blur(); + } + + // When the 'enter' key is pressed while an autocomplete option isn't focused, + // or the user clicks the 'search' button, the autocomplete has no way of + // knowing which option to search with. So, we look at the first option in the + // list and send that to onSelect. + function onInputSearch() { + // If we're still loading the results, don't search just yet. + // TODO: is it possible to defer this instead? + if (loading || !results) return; + + if (!options || !options.length) return; + onSelect(options[0].value); + } + + const staticOption = (value: string, label: ReactNode) => [{ value, label }]; + const renderOptions = useCallback(function(): { value: string; label: ReactNode }[] { + const cleanQuery = value.trim(); + // debug("current state: %b %b %b %b", rateLimitHit, !cleanQuery, loading, results); + + // Show a warning instead of the results if the rate limit was hit + if (rateLimitHit) return staticOption("rateLimitHit", ); + // Don't return anything if there's no query at all + if (!cleanQuery) return []; + + if (!results) { + // Loading spinner, only if we don't already have some results + if (loading) return staticOption("loading", ); + else return staticOption("noResults", ); + } + + const resultsMatches = results.matches; + + // The list of results to return for the AutoComplete component + const options = []; + + // The 'exact match' results; these are pretty immediate and return + // definitive data + const { exactAddress, exactName, exactBlock, exactTransaction } = resultsMatches; + + if (exactAddress) options.push({ + value: "exactAddress", + label: + }); + if (exactName) options.push({ + value: "exactName", + label: + }); + if (exactBlock) options.push({ + value: "exactBlock", + label: + }); + if (exactTransaction) options.push({ + value: "exactTransaction", + label: + }); + + // The 'extended' results; these are counts of transactions and may take a + // bit longer to load. They're only shown if the query is longer than 3 + // characters. + if (cleanQuery.length > 3) { + // Whether or not to show the loading spinner on the extended items. + // This is a pretty poor way to track if the extended results are still + // loading some new value. + const extendedLoading = loading && (!extendedResults || extendedResults.query.originalQuery !== cleanQuery); + const extendedMatches = extendedResults?.matches?.transactions; + + // Do our own checks to preemptively know what kind of transaction results + // will be shown. Note that metadata will always be searched. + const addressInvolved = extendedMatches?.addressInvolved; + const showAddress = (addressInvolved !== false && addressInvolved !== undefined) + && exactAddress; // We definitely know the address exists + + const nameInvolved = extendedMatches?.nameInvolved; + const showName = (nameInvolved !== false && nameInvolved !== undefined) + && exactName; // We definitely know the name exists + + if (showAddress) options.push({ + value: "extendedTransactionsAddress", + label: + }); + + if (showName) options.push({ + value: "extendedTransactionsName", + label: + }); + + // Metadata is always searched + options.push({ + value: "extendedTransactionsMetadata", + label: + }); + } + + return options; + }, [value, loading, rateLimitHit, results, extendedResults]); + + useEffect(() => { + setOptions(renderOptions()); + }, [renderOptions]); + + return
+ { + e?.preventDefault(); + autocompleteRef.current?.focus(); + } + }} + /> + + true} + + onChange={value => { + // debug("search onChange %s", value); + setLoading(true); + setValue(value); + }} + onSearch={onSearch} + onSelect={onSelect} + + // NOTE: This was removed and the LRU expiry time was lowered; a definite + // decision on whether or not the cache should be cleared every time + // the search is opened hasn't been reached, but at the moment it + // seems to be better to just keep the cached entries around, as + // speed and lack of network spam is better than accuracy of the + // result hints. Besides, pressing enter will always take you to the + // up-to-date data anyway. + /* onFocus={() => { + debug("clearing search cache"); + searchCache.reset(); + }} */ + + options={options} + > + + +
; +} diff --git a/src/layout/nav/SearchResults.less b/src/layout/nav/SearchResults.less new file mode 100644 index 0000000..c7c7fe6 --- /dev/null +++ b/src/layout/nav/SearchResults.less @@ -0,0 +1,115 @@ +// Copyright (c) 2020-2021 Drew Lemmy +// This file is part of TenebraWeb 2 under AGPL-3.0. +// Full details: https://github.com/tmpim/TenebraWeb2/blob/master/LICENSE.txt +@import (reference) "../../App.less"; + +.search-result-loading { + display: flex; + justify-content: center; + padding-top: 6px; +} + +.search-result { + &.search-result-exact { + display: flex; + } + + &.search-result-extended { + background: @kw-dark; + transition: background 0.3s ease; + } + + .search-result-type { + display: block; + + color: @text-color-secondary; + font-size: @font-size-sm; + font-weight: bold; + text-transform: uppercase; + } + + .search-result-value { + font-weight: bold; + } + + .result-left { + flex: 1; + } + + .result-right { + display: flex; + flex-direction: column; + justify-content: center; + align-items: flex-end; + + flex: 0; + + text-align: right; + + .tenebra-value { + font-size: @font-size-lg; + } + + .search-name-owner, .search-block-miner { + display: block; + color: @text-color-secondary; + } + + .date-time { + color: @text-color-secondary; + font-size: 90%; + } + } + + .search-result-extended-info { + font-size: @font-size-sm; + + white-space: normal; + + .anticon { + color: @primary-color; + margin-right: @padding-xs; + } + } + + .conditional-link-disabled { + color: inherit; + } + + // Hide some result information on very small screens + @media (max-width: 300px) { + .result-right { + display: none; + } + } +} + +.site-header-search-menu { + // Remove the padding from the menu, so it can be re-added to the + // .search-result-* items, allowing them to change the background + padding: 0; + + .ant-select-item { + padding: 0; + + .search-result { + padding: @select-dropdown-vertical-padding @control-padding-horizontal; + } + + &:hover .search-result-extended, &-option-active:not(&-disabled) .search-result-extended { + background: @select-item-active-bg; + } + } + + // Make the search results dropdown full-width on mobile + @media (max-width: @screen-sm) { + position: fixed; + + top: @layout-header-height !important; + left: 0 !important; + right: 0 !important; + width: 100vw !important; + + border-radius: 0; + } +} diff --git a/src/layout/nav/SearchResults.tsx b/src/layout/nav/SearchResults.tsx new file mode 100644 index 0000000..f6d8ac1 --- /dev/null +++ b/src/layout/nav/SearchResults.tsx @@ -0,0 +1,198 @@ +// Copyright (c) 2020-2021 Drew Lemmy +// This file is part of TenebraWeb 2 under AGPL-3.0. +// Full details: https://github.com/tmpim/TenebraWeb2/blob/master/LICENSE.txt +import { ReactNode } from "react"; +import { Typography, Spin } from "antd"; +import { LoadingOutlined } from "@ant-design/icons"; + +import { Trans, useTranslation } from "react-i18next"; + +import { TenebraAddress, TenebraName, TenebraBlock, TenebraTransaction } from "@api/types"; +import { TenebraValue } from "@comp/tenebra/TenebraValue"; +import { TenebraNameLink } from "@comp/names/TenebraNameLink"; +import { DateTime } from "@comp/DateTime"; + +import "./SearchResults.less"; + +const { Text } = Typography; + +export function Loading(): JSX.Element { + return
; +} + +export function NoResults(): JSX.Element { + const { t } = useTranslation(); + return {t("nav.search.noResults")}; +} + +export function RateLimitHit(): JSX.Element { + const { t } = useTranslation(); + return {t("nav.search.rateLimitHit")}; +} + +interface ExactMatchBaseProps { + typeKey: string; + primaryValue: ReactNode | number; + extraInfo?: ReactNode; +} +export function ExactMatchBase({ typeKey, primaryValue, extraInfo }: ExactMatchBaseProps): JSX.Element { + const { t } = useTranslation(); + + return
+
+ {/* Result type (e.g. 'Address', 'Transaction') */} + + {t(typeKey)} + + + {/* Primary result value (e.g. the address, the ID) */} + + {typeof primaryValue === "number" + ? primaryValue.toLocaleString() + : primaryValue} + +
+ + {extraInfo &&
+ {extraInfo} +
} +
; +} + +export function ExactAddressMatch({ address }: { address: TenebraAddress }): JSX.Element { + return } + />; +} + +export function ExactNameMatch({ name }: { name: TenebraName }): JSX.Element { + const { t } = useTranslation(); + + function Owner() { + return {name.owner}; + } + + return } + extraInfo={ + + Owned by + + } + />; +} + +export function ExactBlockMatch({ block }: { block: TenebraBlock }): JSX.Element { + const { t } = useTranslation(); + + function Miner() { + return {block.address}; + } + + return + + + Mined by + + + + + } + />; +} + +export function ExactTransactionMatch({ transaction }: { transaction: TenebraTransaction }): JSX.Element { + return + + + } + />; +} + +interface ExtendedMatchProps { + loading?: boolean; + count?: number; + query?: ReactNode; + + loadingKey: string; + resultKey: string; +} +type ExtendedMatchBaseProps = Omit & { query: string }; +export function ExtendedMatchBase({ loading, count, query, loadingKey, resultKey }: ExtendedMatchProps): JSX.Element { + const { t } = useTranslation(); + + function Query(): JSX.Element { + return <>{query}; + } + + return
+ {/* Result type (e.g. 'Address', 'Transaction') */} + + {t("nav.search.resultTransactions")} + + + + {loading || typeof count !== "number" + ? <> + + + Placeholder + + + : (count > 0 + ? ( + + {{ count }} placeholder + + ) + : ( + + No placeholder + + ))} + + +
; +} + +export function ExtendedAddressMatch(props: ExtendedMatchBaseProps): JSX.Element { + return {props.query}} + + loadingKey="nav.search.resultTransactionsAddress" + resultKey="nav.search.resultTransactionsAddressResult" + />; +} + +export function ExtendedNameMatch(props: ExtendedMatchBaseProps): JSX.Element { + return } + + loadingKey="nav.search.resultTransactionsName" + resultKey="nav.search.resultTransactionsNameResult" + />; +} + +export function ExtendedMetadataMatch(props: ExtendedMatchBaseProps): JSX.Element { + return '{props.query}'} + + loadingKey="nav.search.resultTransactionsMetadata" + resultKey="nav.search.resultTransactionsMetadataResult" + />; +} diff --git a/src/layout/nav/TopMenu.tsx b/src/layout/nav/TopMenu.tsx new file mode 100644 index 0000000..a73b193 --- /dev/null +++ b/src/layout/nav/TopMenu.tsx @@ -0,0 +1,157 @@ +// Copyright (c) 2020-2021 Drew Lemmy +// This file is part of TenebraWeb 2 under AGPL-3.0. +// Full details: https://github.com/tmpim/TenebraWeb2/blob/master/LICENSE.txt +import { useState, useCallback, useMemo, useContext, createContext, FC, ReactNode } from "react"; +import { Menu, Dropdown } from "antd"; +import { + MoreOutlined, SettingOutlined, SendOutlined, DownloadOutlined, + SortAscendingOutlined +} from "@ant-design/icons"; + +import { useTFns } from "@utils/i18n"; + +import { ConditionalLink } from "@comp/ConditionalLink"; +import { useBreakpoint } from "@utils/hooks"; + +import { OpenSortModalFn, SetOpenSortModalFn } from "@utils/table/SortModal"; + +import Debug from "debug"; +const debug = Debug("tenebraweb:top-menu"); + +export type Opts = React.ReactNode | undefined; +export type SetMenuOptsFn = (opts: Opts) => void; + +interface TopMenuCtxRes { + options?: ReactNode; + setMenuOptions?: SetMenuOptsFn; + + openSortModalFn?: OpenSortModalFn; + setOpenSortModal?: SetOpenSortModalFn; +} + +export const TopMenuContext = createContext({}); + +export function TopMenu(): JSX.Element { + const { tStr } = useTFns("nav."); + const bps = useBreakpoint(); + + const ctxRes = useContext(TopMenuContext); + const options = ctxRes?.options; + const openSortModalFn = ctxRes?.openSortModalFn; + + const menu = useMemo(() => ( + + {/* Send Tenebra */} + + +
{tStr("sendLong")}
+
+
+ + {/* Request Tenebra */} + + +
{tStr("requestLong")}
+
+
+ + {/* Only show the extra divider if there are options */} + {options && } + + {/* Page-specified options */} + {options} + + {/* If the page is a bulk listing on mobile, show a button to adjust + * the sort order. */} + {openSortModalFn?.[0] && <> + + + + {tStr("sort")} + + } + + + + {/* Settings item */} + + +
{tStr("settings")}
+
+
+ } + > + +
+ ), [tStr, options, openSortModalFn]); + + // If on mobile and there are options available from the page, display them + // instead of the settings button. + const btn = useMemo(() => ( + // Menu or settings button in the header + + {!bps.md + ? ( + // Menu button for mobile + + {menu} + + ) + : ( + // Regular settings button + } title={tStr("settings")}> + + + )} + + ), [tStr, bps, menu]); + + return btn; +} + +export const TopMenuProvider: FC = ({ children }) => { + const [menuOptions, setMenuOptions] = useState(); + const [openSortModalFn, setOpenSortModal] = useState(); + + const res: TopMenuCtxRes = useMemo(() => ({ + options: menuOptions, setMenuOptions, + openSortModalFn, setOpenSortModal + }), [menuOptions, openSortModalFn]); + + return + {children} + ; +}; + +export type TopMenuOptionsHookRes = [ + boolean, // isMobile + SetMenuOptsFn, // set + () => void, // unset + SetOpenSortModalFn | undefined +] + +export function useTopMenuOptions(): TopMenuOptionsHookRes { + const bps = useBreakpoint(); + const { setMenuOptions, setOpenSortModal } = useContext(TopMenuContext); + + const set = useCallback((opts: Opts) => { + debug("top menu options hook set"); + setMenuOptions?.(opts); + }, [setMenuOptions]); + + const unset = useCallback(() => { + debug("top menu options hook destructor"); + setMenuOptions?.(undefined); + setOpenSortModal?.(undefined); + }, [setMenuOptions, setOpenSortModal]); + + // Return whether or not the options are being shown + return [!bps.md, set, unset, setOpenSortModal]; +} diff --git a/src/layout/sidebar/ServiceWorkerCheck.tsx b/src/layout/sidebar/ServiceWorkerCheck.tsx new file mode 100644 index 0000000..f4be8c8 --- /dev/null +++ b/src/layout/sidebar/ServiceWorkerCheck.tsx @@ -0,0 +1,61 @@ +// Copyright (c) 2020-2021 Drew Lemmy +// This file is part of TenebraWeb 2 under AGPL-3.0. +// Full details: https://github.com/tmpim/TenebraWeb2/blob/master/LICENSE.txt +import { useState, useEffect } from "react"; +import { Button } from "antd"; + +import { useTranslation } from "react-i18next"; + +import * as serviceWorker from "@utils/serviceWorkerRegistration"; + +import Debug from "debug"; +const debug = Debug("tenebraweb:service-worker-check"); + +export function ServiceWorkerCheck(): JSX.Element | null { + const { t } = useTranslation(); + + const [showReload, setShowReload] = useState(false); + const [waitingWorker, setWaitingWorker] = useState(null); + const [loading, setLoading] = useState(false); + + function onUpdate(registration: ServiceWorkerRegistration) { + setShowReload(true); + setWaitingWorker(registration.waiting); + } + + /** Force the service worker to update, wait for it to become active, then + * reload the page. */ + function reloadPage() { + setLoading(true); + debug("emitting skipWaiting now"); + + waitingWorker?.postMessage({ type: "SKIP_WAITING" }); + + waitingWorker?.addEventListener("statechange", () => { + debug("SW state changed to %s", waitingWorker?.state); + + if (waitingWorker?.state === "activated") { + debug("reloading now!"); + window.location.reload(); + } + }); + } + + // NOTE: The update checker is also responsible for registering the service + // worker in the first place. + useEffect(() => { + debug("registering service worker"); + serviceWorker.register({ onUpdate }); + }, []); + + return showReload ? ( +
+
{t("sidebar.updateTitle")}
+

{t("sidebar.updateDescription")}

+ + +
+ ) : null; +} diff --git a/src/layout/sidebar/Sidebar.less b/src/layout/sidebar/Sidebar.less new file mode 100644 index 0000000..033e9c4 --- /dev/null +++ b/src/layout/sidebar/Sidebar.less @@ -0,0 +1,172 @@ +// Copyright (c) 2020-2021 Drew Lemmy +// This file is part of TenebraWeb 2 under AGPL-3.0. +// Full details: https://github.com/tmpim/TenebraWeb2/blob/master/LICENSE.txt +@import (reference) "../../App.less"; + +.site-sidebar { + background: @kw-sidebar-bg; + border-right: 1px solid @kw-border-color-darker; + + width: @kw-sidebar-width; + + position: fixed; + top: @layout-header-height; + bottom: 0; + + // Above the mobile collapse backdrop, below the modals + z-index: 910; + + transition: left @kw-sidebar-collapse-duration ease; + left: 0; + + &.collapsed { + left: -@kw-sidebar-width; + } + + .site-sidebar-header { + padding: 0.5rem 1rem; + + background: @kw-sidebar-header-bg; + border-bottom: 1px solid @kw-border-color-darker; + + line-height: 1.25; + + user-select: none; + + h5 { + margin-bottom: 0; + + font-size: @font-size-sm; + font-weight: bolder; + + color: @text-color-secondary; + } + + &.site-sidebar-update { + padding: 1rem; + + background: @primary-color; + color: @kw-darkest; + + h5 { + color: @kw-darkest; + margin-bottom: @padding-xs; + } + } + + &.site-sidebar-total-balance { + .anticon svg { + width: 14px; + height: 14px; + + position: relative; + bottom: 0.15em; + + color: @text-color-secondary; + } + + .tenebra-value { + font-size: 20px; + } + } + } + + .ant-layout-sider-children { + display: flex; + flex-direction: column; + + & > .ant-menu { + overflow-y: auto; + height: 100%; + } + } + + .ant-menu-item-group .ant-menu-item-group-title { + font-size: 0.8em; + font-weight: bold; + text-transform: uppercase; + + color: @kw-text-secondary; + + // margin: 1rem 0 0 0; + margin: 0; + padding: 1rem 1rem 0.5rem 1rem; + + border-top: 1px solid @kw-border-color-division; + + user-select: none; + } + + .ant-menu-item { + margin-top: 0; + margin-bottom: 0 !important; + padding: 0; + + line-height: @kw-sidebar-item-height; + height: @kw-sidebar-item-height; + vertical-align: middle; + + user-select: none; + + .anticon { + line-height: @kw-sidebar-item-height; + vertical-align: middle; + + font-size: 18px; + } + + .conditional-link-disabled { + color: @text-color; + } + } + + .site-sidebar-footer { + user-select: none; + + padding: 0.5rem; + + text-align: center; + font-size: 75%; + + color: @text-color-secondary; + + .conditional-link-disabled, a { + color: @text-color; + } + + .site-sidebar-footer-version { + margin: @padding-xs -0.5rem 0 -0.5rem; + padding-top: @padding-xs; + + border-top: 1px solid @kw-border-color-division; + + font-size: 90%; + font-weight: 500; + line-height: 1; + } + } +} + +.site-sidebar-backdrop { + position: fixed; + top: 0; + right: 0; + bottom: 0; + left: 0; + + transition: opacity @kw-sidebar-collapse-duration ease; + + background: @kw-sidebar-backdrop-bg; + opacity: 1; + + pointer-events: all; + cursor: pointer; + + // Below the sidebar and modals + z-index: 900; + + &.collapsed { + pointer-events: none; + opacity: 0; + } +} diff --git a/src/layout/sidebar/Sidebar.tsx b/src/layout/sidebar/Sidebar.tsx new file mode 100644 index 0000000..875153c --- /dev/null +++ b/src/layout/sidebar/Sidebar.tsx @@ -0,0 +1,108 @@ +// Copyright (c) 2020-2021 Drew Lemmy +// This file is part of TenebraWeb 2 under AGPL-3.0. +// Full details: https://github.com/tmpim/TenebraWeb2/blob/master/LICENSE.txt +import { useState, useEffect, useMemo, Dispatch, SetStateAction } from "react"; +import { Layout, Menu, MenuItemProps } from "antd"; +import { HomeOutlined, WalletOutlined, TeamOutlined, BankOutlined, TagsOutlined, BuildOutlined } from "@ant-design/icons"; + +import { TFunction, useTranslation } from "react-i18next"; +import { useLocation } from "react-router-dom"; + +import { ServiceWorkerCheck } from "./ServiceWorkerCheck"; +import { SidebarTotalBalance } from "./SidebarTotalBalance"; +import { SidebarFooter } from "./SidebarFooter"; + +import { ConditionalLink } from "@comp/ConditionalLink"; + +import "./Sidebar.less"; + +const { Sider } = Layout; + +type SidebarItemProps = MenuItemProps & { + to: string; + icon: React.ReactNode; + name: string; + + nyi?: boolean; + + group?: "network"; +} +const sidebarItems: SidebarItemProps[] = [ + { icon: , name: "dashboard", to: "/" }, + { icon: , name: "myWallets", to: "/wallets" }, + { icon: , name: "addressBook", to: "/contacts" }, + { icon: , name: "transactions", to: "/me/transactions" }, + { icon: , name: "names", to: "/me/names" }, + // { icon: , name: "mining", to: "/mining", nyi: true }, + + { group: "network", icon: , name: "blocks", to: "/network/blocks" }, + { group: "network", icon: , name: "transactions", to: "/network/transactions" }, + { group: "network", icon: , name: "names", to: "/network/names" }, + // { group: "network", icon: , name: "statistics", to: "/network/statistics", nyi: true }, +]; + +function getSidebarItems(t: TFunction, group?: string) { + return sidebarItems + .filter(i => i.group === group) + .map(i => ( + + + {t("sidebar." + i.name)} + + + )); +} + +interface Props { + collapsed: boolean; + setCollapsed: Dispatch>; +} + +export function Sidebar({ + collapsed, + setCollapsed +}: Props): JSX.Element { + const { t } = useTranslation(); + + const location = useLocation(); + const [selectedKey, setSelectedKey] = useState(); + + useEffect(() => { + setSelectedKey(sidebarItems.find(i => i.to === "/" + ? location.pathname === "/" + : location.pathname.startsWith(i.to))?.to); + }, [location.pathname]); + + useEffect(() => { + // Close the sidebar if we switch page + setCollapsed(true); + }, [setCollapsed, location.pathname]); + + const memoSidebar = useMemo(() => ( + + {/* Service worker update checker, which may appear at the top of the + * sidebar if an update is available. */} + + + {/* Total balance */} + + + {/* Menu items */} + + {getSidebarItems(t)} + + + {getSidebarItems(t, "network")} + + + + {/* Credits footer */} + + + ), [t, collapsed, selectedKey]); + + return memoSidebar; +} diff --git a/src/layout/sidebar/SidebarFooter.tsx b/src/layout/sidebar/SidebarFooter.tsx new file mode 100644 index 0000000..97300e2 --- /dev/null +++ b/src/layout/sidebar/SidebarFooter.tsx @@ -0,0 +1,51 @@ +// Copyright (c) 2020-2021 Drew Lemmy +// This file is part of TenebraWeb 2 under AGPL-3.0. +// Full details: https://github.com/tmpim/TenebraWeb2/blob/master/LICENSE.txt +import { useTranslation, Trans } from "react-i18next"; + +import { getAuthorInfo, useHostInfo } from "@utils"; + +import { ConditionalLink } from "@comp/ConditionalLink"; + +declare const __GIT_VERSION__: string; +declare const __PKGBUILD__: string; + +export function SidebarFooter(): JSX.Element { + const { t } = useTranslation(); + + const { authorName, authorURL, gitURL } = getAuthorInfo(); + const host = useHostInfo(); + + // Replaced by webpack DefinePlugin and git-revision-webpack-plugin + const gitVersion: string = __GIT_VERSION__; + const pkgbuild = __PKGBUILD__; + + return ( +
+
+ Made by {{authorName}} +
+ { host && + + } +
+ {t("sidebar.github")} +  –  + + {t("sidebar.whatsNew")} + +  –  + + {t("sidebar.credits")} + +
+ + {/* Git describe version */} +
+ {gitVersion}-{pkgbuild} +
+
+ ); +} diff --git a/src/layout/sidebar/SidebarTotalBalance.tsx b/src/layout/sidebar/SidebarTotalBalance.tsx new file mode 100644 index 0000000..64eb327 --- /dev/null +++ b/src/layout/sidebar/SidebarTotalBalance.tsx @@ -0,0 +1,23 @@ +// Copyright (c) 2020-2021 Drew Lemmy +// This file is part of TenebraWeb 2 under AGPL-3.0. +// Full details: https://github.com/tmpim/TenebraWeb2/blob/master/LICENSE.txt +import { useTranslation } from "react-i18next"; + +import { useWallets } from "@wallets"; +import { TenebraValue } from "@comp/tenebra/TenebraValue"; + +export function SidebarTotalBalance(): JSX.Element { + const { t } = useTranslation(); + + const { wallets } = useWallets(); + const balance = Object.values(wallets) + .filter(w => w.balance !== undefined) + .reduce((acc, w) => acc + w.balance!, 0); + + return ( +
+
{t("sidebar.totalBalance")}
+ +
+ ); +} diff --git a/src/pages/NotFoundPage.tsx b/src/pages/NotFoundPage.tsx new file mode 100644 index 0000000..d58a362 --- /dev/null +++ b/src/pages/NotFoundPage.tsx @@ -0,0 +1,32 @@ +// Copyright (c) 2020-2021 Drew Lemmy +// This file is part of TenebraWeb 2 under AGPL-3.0. +// Full details: https://github.com/tmpim/TenebraWeb2/blob/master/LICENSE.txt +import { Button } from "antd"; +import { FrownOutlined } from "@ant-design/icons"; + +import { useHistory } from "react-router-dom"; +import { useTFns } from "@utils/i18n"; + +import { SmallResult } from "@comp/results/SmallResult"; + +interface Props { + nyi?: boolean; +} + +export function NotFoundPage({ nyi }: Props): JSX.Element { + const { tStr } = useTFns("pageNotFound."); + const history = useHistory(); + + return } + status="error" + title={nyi ? tStr("nyiTitle") : tStr("resultTitle")} + subTitle={nyi ? tStr("nyiSubTitle") : undefined} + extra={( + + )} + fullPage + />; +} diff --git a/src/pages/addresses/AddressButtonRow.tsx b/src/pages/addresses/AddressButtonRow.tsx new file mode 100644 index 0000000..03212d3 --- /dev/null +++ b/src/pages/addresses/AddressButtonRow.tsx @@ -0,0 +1,161 @@ +// Copyright (c) 2020-2021 Drew Lemmy +// This file is part of TenebraWeb 2 under AGPL-3.0. +// Full details: https://github.com/tmpim/TenebraWeb2/blob/master/LICENSE.txt +import { useEffect } from "react"; +import { Button, Tooltip, Menu } from "antd"; +import { SendOutlined, SwapOutlined, UserAddOutlined, EditOutlined } from "@ant-design/icons"; + +import { useTFns } from "@utils/i18n"; + +import { isV1Address } from "@utils/tenebra"; + +import { Wallet } from "@wallets"; +import { Contact } from "@contacts"; + +import { useTopMenuOptions } from "@layout/nav/TopMenu"; +import { useAuth } from "@comp/auth"; +import { OpenEditWalletFn } from "@pages/wallets/WalletEditButton"; +import { OpenEditContactFn } from "@pages/contacts/ContactEditButton"; +import { OpenSendTxFn } from "@comp/transactions/SendTransactionModalLink"; + +interface Props { + address: string; + myWallet?: Wallet; + myContact?: Contact; + + openEditWallet: OpenEditWalletFn; + openEditContact: OpenEditContactFn; + openSendTx: OpenSendTxFn; +} + +export function AddressButtonRow({ + address, + myWallet, + myContact, + + openEditWallet, + openEditContact, + openSendTx +}: Props): JSX.Element { + const { t, tStr, tKey } = useTFns("address."); + + const isV1 = isV1Address(address); + + const promptAuth = useAuth(); + + const [usingTopMenu, set, unset] = useTopMenuOptions(); + useEffect(() => { + set(<> + {/* Send/transfer Tenebra */} + : } + disabled={isV1} + onClick={() => promptAuth(false, () => + openSendTx(undefined, address))} + > + {t( + tKey(myWallet ? "buttonTransferTenebra" : "buttonSendTenebra"), + { address } + )} + + + {/* Add contact/edit wallet */} + {myWallet + ? ( + } + onClick={() => promptAuth(true, () => openEditWallet(myWallet))} + > + {tStr("buttonEditWallet")} + + ) + : ( + : } + onClick={() => openEditContact(address, myContact)} + > + {tStr(myContact ? "buttonEditContact" : "buttonAddContact")} + + ) + } + ); + return unset; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [ + t, tStr, tKey, set, unset, address, openSendTx, openEditWallet, + openEditContact, isV1, promptAuth, myContact?.id, myWallet?.id + ]); + + return <> + {/* Only display the buttons on desktop */} + {!usingTopMenu && } + ; +} + +function Buttons({ + address, + myWallet, + myContact, + isV1, + + openEditWallet, + openEditContact, + openSendTx +}: Props & { isV1: boolean }): JSX.Element { + const { t, tStr, tKey } = useTFns("address."); + + const promptAuth = useAuth(); + + const sendButton = ; + + return <> + {/* Send/transfer Tenebra button */} + {isV1 + ? ( // Disable the button and show a tooltip for V1 addresses + + {sendButton} + + ) + : sendButton // Otherwise, enable the button + } + + {/* Add contact/edit wallet button */} + {myWallet + ? ( + + ) + : ( + + )} + ; +} diff --git a/src/pages/addresses/AddressNamesCard.tsx b/src/pages/addresses/AddressNamesCard.tsx new file mode 100644 index 0000000..4d0fe42 --- /dev/null +++ b/src/pages/addresses/AddressNamesCard.tsx @@ -0,0 +1,76 @@ +// Copyright (c) 2020-2021 Drew Lemmy +// This file is part of TenebraWeb 2 under AGPL-3.0. +// Full details: https://github.com/tmpim/TenebraWeb2/blob/master/LICENSE.txt +import { useState, useEffect } from "react"; +import classNames from "classnames"; +import { Card, Skeleton, Empty, Row } from "antd"; + +import { useTranslation } from "react-i18next"; +import { Link } from "react-router-dom"; + +import { NameItem } from "./NameItem"; +import { lookupNames, LookupNamesResponse } from "@api/lookup"; + +import { useSyncNode } from "@api"; + +import { SmallResult } from "@comp/results/SmallResult"; + +import Debug from "debug"; +const debug = Debug("tenebraweb:address-names-card"); + +async function fetchNames(address: string): Promise { + debug("fetching names"); + return lookupNames( + [address], + { limit: 5, orderBy: "registered", order: "DESC" } + ); +} + +export function AddressNamesCard({ address }: { address: string }): JSX.Element { + const { t } = useTranslation(); + const syncNode = useSyncNode(); + + const [res, setRes] = useState(); + const [error, setError] = useState(); + const [loading, setLoading] = useState(true); + + // Fetch names on page load or sync node reload + useEffect(() => { + if (!syncNode) return; + + // Remove the existing results in case the address changed + setRes(undefined); + setLoading(true); + + fetchNames(address) + .then(setRes) + .catch(setError) + .finally(() => setLoading(false)); + }, [syncNode, address]); + + const isEmpty = !loading && (error || !res || res.count === 0); + const classes = classNames("kw-card", "address-card-names", { + "empty": isEmpty + }); + + return + + {error + ? + : (res && res.count > 0 + ? <> + {/* Name listing */} + {res.names.map(name => )} + + {/* See more link */} + + + {t("address.namesSeeMore", { count: res.total })} + + + + : + )} + + ; +} diff --git a/src/pages/addresses/AddressPage.less b/src/pages/addresses/AddressPage.less new file mode 100644 index 0000000..39674ee --- /dev/null +++ b/src/pages/addresses/AddressPage.less @@ -0,0 +1,80 @@ +// Copyright (c) 2020-2021 Drew Lemmy +// This file is part of TenebraWeb 2 under AGPL-3.0. +// Full details: https://github.com/tmpim/TenebraWeb2/blob/master/LICENSE.txt +@import (reference) "../../App.less"; + +.address-page { + .top-address-row { + display: flex; + align-items: center; + + .address { + display: inline-block; + margin-right: @margin-lg; + margin-bottom: 0; + + font-size: @font-size-base * 2; + font-weight: 500; + + .ant-typography-copy { + line-height: 1 !important; + margin-left: @padding-xs; + + .anticon { + font-size: @font-size-base; + vertical-align: 0; + } + } + } + + .ant-btn { + margin-right: @margin-md; + } + + > .ant-btn:last-child { margin-right: 0; } + } + + .address-wallet-row { + margin-top: @padding-xs; + font-size: 90%; + + .prefix { + margin-right: @padding-xs; + } + + .address-wallet-verified, + .address-wallet-label, + .address-wallet-category, + .address-wallet-contact { + &:not(:last-child) { + margin-right: @padding-xs; + } + } + } + + .address-verified-description-row { + margin-top: @margin-md; + margin-bottom: @margin-md; + } + + .address-info-row { + max-width: 768px; + margin-bottom: @margin-lg; + + .kw-statistic { + margin-top: @margin-lg; + } + } + + .address-card-row { + & > .ant-col { + margin-bottom: @margin-md; + } + } + + .address-card-names { + .address-name-item { + display: block; + } + } +} diff --git a/src/pages/addresses/AddressPage.tsx b/src/pages/addresses/AddressPage.tsx new file mode 100644 index 0000000..19ea985 --- /dev/null +++ b/src/pages/addresses/AddressPage.tsx @@ -0,0 +1,235 @@ +// Copyright (c) 2020-2021 Drew Lemmy +// This file is part of TenebraWeb 2 under AGPL-3.0. +// Full details: https://github.com/tmpim/TenebraWeb2/blob/master/LICENSE.txt +import { useState, useEffect } from "react"; +import { Row, Col, Skeleton, Tag, Typography } from "antd"; + +import { useTranslation } from "react-i18next"; +import { useParams } from "react-router-dom"; + +import { PageLayout } from "@layout/PageLayout"; +import { APIErrorResult } from "@comp/results/APIErrorResult"; + +import { Statistic } from "@comp/Statistic"; +import { TenebraValue } from "@comp/tenebra/TenebraValue"; +import { DateTime } from "@comp/DateTime"; + +import * as api from "@api"; +import { lookupAddress, TenebraAddressWithNames } from "@api/lookup"; +import { useWallets } from "@wallets"; +import { useContacts } from "@contacts"; +import { useSubscription } from "@global/ws/WebsocketSubscription"; +import { useBooleanSetting } from "@utils/settings"; + +import { AddressButtonRow } from "./AddressButtonRow"; +import { AddressTransactionsCard } from "./AddressTransactionsCard"; +import { AddressNamesCard } from "./AddressNamesCard"; + +import { getVerified, VerifiedDescription } from "@comp/addresses/VerifiedAddress"; + +import { useEditWalletModal } from "@pages/wallets/WalletEditButton"; +import { useEditContactModal } from "@pages/contacts/ContactEditButton"; +import { useSendTransactionModal } from "@comp/transactions/SendTransactionModalLink"; + +import "./AddressPage.less"; + +const { Text } = Typography; + +interface ParamTypes { + address: string; +} + +interface PageContentsProps { + address: TenebraAddressWithNames; + lastTransactionID: number; +} + +function PageContents({ address, lastTransactionID }: PageContentsProps): JSX.Element { + const { t } = useTranslation(); + const { walletAddressMap } = useWallets(); + const { contactAddressMap } = useContacts(); + + const myWallet = walletAddressMap[address.address]; + const myContact = contactAddressMap[address.address]; + const showWalletTags = myWallet && (myWallet.label || myWallet.category); + const showContactTags = myContact && myContact.label; + + const verified = getVerified(address.address); + const showVerifiedDesc = verified?.description || verified?.website || + verified?.isActive === false; + + const [openEditWallet, editWalletModal] = useEditWalletModal(); + const [openEditContact, editContactModal] = useEditContactModal(); + const [openSendTx, sendTxModal] = useSendTransactionModal(); + + return <> + {/* Address and buttons */} + + {/* Address */} + + {address.address} + + + {/* Buttons (e.g. Send Tenebra, Add contact) */} + + + + {/* Wallet/contact/verified tags (if applicable) */} + {(showWalletTags || showContactTags || verified) && ( + + {/* Verified label */} + {verified?.label && + + {verified.label} + + } + + {/* Label */} + {myWallet?.label && + {t("address.walletLabel")} + {myWallet.label} + } + + {/* Category */} + {myWallet?.category && + {t("address.walletCategory")} + {myWallet.category} + } + + {/* Contact label */} + {myContact?.label && + {t("address.contactLabel")} + {myContact.label} + } + + )} + + {/* Main address info */} + + {/* Current balance */} + + } + /> + + + {/* Names */} + + 0 + ? t("address.nameCount", { count: address.names }) + : t("address.nameCountEmpty")} + /> + + + {/* First seen */} + + } + /> + + + + {/* Verified description/website */} + {showVerifiedDesc && ( + + )} + + {/* Transaction and name row */} + + {/* Recent transactions */} + + + + + {/* Names */} + + {/* TODO: Subscription for this card */} + + + + + {sendTxModal} + {editWalletModal} + {editContactModal} + ; +} + +export function AddressPage(): JSX.Element { + // Used to refresh the address data on syncNode change + const syncNode = api.useSyncNode(); + + const { address } = useParams(); + const [tenebraAddress, setTenebraAddress] = useState(); + const [error, setError] = useState(); + + // Used to refresh the address data when a transaction is made to it + const lastTransactionID = useSubscription({ address }); + const shouldAutoRefresh = useBooleanSetting("autoRefreshAddressPage"); + const usedRefreshID = shouldAutoRefresh ? lastTransactionID : 0; + + // Load the address on page load + // TODO: passthrough router state to pre-load from search + // REVIEW: The search no longer clears the LRU cache on each open, meaning it + // is possible for an address's information to be up to 3 minutes + // out-of-date in the search box. If we passed through the state from + // the search and directly used it here, it would definitely be too + // outdated to display. It could be possible to show that state data + // and still lookup the most recent data, but is it worth it? The page + // would appear 10-200ms faster, sure, but if the data _has_ changed, + // then it would cause a jarring re-render, just to save a single + // cheap network request. Will definitely require some further + // usability testing. + useEffect(() => { + lookupAddress(address, true) + .then(setTenebraAddress) + .catch(setError); + }, [syncNode, address, usedRefreshID]); + + // Change the page title depending on whether or not the address has loaded + const title = tenebraAddress + ? { siteTitle: tenebraAddress.address, subTitle: tenebraAddress.address } + : { siteTitleKey: "address.title" }; + + return + {error + ? ( + + ) + : (tenebraAddress + ? ( + + ) + : )} + ; +} diff --git a/src/pages/addresses/AddressTransactionsCard.tsx b/src/pages/addresses/AddressTransactionsCard.tsx new file mode 100644 index 0000000..d821111 --- /dev/null +++ b/src/pages/addresses/AddressTransactionsCard.tsx @@ -0,0 +1,76 @@ +// Copyright (c) 2020-2021 Drew Lemmy +// This file is part of TenebraWeb 2 under AGPL-3.0. +// Full details: https://github.com/tmpim/TenebraWeb2/blob/master/LICENSE.txt +import { useState, useEffect } from "react"; +import classNames from "classnames"; +import { Card, Skeleton, Empty } from "antd"; + +import { useTranslation } from "react-i18next"; + +import { TransactionSummary } from "@comp/transactions/TransactionSummary"; +import { lookupTransactions, LookupTransactionsResponse } from "@api/lookup"; + +import { useSyncNode } from "@api"; + +import { SmallResult } from "@comp/results/SmallResult"; + +import Debug from "debug"; +const debug = Debug("tenebraweb:address-transactions-card"); + +async function fetchTransactions(address: string): Promise { + debug("fetching transactions"); + return lookupTransactions( + [address], + { includeMined: true, limit: 5, orderBy: "id", order: "DESC" } + ); +} + +interface Props { + address: string; + lastTransactionID: number; +} + +export function AddressTransactionsCard({ address, lastTransactionID }: Props): JSX.Element { + const { t } = useTranslation(); + const syncNode = useSyncNode(); + + const [res, setRes] = useState(); + const [error, setError] = useState(); + const [loading, setLoading] = useState(true); + + // Fetch transactions on page load or sync node reload + useEffect(() => { + if (!syncNode) return; + + // Remove the existing results in case the address changed + setRes(undefined); + setLoading(true); + + fetchTransactions(address) + .then(setRes) + .catch(setError) + .finally(() => setLoading(false)); + }, [syncNode, address, lastTransactionID]); + + const isEmpty = !loading && (error || !res || res.count === 0); + const classes = classNames("kw-card", "address-card-transactions", { + "empty": isEmpty + }); + + return + + {error + ? + : (res && res.count > 0 + ? ( + + ) + : + )} + + ; +} diff --git a/src/pages/addresses/NameItem.tsx b/src/pages/addresses/NameItem.tsx new file mode 100644 index 0000000..a077ca9 --- /dev/null +++ b/src/pages/addresses/NameItem.tsx @@ -0,0 +1,34 @@ +// Copyright (c) 2020-2021 Drew Lemmy +// This file is part of TenebraWeb 2 under AGPL-3.0. +// Full details: https://github.com/tmpim/TenebraWeb2/blob/master/LICENSE.txt +import { Row } from "antd"; + +import { useTranslation, Trans } from "react-i18next"; +import { Link } from "react-router-dom"; + +import { TenebraName } from "@api/types"; +import { TenebraNameLink } from "@comp/names/TenebraNameLink"; +import { DateTime } from "@comp/DateTime"; + +export function NameItem({ name }: { name: TenebraName }): JSX.Element { + const { t } = useTranslation(); + + const nameEl = ; + const nameLink = "/network/names/" + encodeURIComponent(name.name); + const nameTime = new Date(name.registered); + + return +
+ {/* Display 'purchased' if this is the original owner, otherwise display + * 'received'. */} + {name.owner === name.original_owner + ? Purchased {nameEl} + : Received {nameEl}} +
+ + {/* Purchase time */} + + + +
; +} diff --git a/src/pages/backup/BackupResultsSummary.less b/src/pages/backup/BackupResultsSummary.less new file mode 100644 index 0000000..6d8b661 --- /dev/null +++ b/src/pages/backup/BackupResultsSummary.less @@ -0,0 +1,21 @@ +// Copyright (c) 2020-2021 Drew Lemmy +// This file is part of TenebraWeb 2 under AGPL-3.0. +// Full details: https://github.com/tmpim/TenebraWeb2/blob/master/LICENSE.txt +@import (reference) "../../App.less"; + +.backup-results-summary { + .summary-wallets-imported .positive, .summary-contacts-imported .positive { + color: @kw-green; + font-weight: bold; + } + + .summary-errors-warnings .errors { + color: @kw-red; + font-weight: bold; + } + + .summary-errors-warnings .warnings { + color: @kw-orange; + font-weight: bold; + } +} diff --git a/src/pages/backup/BackupResultsSummary.tsx b/src/pages/backup/BackupResultsSummary.tsx new file mode 100644 index 0000000..e174a28 --- /dev/null +++ b/src/pages/backup/BackupResultsSummary.tsx @@ -0,0 +1,90 @@ +// Copyright (c) 2020-2021 Drew Lemmy +// This file is part of TenebraWeb 2 under AGPL-3.0. +// Full details: https://github.com/tmpim/TenebraWeb2/blob/master/LICENSE.txt +import { Typography } from "antd"; + +import { useTranslation, Trans } from "react-i18next"; + +import { BackupResults, ResultType } from "./backupResults"; + +import "./BackupResultsSummary.less"; + +const { Paragraph } = Typography; + +function getMessageCountByType( + results: BackupResults, + type: ResultType +): number { + let acc = 0; + + acc += Object.values(results.messages.wallets) + .reduce((acc, r) => acc + r.messages.filter(m => m.type === type).length, 0); + acc += Object.values(results.messages.contacts) + .reduce((acc, r) => acc + r.messages.filter(m => m.type === type).length, 0); + + return acc; +} + +/** Provides a paragraph summarising the results of the backup import (e.g. the + * amount of wallets imported, the amount of errors, etc.). */ +export function BackupResultsSummary({ results }: { results: BackupResults }): JSX.Element { + const { t } = useTranslation(); + + const { newWallets, skippedWallets, newContacts, skippedContacts } = results; + const warningCount = getMessageCountByType(results, "warning"); + const errorCount = getMessageCountByType(results, "error"); + + return + {/* New wallets imported count */} +
+ {newWallets > 0 + ? ( + + {{ count: newWallets }} new wallet + was imported. + + ) + : t("import.results.noneImported")} +
+ + {/* Skipped wallets count */} + {skippedWallets > 0 &&
+ + {{ count: skippedWallets }} wallet was skipped. + +
} + + {/* New contacts imported count */} + {newContacts > 0 &&
+ + {{ count: newContacts }} new contact + was imported. + +
} + + {/* Skipped contacts count */} + {skippedContacts > 0 &&
+ + {{ count: skippedContacts }} contact was skipped. + +
} + + {/* Errors */} + {errorCount > 0 &&
+ + There was + {{ count: errorCount }} error + while importing your backup. + +
} + + {/* Warnings */} + {warningCount > 0 &&
+ + There was + {{ count: warningCount }} warning + while importing your backup. + +
} +
; +} diff --git a/src/pages/backup/BackupResultsTree.less b/src/pages/backup/BackupResultsTree.less new file mode 100644 index 0000000..98572bc --- /dev/null +++ b/src/pages/backup/BackupResultsTree.less @@ -0,0 +1,28 @@ +// Copyright (c) 2020-2021 Drew Lemmy +// This file is part of TenebraWeb 2 under AGPL-3.0. +// Full details: https://github.com/tmpim/TenebraWeb2/blob/master/LICENSE.txt +@import (reference) "../../App.less"; + +.backup-results-tree { + max-height: 480px; + overflow-y: auto; + + .backup-result-icon { + margin-right: @padding-xs; + } + + // Map the different result type icons to their appropriate colours + .anticon.backup-result-success { color: @kw-green; } + .anticon.backup-result-warning { color: @kw-orange; } + .anticon.backup-result-error { color: @kw-red; } + + // Make the non-leaf nodes bold, so the tree is more readable + .ant-tree-treenode:not(.backup-results-tree-message) .ant-tree-title { + font-weight: 500; + } + + // The leaf nodes have an invisible button which takes up space; remove that + .ant-tree-switcher-noop { + display: none; + } +} diff --git a/src/pages/backup/BackupResultsTree.tsx b/src/pages/backup/BackupResultsTree.tsx new file mode 100644 index 0000000..edac9ff --- /dev/null +++ b/src/pages/backup/BackupResultsTree.tsx @@ -0,0 +1,141 @@ +// Copyright (c) 2020-2021 Drew Lemmy +// This file is part of TenebraWeb 2 under AGPL-3.0. +// Full details: https://github.com/tmpim/TenebraWeb2/blob/master/LICENSE.txt +import { useMemo } from "react"; +import { Tree } from "antd"; +import { DataNode } from "antd/lib/tree"; +import { CheckCircleOutlined, WarningOutlined, ExclamationCircleOutlined } from "@ant-design/icons"; + +import { i18n } from "i18next"; +import { useTranslation, TFunction } from "react-i18next"; +import { translateError } from "@utils/i18n"; + +import { + BackupResults, ResultType, MessageType, TranslatedMessage +} from "./backupResults"; + +import "./BackupResultsTree.less"; + +interface Props { + results: BackupResults; +} + +const CLEAN_ID_RE = /^(?:[Ww]allet\d*|[Ff]riend\d*|[Cc]ontact\d*)-/; + +/** Maps the different types of message results (success, warning, error) + * to icons. */ +function getMessageIcon(type: ResultType) { + switch (type) { + case "success": + return ; + case "warning": + return ; + case "error": + return ; + } +} + +/** Maps the different types of messages to a properly rendered ReactNode. */ +function getMessageTitle( + t: TFunction, i18n: i18n, + message?: MessageType, error?: Error +): React.ReactNode { + // If there was an error, translate it if possible and render it. + if (error) { + return translateError(t, error); + } + + // If there's no error, show the message instead + if (typeof message === "string") { + // If the message is a string, translate it if possible, otherwise, render + // it directly. + if (i18n.exists(message)) return t(message); + else return message; + } else if (message && typeof message === "object" && (message as TranslatedMessage).key) { + // If the message is a TranslatedMessage, translate it and substitute the + // arguments in + const msg = (message as TranslatedMessage); + return t(msg.key, msg.args); + } else if (message) { + // It's probably a ReactNode, render it directly + return message; + } + + // Shouldn't happen, but there was neither a message nor an error. + return null; +} + +function getTreeItem( + t: TFunction, i18n: i18n, + results: BackupResults, + type: "wallets" | "contacts", + id: string, +): DataNode { + // The IDs are the keys of the backup, which may begin with prefixes like + // "Wallet-"; remove those for cleanliness + const cleanID = id.replace(CLEAN_ID_RE, ""); + const resultSet = results.messages[type][id]; + const { label, messages } = resultSet; + const messageNodes: DataNode[] = []; + + for (let i = 0; i < messages.length; i++) { + const { type, message, error } = messages[i]; + const icon = getMessageIcon(type); + const title = getMessageTitle(t, i18n, message, error); + + messageNodes.push({ + key: `${type}-${cleanID}-${i}`, + title, + icon, + isLeaf: true, + className: "backup-results-tree-message" + }); + } + + return { + key: `${type}-${cleanID}`, + title: t( + type === "wallets" + ? "import.results.treeWallet" + : "import.results.treeContact", + { id: label || cleanID } + ), + children: messageNodes + }; +} + +/** Converts the backup results into a tree of messages, grouped by wallet + * and contact UUID. */ +function getTreeData( + t: TFunction, i18n: i18n, + results: BackupResults +): DataNode[] { + const out: DataNode[] = []; + + // Add the wallet messages data + for (const id in results.messages.wallets) + out.push(getTreeItem(t, i18n, results, "wallets", id)); + + // Add the contact messages data + for (const id in results.messages.contacts) + out.push(getTreeItem(t, i18n, results, "contacts", id)); + + return out; +} + +export function BackupResultsTree({ results }: Props): JSX.Element { + const { t, i18n } = useTranslation(); + + const treeData = useMemo(() => + getTreeData(t, i18n, results), [t, i18n, results]); + + return ; +} diff --git a/src/pages/backup/ExportBackupModal.tsx b/src/pages/backup/ExportBackupModal.tsx new file mode 100644 index 0000000..26c3e1e --- /dev/null +++ b/src/pages/backup/ExportBackupModal.tsx @@ -0,0 +1,117 @@ +// Copyright (c) 2020-2021 Drew Lemmy +// This file is part of TenebraWeb 2 under AGPL-3.0. +// Full details: https://github.com/tmpim/TenebraWeb2/blob/master/LICENSE.txt +import { useState, useEffect, useRef, Dispatch, SetStateAction } from "react"; +import { Modal, Button, Input } from "antd"; +import { DownloadOutlined } from "@ant-design/icons"; + +import { Trans } from "react-i18next"; +import { useTFns } from "@utils/i18n"; + +import { useWallets } from "@wallets"; + +import { backupExport } from "./backupExport"; +import { CopyInputButton } from "@comp/CopyInputButton"; + +import dayjs from "dayjs"; +import { saveAs } from "file-saver"; + +import { criticalError } from "@utils"; + +interface Props { + visible?: boolean; + setVisible: Dispatch>; +} + +export function ExportBackupModal({ + visible, + setVisible +}: Props): JSX.Element { + const { t, tStr, tKey } = useTFns("export."); + + // The generated export code + const [code, setCode] = useState(""); + const inputRef = useRef(null); + + // Used to auto-refresh the code if the wallets change + const { wallets } = useWallets(); + + // Generate the backup code + useEffect(() => { + // Don't bother generating if the modal isn't visible + if (!visible) { + setCode(""); + return; + } + + backupExport() + .then(setCode) + .catch(criticalError); + }, [visible, wallets]); + + function saveToFile() { + const blob = new Blob([code], { type: "text/plain;charset=utf-8" }); + saveAs(blob, `TenebraWeb2-export-${dayjs().format("YYYY-MM-DD--HH-mm-ss")}.txt`); + closeModal(); + } + + function closeModal() { + setVisible(false); + setCode(""); + } + + // Shows a formatted size for the backup code + function Size() { + return {(code.length / 1024).toFixed(1)} KiB; + } + + return + {/* Close button */} + + + {/* Copy to clipboard button */} + + + {/* Save to file button */} + + } + + onCancel={closeModal} + destroyOnClose + > + {/* Description paragraph */} +

+ This secret code contains your wallets and address book contacts. You can + use it to import them in another browser, or to back them up. You will + still need your master password to import the wallets in the future. + Do not share this code with anyone. +

+ + {/* Size calculation */} +

+ Size: +

+ + +
; +} diff --git a/src/pages/backup/ImportBackupForm.tsx b/src/pages/backup/ImportBackupForm.tsx new file mode 100644 index 0000000..c521e1c --- /dev/null +++ b/src/pages/backup/ImportBackupForm.tsx @@ -0,0 +1,201 @@ +// Copyright (c) 2020-2021 Drew Lemmy +// This file is part of TenebraWeb 2 under AGPL-3.0. +// Full details: https://github.com/tmpim/TenebraWeb2/blob/master/LICENSE.txt +import { useState, Dispatch, SetStateAction } from "react"; +import { Form, Input, Checkbox, Typography } from "antd"; + +import { useTFns, translateError } from "@utils/i18n"; + +import { getMasterPasswordInput } from "@comp/auth/MasterPasswordInput"; + +import { useBooleanSetting, setBooleanSetting } from "@utils/settings"; + +import { ImportDetectFormat } from "./ImportDetectFormat"; +import { IncrProgressFn, InitProgressFn } from "./ImportProgress"; +import { decodeBackup } from "./backupParser"; +import { backupVerifyPassword, backupImport } from "./backupImport"; +import { BackupResults } from "./backupResults"; + +import Debug from "debug"; +const debug = Debug("tenebraweb:import-backup-modal"); + +const { Paragraph } = Typography; +const { TextArea } = Input; + +interface FormValues { + masterPassword: string; + code: string; + overwrite: boolean; +} + +interface ImportBackupFormHookRes { + form: JSX.Element; + + resetForm: () => void; + triggerSubmit: () => Promise; + + setCode: (code: string) => void; +} + +export function useImportBackupForm( + setLoading: Dispatch>, + setResults: Dispatch>, + + onProgress: IncrProgressFn, + initProgress: InitProgressFn +): ImportBackupFormHookRes { + const { t, tStr, tKey } = useTFns("import."); + + const [form] = Form.useForm(); + + const [code, setCode] = useState(""); + const [decodeError, setDecodeError] = useState(); + const [masterPasswordError, setMasterPasswordError] = useState(); + + const importOverwrite = useBooleanSetting("importOverwrite"); + + function resetForm() { + form.resetFields(); + setCode(""); + setDecodeError(""); + setMasterPasswordError(""); + } + + function onValuesChange(changed: Partial) { + if (changed.code) setCode(changed.code); + + // Remember the value of the 'overwrite' checkbox + if (changed.overwrite !== undefined) { + debug("updating importOverwrite to %b", changed.overwrite); + setBooleanSetting("importOverwrite", changed.overwrite, false); + } + } + + // Detect the backup format for the final time, validate the password, and + // if all is well, begin the import + async function onFinish() { + const values = await form.validateFields(); + + const { masterPassword, code, overwrite } = values; + if (!masterPassword || !code) return; + + setLoading(true); + + try { + // Decode first + const backup = decodeBackup(code); + debug("detected format: %s", backup.type); + setDecodeError(undefined); + + // Attempt to verify the master password + await backupVerifyPassword(backup, masterPassword); + setMasterPasswordError(undefined); + + // Perform the import + const results = await backupImport( + backup, masterPassword, !overwrite, + onProgress, initProgress + ); + + setResults(results); + } catch (err) { + if (err.message === "import.masterPasswordRequired" + || err.message === "import.masterPasswordIncorrect") { + // Master password incorrect error + setMasterPasswordError(translateError(t, err)); + } else { + // Any other decoding error + console.error(err); + setDecodeError(translateError(t, err, tKey("decodeErrors.unknown"))); + } + } finally { + setLoading(false); + } + } + + const formEl =
+ {/* Import lead */} + {tStr("description")} + + {/* Detected format information */} + + + {/* Password input */} + + {getMasterPasswordInput({ + placeholder: tStr("masterPasswordPlaceholder"), + autoFocus: true + })} + + + {/* Code textarea */} + +