Merge branch 'client-develop'

This commit is contained in:
zkldi
2022-07-04 01:59:55 +01:00
342 changed files with 46820 additions and 0 deletions
+10
View File
@@ -0,0 +1,10 @@
github: # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2]
patreon: # zkldi
open_collective: # Replace with a single Open Collective username
ko_fi: zkldi
tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel
community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry
liberapay: # Replace with a single Liberapay username
issuehunt: # Replace with a single IssueHunt username
otechie: # Replace with a single Otechie username
custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2']
+28
View File
@@ -0,0 +1,28 @@
name: deploy
on:
push:
branches: [master, develop]
workflow_dispatch:
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- name: Deploy to staging
if: github.ref == 'refs/heads/develop'
uses: appleboy/ssh-action@1a8b3784eaa665f677fa114edd5683bb6a6bfaa8
with:
host: ${{ secrets.TACHI_HOST }}
username: tachi
key: ${{ secrets.TACHI_KEY }}
script: tachi-client-deploy -s
- name: Deploy to live
if: github.ref == 'refs/heads/master'
uses: appleboy/ssh-action@1a8b3784eaa665f677fa114edd5683bb6a6bfaa8
with:
host: ${{ secrets.TACHI_HOST }}
username: tachi
key: ${{ secrets.TACHI_KEY }}
script: tachi-client-deploy
+17
View File
@@ -0,0 +1,17 @@
# dependencies
/node_modules
.env
/build
/src/react-app-env.d.ts
.vscode
/src/react-app-env.d.ts
.pnpm-debug.log
dump.rdb
...bfg-report
+3
View File
@@ -0,0 +1,3 @@
[submodule "src/_assets/metronic-scss"]
path = src/_assets/metronic-scss
url = https://github.com/tng-dev/tachi-client-scss
+82
View File
@@ -0,0 +1,82 @@
<!-- This file is not automatically generated. We write this by hand. -->
<!-- You must update it by hand too! -->
# Syntax
| Type | Description |
| :----: | :---------------------------------------------------------------: |
| BUG | This was a bug fix. |
| ENH | This was an enhancement, An improvement to an existing behaviour. |
| FEAT | A new feature. |
| REWORK | An existing feature was reworked/changed. |
Anything prefixed with `INTERNAL_` has absolutely no bearing on end users, and should
not generally be shown to end users.
# Changelog
# Current
- [REWORK] Metronic CSS has been forced out of the repo, allowing us to go open source. Watch this space!
*****
- [ENH] IIDX and BMS Dan gauge now displays in gray.
- [ENH] Showcase Stats now have the option to limit returned charts to just those you've played.
- [ENH] internal decimal levels are now displayed on the song page.
*****
- [BUG] Numeric level indicators no longer appear when the numeric level is 0 (For kichiku, kiraku, etc.)
- [BUG] 2dxtra charts now have more obvious shorthand.
# 2.1.7
I haven't been keeping this dilligently up to date.
- [ENH] Sessions now display their ratings.
- [ENH] BMS scores submitted by the LR2Hook now include graph data.
# 2.1.2
- [INTERNAL_ENH] Keys no longer *ever* use random ids. This was a fundamental misunderstanding of react on my part.
*****
- [BUG] The tierlist viewer no longer crashes if a change causes a bucket to be empty.
# 2.1.1
- [BUG] USC Searches no longer display (undefined) next to them.
## 2.1.0
- [FEAT] Added UI support for Jubeat, Pop'n, WACCA and PMS.
- [INTERNAL_FEAT] Config is now served from the server instead of being cloned to the client.
*****
- [ENH] Added a CHANGELOG.md file. (#81)
- [ENH] Rating algorithms now explain what they are in the UI.
- [ENH] Massively improved the display of BPI related stuff. (#82)
- [ENH] Showcase stats on charts now link to the chart in question.
- [ENH] Ranking info is now clickable to go to the leaderboard.
- [ENH] You can now set a BPI target.
- [ENH] You can now set a VOLFORCE target.
- [ENH] Song search now displays the table for the chart for BMS.
- [ENH] Song search now displays the effector for USC.
- [ENH] Banned users now know that they're banned.
- [ENH] Beatoraja IR instructions now include example `.bat` and `.sh` files for properly booting the IR.
- [ENH] Add a GDPR page.
- [ENH] Renamed "All Games" to "Global Info", should be clearer.
- [ENH] FAILED scores for SDVX and USC now visually display as "PLAYED" instead. Note that this is **PURELY** visual.
- [ENH] Import Scores now shows recently used import methods if no game is selected.
*****
- [BUG] Going from the ARC page to your profile no longer crashes the client.
- [BUG] Using userIDs instead of usernames in the URL no longer crashes the client.
- [BUG] The Switchboard graph now loads even if you have no sessions on the game.
- [BUG] Charts without tierlist information now still render at the bottom of tierlist view.
- [BUG] The PBs indicator underneath a session is now correct, and not just the amount of scores you had.
+661
View File
@@ -0,0 +1,661 @@
GNU AFFERO GENERAL PUBLIC LICENSE
Version 3, 19 November 2007
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
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.
<one line to give the program's name and a brief idea of what it does.>
Copyright (C) <year> <name of author>
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 <https://www.gnu.org/licenses/>.
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
<https://www.gnu.org/licenses/>.
+58
View File
@@ -0,0 +1,58 @@
# Tachi Client
Tachi-Client is the frontend for Tachi.
## Setup
Run `pnpm install` to install dependencies.
### CSS and Submodules
We legally cannot redistribute the source SCSS files for this repository, as they are from
[Metronic](https://preview.keenthemes.com/metronic8/demo2/index.html). Although the codebase itself has been completely cleanly implemented, with no metronic, the SCSS is still
theirs, and we do not have the legal right to redistribute it.
If you are a member of TNG-Dev, you will have access to [tachi-client-scss](https://github.com/tng-dev/tachi-client-scss). This submodule is located at `src/_assets/metronic-scss`.
Cloning this will make that the source of truth for CSS styling. Otherwise, we fall back
to `src/_assets/compiled-css/main.css`. This is legally redistributable compiled (minified)
output from the metronic SCSS.
## Dev Server
`pnpm start` launches a development server for rapid iteration. Use this if you're developing.
## Building
`pnpm build` compiles the source code into `build/`. This directory can then be shipped around and
served by tools like nginx. This should be used for production use cases only, as
compiling the client takes around 20-30 seconds, whereas the dev server is near instant.
## Config
For local development, spawn an instance of [tachi-server](https://github.com/tng-dev/tachi-server). Create a `.env` file with atleast the following content:
```sh
REACT_APP_TCHIC_MODE="omni"
REACT_APP_SERVER_URL="https://127.0.0.1:8080"
REACT_APP_CDN_URL="https://127.0.0.1:8080/cdn"
```
Replace `8080` with a different port if you've changed it in your instance of tachi-server.
Other configuration variables are as follows:
```sh
# Stop react from launching a browser when you run pnpm start.
BROWSER="none"
# Where IMGScores should be generated from.
REACT_APP_IMGSCORE_URL=""
# KAI API Client IDs.
REACT_APP_EAG_CLIENT_ID=""
REACT_APP_MIN_CLIENT_ID=""
REACT_APP_FLO_CLIENT_ID=""
# How long users should have to read the rules for, in seconds.
# Defaults to 30, but useful to lower for localdev.
REACT_APP_RULES_READ_TIME=30
```
+93
View File
@@ -0,0 +1,93 @@
'use strict';
const fs = require('fs');
const path = require('path');
const paths = require('./paths');
// Make sure that including paths.js after env.js will read .env variables.
delete require.cache[require.resolve('./paths')];
const NODE_ENV = process.env.NODE_ENV;
if (!NODE_ENV) {
throw new Error(
'The NODE_ENV environment variable is required but was not specified.'
);
}
// https://github.com/bkeepers/dotenv#what-other-env-files-can-i-use
const dotenvFiles = [
`${paths.dotenv}.${NODE_ENV}.local`,
`${paths.dotenv}.${NODE_ENV}`,
// Don't include `.env.local` for `test` environment
// since normally you expect tests to produce the same
// results for everyone
NODE_ENV !== 'test' && `${paths.dotenv}.local`,
paths.dotenv,
].filter(Boolean);
// Load environment variables from .env* files. Suppress warnings using silent
// if this file is missing. dotenv will never modify any environment variables
// that have already been set. Variable expansion is supported in .env files.
// https://github.com/motdotla/dotenv
// https://github.com/motdotla/dotenv-expand
dotenvFiles.forEach(dotenvFile => {
if (fs.existsSync(dotenvFile)) {
require('dotenv-expand')(
require('dotenv').config({
path: dotenvFile,
})
);
}
});
// We support resolving modules according to `NODE_PATH`.
// This lets you use absolute paths in imports inside large monorepos:
// https://github.com/facebook/create-react-app/issues/253.
// It works similar to `NODE_PATH` in Node itself:
// https://nodejs.org/api/modules.html#modules_loading_from_the_global_folders
// Note that unlike in Node, only *relative* paths from `NODE_PATH` are honored.
// Otherwise, we risk importing Node.js core modules into an app instead of Webpack shims.
// https://github.com/facebook/create-react-app/issues/1023#issuecomment-265344421
// We also resolve them to make sure all tools using them work consistently.
const appDirectory = fs.realpathSync(process.cwd());
process.env.NODE_PATH = (process.env.NODE_PATH || '')
.split(path.delimiter)
.filter(folder => folder && !path.isAbsolute(folder))
.map(folder => path.resolve(appDirectory, folder))
.join(path.delimiter);
// Grab NODE_ENV and REACT_APP_* environment variables and prepare them to be
// injected into the application via DefinePlugin in Webpack configuration.
const REACT_APP = /^REACT_APP_/i;
function getClientEnvironment(publicUrl) {
const raw = Object.keys(process.env)
.filter(key => REACT_APP.test(key))
.reduce(
(env, key) => {
env[key] = process.env[key];
return env;
},
{
// Useful for determining whether we’re running in production mode.
// Most importantly, it switches React into the correct mode.
NODE_ENV: process.env.NODE_ENV || 'development',
// Useful for resolving the correct path to static assets in `public`.
// For example, <img src={process.env.PUBLIC_URL + '/img/logo.png'} />.
// This should only be used as an escape hatch. Normally you would put
// images into the `src` and `import` them in code to get their paths.
PUBLIC_URL: publicUrl,
}
);
// Stringify all values so we can feed into Webpack DefinePlugin
const stringified = {
'process.env': Object.keys(raw).reduce((env, key) => {
env[key] = JSON.stringify(raw[key]);
return env;
}, {}),
};
return { raw, stringified };
}
module.exports = getClientEnvironment;
+14
View File
@@ -0,0 +1,14 @@
'use strict';
// This is a custom Jest transformer turning style imports into empty objects.
// http://facebook.github.io/jest/docs/en/webpack.html
module.exports = {
process() {
return 'module.exports = {};';
},
getCacheKey() {
// The output is always the same.
return 'cssTransform';
},
};
+40
View File
@@ -0,0 +1,40 @@
'use strict';
const path = require('path');
const camelcase = require('camelcase');
// This is a custom Jest transformer turning file imports into filenames.
// http://facebook.github.io/jest/docs/en/webpack.html
module.exports = {
process(src, filename) {
const assetFilename = JSON.stringify(path.basename(filename));
if (filename.match(/\.svg$/)) {
// Based on how SVGR generates a component name:
// https://github.com/smooth-code/svgr/blob/01b194cf967347d43d4cbe6b434404731b87cf27/packages/core/src/state.js#L6
const pascalCaseFilename = camelcase(path.parse(filename).name, {
pascalCase: true,
});
const componentName = `Svg${pascalCaseFilename}`;
return `const React = require('react');
module.exports = {
__esModule: true,
default: ${assetFilename},
ReactComponent: React.forwardRef(function ${componentName}(props, ref) {
return {
$$typeof: Symbol.for('react.element'),
type: 'svg',
ref: ref,
key: null,
props: Object.assign({}, props, {
children: ${assetFilename}
})
};
}),
};`;
}
return `module.exports = ${assetFilename};`;
},
};
+141
View File
@@ -0,0 +1,141 @@
'use strict';
const fs = require('fs');
const path = require('path');
const paths = require('./paths');
const chalk = require('react-dev-utils/chalk');
const resolve = require('resolve');
/**
* Get additional module paths based on the baseUrl of a compilerOptions object.
*
* @param {Object} options
*/
function getAdditionalModulePaths(options = {}) {
const baseUrl = options.baseUrl;
// We need to explicitly check for null and undefined (and not a falsy value) because
// TypeScript treats an empty string as `.`.
if (baseUrl == null) {
// If there's no baseUrl set we respect NODE_PATH
// Note that NODE_PATH is deprecated and will be removed
// in the next major release of create-react-app.
const nodePath = process.env.NODE_PATH || '';
return nodePath.split(path.delimiter).filter(Boolean);
}
const baseUrlResolved = path.resolve(paths.appPath, baseUrl);
// We don't need to do anything if `baseUrl` is set to `node_modules`. This is
// the default behavior.
if (path.relative(paths.appNodeModules, baseUrlResolved) === '') {
return null;
}
// Allow the user set the `baseUrl` to `appSrc`.
if (path.relative(paths.appSrc, baseUrlResolved) === '') {
return [paths.appSrc];
}
// If the path is equal to the root directory we ignore it here.
// We don't want to allow importing from the root directly as source files are
// not transpiled outside of `src`. We do allow importing them with the
// absolute path (e.g. `src/Components/Button.js`) but we set that up with
// an alias.
if (path.relative(paths.appPath, baseUrlResolved) === '') {
return null;
}
// Otherwise, throw an error.
throw new Error(
chalk.red.bold(
"Your project's `baseUrl` can only be set to `src` or `node_modules`." +
' Create React App does not support other values at this time.'
)
);
}
/**
* Get webpack aliases based on the baseUrl of a compilerOptions object.
*
* @param {*} options
*/
function getWebpackAliases(options = {}) {
const baseUrl = options.baseUrl;
if (!baseUrl) {
return {};
}
const baseUrlResolved = path.resolve(paths.appPath, baseUrl);
if (path.relative(paths.appPath, baseUrlResolved) === '') {
return {
src: paths.appSrc,
};
}
}
/**
* Get jest aliases based on the baseUrl of a compilerOptions object.
*
* @param {*} options
*/
function getJestAliases(options = {}) {
const baseUrl = options.baseUrl;
if (!baseUrl) {
return {};
}
const baseUrlResolved = path.resolve(paths.appPath, baseUrl);
if (path.relative(paths.appPath, baseUrlResolved) === '') {
return {
'src/(.*)$': '<rootDir>/src/$1',
};
}
}
function getModules() {
// Check if TypeScript is setup
const hasTsConfig = fs.existsSync(paths.appTsConfig);
const hasJsConfig = fs.existsSync(paths.appJsConfig);
if (hasTsConfig && hasJsConfig) {
throw new Error(
'You have both a tsconfig.json and a jsconfig.json. If you are using TypeScript please remove your jsconfig.json file.'
);
}
let config;
// If there's a tsconfig.json we assume it's a
// TypeScript project and set up the config
// based on tsconfig.json
if (hasTsConfig) {
const ts = require(resolve.sync('typescript', {
basedir: paths.appNodeModules,
}));
config = ts.readConfigFile(paths.appTsConfig, ts.sys.readFile).config;
// Otherwise we'll check if there is jsconfig.json
// for non TS projects.
} else if (hasJsConfig) {
config = require(paths.appJsConfig);
}
config = config || {};
const options = config.compilerOptions || {};
const additionalModulePaths = getAdditionalModulePaths(options);
return {
additionalModulePaths: additionalModulePaths,
webpackAliases: getWebpackAliases(options),
jestAliases: getJestAliases(options),
hasTsConfig,
};
}
module.exports = getModules();
+90
View File
@@ -0,0 +1,90 @@
'use strict';
const path = require('path');
const fs = require('fs');
const url = require('url');
// Make sure any symlinks in the project folder are resolved:
// https://github.com/facebook/create-react-app/issues/637
const appDirectory = fs.realpathSync(process.cwd());
const resolveApp = relativePath => path.resolve(appDirectory, relativePath);
const envPublicUrl = process.env.PUBLIC_URL;
function ensureSlash(inputPath, needsSlash) {
const hasSlash = inputPath.endsWith('/');
if (hasSlash && !needsSlash) {
return inputPath.substr(0, inputPath.length - 1);
} else if (!hasSlash && needsSlash) {
return `${inputPath}/`;
} else {
return inputPath;
}
}
const getPublicUrl = appPackageJson =>
envPublicUrl || require(appPackageJson).homepage;
// We use `PUBLIC_URL` environment variable or "homepage" field to infer
// "public path" at which the app is served.
// Webpack needs to know it to put the right <script> hrefs into HTML even in
// single-page apps that may serve index.html for nested URLs like /todos/42.
// We can't use a relative path in HTML because we don't want to load something
// like /todos/42/static/js/bundle.7289d.js. We have to know the root.
function getServedPath(appPackageJson) {
const publicUrl = getPublicUrl(appPackageJson);
const servedUrl =
envPublicUrl || (publicUrl ? url.parse(publicUrl).pathname : '/');
return ensureSlash(servedUrl, true);
}
const moduleFileExtensions = [
'web.mjs',
'mjs',
'web.js',
'js',
'web.ts',
'ts',
'web.tsx',
'tsx',
'json',
'web.jsx',
'jsx',
];
// Resolve file paths in the same order as webpack
const resolveModule = (resolveFn, filePath) => {
const extension = moduleFileExtensions.find(extension =>
fs.existsSync(resolveFn(`${filePath}.${extension}`))
);
if (extension) {
return resolveFn(`${filePath}.${extension}`);
}
return resolveFn(`${filePath}.js`);
};
// config after eject: we're in ./config/
module.exports = {
dotenv: resolveApp('.env'),
appPath: resolveApp('.'),
appBuild: resolveApp(process.env.BUILD_OUT_DIR ?? 'build'),
appPublic: resolveApp('public'),
appHtml: resolveApp('public/index.html'),
appIndexJs: resolveModule(resolveApp, 'src/index'),
appPackageJson: resolveApp('package.json'),
appSrc: resolveApp('src'),
appTsConfig: resolveApp('tsconfig.json'),
appJsConfig: resolveApp('jsconfig.json'),
yarnLockFile: resolveApp('yarn.lock'),
testsSetup: resolveModule(resolveApp, 'src/setupTests'),
proxySetup: resolveApp('src/setupProxy.js'),
appNodeModules: resolveApp('node_modules'),
publicUrl: getPublicUrl(resolveApp('package.json')),
servedPath: getServedPath(resolveApp('package.json')),
};
module.exports.moduleFileExtensions = moduleFileExtensions;
+35
View File
@@ -0,0 +1,35 @@
'use strict';
const { resolveModuleName } = require('ts-pnp');
exports.resolveModuleName = (
typescript,
moduleName,
containingFile,
compilerOptions,
resolutionHost
) => {
return resolveModuleName(
moduleName,
containingFile,
compilerOptions,
resolutionHost,
typescript.resolveModuleName
);
};
exports.resolveTypeReferenceDirective = (
typescript,
moduleName,
containingFile,
compilerOptions,
resolutionHost
) => {
return resolveModuleName(
moduleName,
containingFile,
compilerOptions,
resolutionHost,
typescript.resolveTypeReferenceDirective
);
};
+653
View File
@@ -0,0 +1,653 @@
const fs = require("fs");
const isWsl = require("is-wsl");
const path = require("path");
const webpack = require("webpack");
const resolve = require("resolve");
const PnpWebpackPlugin = require("pnp-webpack-plugin");
const HtmlWebpackPlugin = require("html-webpack-plugin");
const CaseSensitivePathsPlugin = require("case-sensitive-paths-webpack-plugin");
const InlineChunkHtmlPlugin = require("react-dev-utils/InlineChunkHtmlPlugin");
const TerserPlugin = require("terser-webpack-plugin");
const MiniCssExtractPlugin = require("mini-css-extract-plugin");
const OptimizeCSSAssetsPlugin = require("optimize-css-assets-webpack-plugin");
const safePostCssParser = require("postcss-safe-parser");
const ManifestPlugin = require("webpack-manifest-plugin");
const InterpolateHtmlPlugin = require("react-dev-utils/InterpolateHtmlPlugin");
const WorkboxWebpackPlugin = require("workbox-webpack-plugin");
const WatchMissingNodeModulesPlugin = require("react-dev-utils/WatchMissingNodeModulesPlugin");
const ModuleScopePlugin = require("react-dev-utils/ModuleScopePlugin");
const getCSSModuleLocalIdent = require("react-dev-utils/getCSSModuleLocalIdent");
const paths = require("./paths");
const modules = require("./modules");
const getClientEnvironment = require("./env");
const ModuleNotFoundPlugin = require("react-dev-utils/ModuleNotFoundPlugin");
const ForkTsCheckerWebpackPlugin = require("react-dev-utils/ForkTsCheckerWebpackPlugin");
const typescriptFormatter = require("react-dev-utils/typescriptFormatter");
const eslint = require("eslint");
const postcssNormalize = require("postcss-normalize");
const appPackageJson = require(paths.appPackageJson);
// Source maps are resource heavy and can cause out of memory issue for large source files.
const shouldUseSourceMap = true; // process.env.GENERATE_SOURCEMAP !== "false" && process.env.NODE_ENV !== "production";
// Some apps do not need the benefits of saving a web request, so not inlining the chunk
// makes for a smoother build process.
const shouldInlineRuntimeChunk = process.env.INLINE_RUNTIME_CHUNK !== "false";
const imageInlineSizeLimit = parseInt(
process.env.IMAGE_INLINE_SIZE_LIMIT || "10000"
);
// Check if TypeScript is setup
const useTypeScript = fs.existsSync(paths.appTsConfig);
// style files regexes
const cssRegex = /\.css$/;
const cssModuleRegex = /\.module\.css$/;
const sassRegex = /\.(scss|sass)$/;
const sassModuleRegex = /\.module\.(scss|sass)$/;
let COLOUR_SET;
if (process.env.REACT_APP_TCHIC_MODE === "ktchi" || process.env.REACT_APP_TCHIC_MODE === "omni") {
COLOUR_SET = `$tachi-primary: #e61c6e;
$tachi-primary-hover: #de6589;
$tachi-background: #131313;
$tachi-lightground: #2b292b;
$tachi-backestground: #000000;
$tachi-overground: #524e52;
$tachi-info: #527acc;
$tachi-info-hover: #8da7dd;`;
} else {
COLOUR_SET = `$tachi-primary: #527acc;
$tachi-primary-hover: #8da7dd;
$tachi-background: #131313;
$tachi-lightground: #2b292b;
$tachi-backestground: #000000;
$tachi-overground: #524e52;
$tachi-info: #31497A;
$tachi-info-hover: #455B87;`;
}
// This is dynamically inserted into the head of _style/base.scss, on the first line.
let scssEntryPoint;
// If you have access to the private metronic scss submodule
// and have cloned it, use that.
if (fs.existsSync(path.join(__dirname, "../_assets/metronic-scss/style.react.scss"))) {
scssEntryPoint = "../_assets/metronic-scss/style.react.scss";
} else {
// Else, use some default compiled css.
scssEntryPoint = "../_assets/compiled-css/main.css";
}
// This is the production and development configuration.
// It is focused on developer experience, fast rebuilds, and a minimal bundle.
module.exports = function (webpackEnv) {
const isEnvDevelopment = webpackEnv === "development";
const isEnvProduction = webpackEnv === "production";
// Variable used for enabling profiling in Production
// passed into alias object. Uses a flag if passed into the build command
const isEnvProductionProfile =
isEnvProduction && process.argv.includes("--profile");
// Webpack uses `publicPath` to determine where the app is being served from.
// It requires a trailing slash, or the file assets will get an incorrect path.
// In development, we always serve from the root. This makes config easier.
const publicPath = isEnvProduction
? paths.servedPath
: isEnvDevelopment && "/";
// Some apps do not use client-side routing with pushState.
// For these, "homepage" can be set to "." to enable relative asset paths.
const shouldUseRelativeAssetPaths = publicPath === "./";
// `publicUrl` is just like `publicPath`, but we will provide it to our app
// as %PUBLIC_URL% in `index.html` and `process.env.PUBLIC_URL` in JavaScript.
// Omit trailing slash as %PUBLIC_URL%/xyz looks better than %PUBLIC_URL%xyz.
const publicUrl = isEnvProduction
? publicPath.slice(0, -1)
: isEnvDevelopment && "";
// Get environment variables to inject into our app.
const env = getClientEnvironment(publicUrl);
// common function to get style loaders
const getStyleLoaders = (cssOptions, preProcessor, options = {}) => {
const loaders = [
isEnvDevelopment && require.resolve("style-loader"),
isEnvProduction && {
loader: MiniCssExtractPlugin.loader,
options: shouldUseRelativeAssetPaths ? { publicPath: "../../" } : {},
},
{
loader: require.resolve("css-loader"),
options: cssOptions,
},
{
// Options for PostCSS as we reference these options twice
// Adds vendor prefixing based on your specified browser support in
// package.json
loader: require.resolve("postcss-loader"),
options: {
// Necessary for external CSS imports to work
// https://github.com/facebook/create-react-app/issues/2677
ident: "postcss",
plugins: () => [
require("postcss-flexbugs-fixes"),
require("postcss-preset-env")({
autoprefixer: {
flexbox: "no-2009",
},
stage: 3,
}),
// Adds PostCSS Normalize as the reset css with default options,
// so that it honors browserslist config in package.json
// which in turn let's users customize the target behavior as per their needs.
postcssNormalize(),
],
sourceMap: shouldUseSourceMap,
},
},
].filter(Boolean);
if (preProcessor) {
loaders.push(
{
loader: require.resolve("resolve-url-loader"),
options: {
sourceMap: shouldUseSourceMap,
},
},
{
loader: require.resolve(preProcessor),
options: {
sourceMap: true,
...options
},
}
);
}
return loaders;
};
return {
mode: isEnvProduction ? "production" : isEnvDevelopment && "development",
// Stop compilation early in production
bail: isEnvProduction,
devtool: isEnvProduction
? shouldUseSourceMap
? "source-map"
: false
: isEnvDevelopment && "cheap-module-source-map",
// These are the "entry points" to our application.
// This means they will be the "root" imports that are included in JS bundle.
entry: [
// Include an alternative client for WebpackDevServer. A client's job is to
// connect to WebpackDevServer by a socket and get notified about changes.
// When you save a file, the client will either apply hot updates (in case
// of CSS changes), or refresh the page (in case of JS changes). When you
// make a syntax error, this client will display a syntax error overlay.
// Note: instead of the default WebpackDevServer client, we use a custom one
// to bring better experience for Create React App users. You can replace
// the line below with these two lines if you prefer the stock client:
// require.resolve('webpack-dev-server/client') + '?/',
// require.resolve('webpack/hot/dev-server'),
isEnvDevelopment &&
require.resolve("react-dev-utils/webpackHotDevClient"),
// Finally, this is your app's code:
paths.appIndexJs,
// We include the app code last so that if there is a runtime error during
// initialization, it doesn't blow up the WebpackDevServer client, and
// changing JS code would still trigger a refresh.
].filter(Boolean),
output: {
// The build folder.
path: isEnvProduction ? paths.appBuild : undefined,
// Add /* filename */ comments to generated require()s in the output.
pathinfo: isEnvDevelopment,
// There will be one main bundle, and one file per asynchronous chunk.
// In development, it does not produce real files.
filename: isEnvProduction
? "static/js/[name].[contenthash:8].js"
: isEnvDevelopment && "static/js/bundle.js",
// TODO: remove this when upgrading to webpack 5
futureEmitAssets: true,
// There are also additional JS chunk files if you use code splitting.
chunkFilename: isEnvProduction
? "static/js/[name].[contenthash:8].chunk.js"
: isEnvDevelopment && "static/js/[name].chunk.js",
// We inferred the "public path" (such as / or /my-project) from homepage.
// We use "/" in development.
publicPath: publicPath,
// Point sourcemap entries to original disk location (format as URL on Windows)
devtoolModuleFilenameTemplate: isEnvProduction
? (info) =>
path
.relative(paths.appSrc, info.absoluteResourcePath)
.replace(/\\/g, "/")
: isEnvDevelopment &&
((info) =>
path.resolve(info.absoluteResourcePath).replace(/\\/g, "/")),
// Prevents conflicts when multiple Webpack runtimes (from different apps)
// are used on the same page.
jsonpFunction: `webpackJsonp${appPackageJson.name}`,
// this defaults to 'window', but by setting it to 'this' then
// module chunks which are built will work in web workers as well.
globalObject: "this",
},
optimization: {
minimize: isEnvProduction,
minimizer: [
// This is only used in production mode
new TerserPlugin({
terserOptions: {
parse: {
// We want terser to parse ecma 8 code. However, we don't want it
// to apply any minification steps that turns valid ecma 5 code
// into invalid ecma 5 code. This is why the 'compress' and 'output'
// sections only apply transformations that are ecma 5 safe
// https://github.com/facebook/create-react-app/pull/4234
ecma: 8,
},
compress: {
ecma: 5,
warnings: false,
// Disabled because of an issue with Uglify breaking seemingly valid code:
// https://github.com/facebook/create-react-app/issues/2376
// Pending further investigation:
// https://github.com/mishoo/UglifyJS2/issues/2011
comparisons: false,
// Disabled because of an issue with Terser breaking valid code:
// https://github.com/facebook/create-react-app/issues/5250
// Pending further investigation:
// https://github.com/terser-js/terser/issues/120
inline: 2,
},
mangle: {
safari10: true,
},
// Added for profiling in devtools
keep_classnames: isEnvProductionProfile,
keep_fnames: isEnvProductionProfile,
output: {
ecma: 5,
comments: false,
// Turned on because emoji and regex is not minified properly using default
// https://github.com/facebook/create-react-app/issues/2488
ascii_only: true,
},
},
// Use multi-process parallel running to improve the build speed
// Default number of concurrent runs: os.cpus().length - 1
// Disabled on WSL (Windows Subsystem for Linux) due to an issue with Terser
// https://github.com/webpack-contrib/terser-webpack-plugin/issues/21
parallel: !isWsl,
// Enable file caching
cache: true,
sourceMap: shouldUseSourceMap,
}),
// This is only used in production mode
new OptimizeCSSAssetsPlugin({
cssProcessorOptions: {
parser: safePostCssParser,
map: shouldUseSourceMap
? {
// `inline: false` forces the sourcemap to be output into a
// separate file
inline: false,
// `annotation: true` appends the sourceMappingURL to the end of
// the css file, helping the browser find the sourcemap
annotation: true,
}
: false,
},
}),
],
// Automatically split vendor and commons
// https://twitter.com/wSokra/status/969633336732905474
// https://medium.com/webpack/webpack-4-code-splitting-chunk-graph-and-the-splitchunks-optimization-be739a861366
splitChunks: {
chunks: "all",
name: false,
},
// Keep the runtime chunk separated to enable long term caching
// https://twitter.com/wSokra/status/969679223278505985
// https://github.com/facebook/create-react-app/issues/5358
runtimeChunk: {
name: (entrypoint) => `runtime-${entrypoint.name}`,
},
},
resolve: {
// This allows you to set a fallback for where Webpack should look for modules.
// We placed these paths second because we want `node_modules` to "win"
// if there are any conflicts. This matches Node resolution mechanism.
// https://github.com/facebook/create-react-app/issues/253
modules: ["node_modules", paths.appNodeModules].concat(
modules.additionalModulePaths || []
),
// These are the reasonable defaults supported by the Node ecosystem.
// We also include JSX as a common component filename extension to support
// some tools, although we do not recommend using it, see:
// https://github.com/facebook/create-react-app/issues/290
// `web` extension prefixes have been added for better support
// for React Native Web.
extensions: paths.moduleFileExtensions
.map((ext) => `.${ext}`)
.filter((ext) => useTypeScript || !ext.includes("ts")),
alias: {
// Support React Native Web
// https://www.smashingmagazine.com/2016/08/a-glimpse-into-the-future-with-react-native-for-web/
"react-native": "react-native-web",
// Allows for better profiling with ReactDevTools
...(isEnvProductionProfile && {
"react-dom$": "react-dom/profiling",
"scheduler/tracing": "scheduler/tracing-profiling",
}),
...(modules.webpackAliases || {}),
},
plugins: [
// Adds support for installing with Plug'n'Play, leading to faster installs and adding
// guards against forgotten dependencies and such.
PnpWebpackPlugin,
// Prevents users from importing files from outside of src/ (or node_modules/).
// This often causes confusion because we only process files within src/ with babel.
// To fix this, we prevent you from importing files out of src/ -- if you'd like to,
// please link the files into your node_modules/ and let module-resolution kick in.
// Make sure your source files are compiled, as they will not be processed in any way.
new ModuleScopePlugin(paths.appSrc, [paths.appPackageJson]),
],
},
resolveLoader: {
plugins: [
// Also related to Plug'n'Play, but this time it tells Webpack to load its loaders
// from the current package.
PnpWebpackPlugin.moduleLoader(module),
],
},
module: {
strictExportPresence: true,
rules: [
// Disable require.ensure as it's not a standard language feature.
{ parser: { requireEnsure: false } },
// First, run the linter.
// It's important to do this before Babel processes the JS.
// {
// // WARN: disabled js here - should be re-enabled at some point.
// test: /\.(jsx|ts|tsx)$/,
// enforce: "pre",
// use: [
// {
// options: {
// cache: true,
// formatter: require.resolve("react-dev-utils/eslintFormatter"),
// eslintPath: require.resolve("eslint"),
// resolvePluginsRelativeTo: __dirname,
// },
// loader: require.resolve("eslint-loader"),
// },
// ],
// include: paths.appSrc,
// },
{
// "oneOf" will traverse all following loaders until one will
// match the requirements. When no loader matches it will fall
// back to the "file" loader at the end of the loader list.
oneOf: [
// "url" loader works like "file" loader except that it embeds assets
// smaller than specified limit in bytes as data URLs to avoid requests.
// A missing `test` is equivalent to a match.
{
test: [/\.bmp$/, /\.gif$/, /\.jpe?g$/, /\.png$/],
loader: require.resolve("url-loader"),
options: {
limit: imageInlineSizeLimit,
name: "static/media/[name].[hash:8].[ext]",
},
},
// Process application JS with Babel.
// The preset includes JSX, Flow, TypeScript, and some ESnext features.
{
test: /\.(js|mjs|jsx|ts|tsx)$/,
include: paths.appSrc,
loader: require.resolve("esbuild-loader"),
options: {
sourcemap: false,
loader: "tsx"
},
},
// Process any JS outside of the app with Babel.
// Unlike the application JS, we only compile the standard ES features.
{
test: /\.(js|mjs)$/,
exclude: /@babel(?:\/|\\{1,2})runtime/,
loader: require.resolve("babel-loader"),
options: {
babelrc: false,
configFile: false,
compact: false,
presets: [
[
require.resolve("babel-preset-react-app/dependencies"),
{ helpers: true },
],
],
cacheDirectory: true,
// See #6846 for context on why cacheCompression is disabled
cacheCompression: false,
// If an error happens in a package, it's possible to be
// because it was compiled. Thus, we don't want the browser
// debugger to show the original code. Instead, the code
// being evaluated would be much more helpful.
sourceMaps: false,
},
},
// "postcss" loader applies autoprefixer to our CSS.
// "css" loader resolves paths in CSS and adds assets as dependencies.
// "style" loader turns CSS into JS modules that inject <style> tags.
// In production, we use MiniCSSExtractPlugin to extract that CSS
// to a file, but in development "style" loader enables hot editing
// of CSS.
// By default we support CSS Modules with the extension .module.css
{
test: cssRegex,
exclude: cssModuleRegex,
use: getStyleLoaders({
importLoaders: 1,
sourceMap: shouldUseSourceMap,
}),
// Don't consider CSS imports dead code even if the
// containing package claims to have no side effects.
// Remove this when webpack adds a warning or an error for this.
// See https://github.com/webpack/webpack/issues/6571
sideEffects: true,
},
// Adds support for CSS Modules (https://github.com/css-modules/css-modules)
// using the extension .module.css
{
test: cssModuleRegex,
use: getStyleLoaders({
importLoaders: 1,
sourceMap: shouldUseSourceMap,
modules: true,
getLocalIdent: getCSSModuleLocalIdent,
}),
},
// Opt-in support for SASS (using .scss or .sass extensions).
// By default we support SASS Modules with the
// extensions .module.scss or .module.sass
{
test: sassRegex,
exclude: sassModuleRegex,
use: getStyleLoaders(
{
importLoaders: 2,
sourceMap: shouldUseSourceMap,
},
"sass-loader",
{
data: `${COLOUR_SET}\n@import "${scssEntryPoint}";`,
}
),
// Don't consider CSS imports dead code even if the
// containing package claims to have no side effects.
// Remove this when webpack adds a warning or an error for this.
// See https://github.com/webpack/webpack/issues/6571
sideEffects: true,
},
// Adds support for CSS Modules, but using SASS
// using the extension .module.scss or .module.sass
{
test: sassModuleRegex,
use: getStyleLoaders(
{
importLoaders: 2,
sourceMap: shouldUseSourceMap,
modules: true,
getLocalIdent: getCSSModuleLocalIdent,
},
"sass-loader"
),
},
// "file" loader makes sure those assets get served by WebpackDevServer.
// When you `import` an asset, you get its (virtual) filename.
// In production, they would get copied to the `build` folder.
// This loader doesn't use a "test" so it will catch all modules
// that fall through the other loaders.
{
loader: require.resolve("file-loader"),
// Exclude `js` files to keep "css" loader working as it injects
// its runtime that would otherwise be processed through "file" loader.
// Also exclude `html` and `json` extensions so they get processed
// by webpacks internal loaders.
exclude: [/\.(js|mjs|jsx|ts|tsx)$/, /\.html$/, /\.json$/],
options: {
name: "static/media/[name].[hash:8].[ext]",
},
},
// ** STOP ** Are you adding a new loader?
// Make sure to add the new loader(s) before the "file" loader.
],
},
],
},
plugins: [
// Generates an `index.html` file with the <script> injected.
new HtmlWebpackPlugin(
Object.assign(
{},
{
inject: true,
template: paths.appHtml,
},
isEnvProduction
? {
minify: {
removeComments: true,
collapseWhitespace: true,
removeRedundantAttributes: true,
useShortDoctype: true,
removeEmptyAttributes: true,
removeStyleLinkTypeAttributes: true,
keepClosingSlash: true,
minifyJS: true,
minifyCSS: true,
minifyURLs: true,
},
}
: undefined
)
),
// Inlines the webpack runtime script. This script is too small to warrant
// a network request.
// https://github.com/facebook/create-react-app/issues/5358
isEnvProduction &&
shouldInlineRuntimeChunk &&
new InlineChunkHtmlPlugin(HtmlWebpackPlugin, [/runtime-.+[.]js/]),
// Makes some environment variables available in index.html.
// The public URL is available as %PUBLIC_URL% in index.html, e.g.:
// <link rel="icon" href="%PUBLIC_URL%/favicon.ico">
// In production, it will be an empty string unless you specify "homepage"
// in `package.json`, in which case it will be the pathname of that URL.
// In development, this will be an empty string.
new InterpolateHtmlPlugin(HtmlWebpackPlugin, env.raw),
// This gives some necessary context to module not found errors, such as
// the requesting resource.
new ModuleNotFoundPlugin(paths.appPath),
// Makes some environment variables available to the JS code, for example:
// if (process.env.NODE_ENV === 'production') { ... }. See `./env.js`.
// It is absolutely essential that NODE_ENV is set to production
// during a production build.
// Otherwise React will be compiled in the very slow development mode.
new webpack.DefinePlugin(env.stringified),
// This is necessary to emit hot updates (currently CSS only):
isEnvDevelopment && new webpack.HotModuleReplacementPlugin(),
// Watcher doesn't work well if you mistype casing in a path so we use
// a plugin that prints an error when you attempt to do this.
// See https://github.com/facebook/create-react-app/issues/240
isEnvDevelopment && new CaseSensitivePathsPlugin(),
// If you require a missing module and then `npm install` it, you still have
// to restart the development server for Webpack to discover it. This plugin
// makes the discovery automatic so you don't have to restart.
// See https://github.com/facebook/create-react-app/issues/186
isEnvDevelopment &&
new WatchMissingNodeModulesPlugin(paths.appNodeModules),
isEnvProduction &&
new MiniCssExtractPlugin({
// Options similar to the same options in webpackOptions.output
// both options are optional
filename: "static/css/[name].[contenthash:8].css",
chunkFilename: "static/css/[name].[contenthash:8].chunk.css",
}),
// Moment.js is an extremely popular library that bundles large locale files
// by default due to how Webpack interprets its code. This is a practical
// solution that requires the user to opt into importing specific locales.
// https://github.com/jmblog/how-to-optimize-momentjs-with-webpack
// You can remove this if you don't use Moment.js:
new webpack.IgnorePlugin(/^\.\/locale$/, /moment$/),
// TypeScript type checking
useTypeScript &&
new ForkTsCheckerWebpackPlugin({
typescript: resolve.sync("typescript", {
basedir: paths.appNodeModules,
}),
async: isEnvDevelopment,
useTypescriptIncrementalApi: true,
checkSyntacticErrors: true,
resolveModuleNameModule: process.versions.pnp
? `${__dirname}/pnpTs.js`
: undefined,
resolveTypeReferenceDirectiveModule: process.versions.pnp
? `${__dirname}/pnpTs.js`
: undefined,
tsconfig: paths.appTsConfig,
reportFiles: [
"**",
"!**/__tests__/**",
"!**/?(*.)(spec|test).*",
"!**/src/setupProxy.*",
"!**/src/setupTests.*",
],
silent: true,
// The formatter is invoked directly in WebpackDevServerUtils during development
formatter: isEnvProduction ? typescriptFormatter : undefined,
}),
].filter(Boolean),
// Some libraries import Node modules but don't use them in the browser.
// Tell Webpack to provide empty mocks for them so importing them works.
node: {
module: "empty",
dgram: "empty",
dns: "mock",
fs: "empty",
http2: "empty",
net: "empty",
tls: "empty",
child_process: "empty",
},
// Turn off performance processing because we utilize
// our own hints via the FileSizeReporter
performance: false,
};
};
+104
View File
@@ -0,0 +1,104 @@
const errorOverlayMiddleware = require('react-dev-utils/errorOverlayMiddleware');
const evalSourceMapMiddleware = require('react-dev-utils/evalSourceMapMiddleware');
const noopServiceWorkerMiddleware = require('react-dev-utils/noopServiceWorkerMiddleware');
const ignoredFiles = require('react-dev-utils/ignoredFiles');
const paths = require('./paths');
const fs = require('fs');
const protocol = process.env.HTTPS === 'true' ? 'https' : 'http';
const host = process.env.HOST || '0.0.0.0';
module.exports = function(proxy, allowedHost) {
return {
// WebpackDevServer 2.4.3 introduced a security fix that prevents remote
// websites from potentially accessing local content through DNS rebinding:
// https://github.com/webpack/webpack-dev-server/issues/887
// https://medium.com/webpack/webpack-dev-server-middleware-security-issues-1489d950874a
// However, it made several existing use cases such as development in cloud
// environment or subdomains in development significantly more complicated:
// https://github.com/facebook/create-react-app/issues/2271
// https://github.com/facebook/create-react-app/issues/2233
// While we're investigating better solutions, for now we will take a
// compromise. Since our WDS configuration only serves files in the `public`
// folder we won't consider accessing them a vulnerability. However, if you
// use the `proxy` feature, it gets more dangerous because it can expose
// remote code execution vulnerabilities in backends like Django and Rails.
// So we will disable the host check normally, but enable it if you have
// specified the `proxy` setting. Finally, we let you override it if you
// really know what you're doing with a special environment variable.
disableHostCheck:
!proxy || process.env.DANGEROUSLY_DISABLE_HOST_CHECK === 'true',
// Enable gzip compression of generated files.
compress: true,
// Silence WebpackDevServer's own logs since they're generally not useful.
// It will still show compile warnings and errors with this setting.
clientLogLevel: 'none',
// By default WebpackDevServer serves physical files from current directory
// in addition to all the virtual build products that it serves from memory.
// This is confusing because those files won’t automatically be available in
// production build folder unless we copy them. However, copying the whole
// project directory is dangerous because we may expose sensitive files.
// Instead, we establish a convention that only files in `public` directory
// get served. Our build script will copy `public` into the `build` folder.
// In `index.html`, you can get URL of `public` folder with %PUBLIC_URL%:
// <link rel="icon" href="%PUBLIC_URL%/favicon.ico">
// In JavaScript code, you can access it with `process.env.PUBLIC_URL`.
// Note that we only recommend to use `public` folder as an escape hatch
// for files like `favicon.ico`, `manifest.json`, and libraries that are
// for some reason broken when imported through Webpack. If you just want to
// use an image, put it in `src` and `import` it from JavaScript instead.
contentBase: paths.appPublic,
// By default files from `contentBase` will not trigger a page reload.
watchContentBase: true,
// Enable hot reloading server. It will provide /sockjs-node/ endpoint
// for the WebpackDevServer client so it can learn when the files were
// updated. The WebpackDevServer client is included as an entry point
// in the Webpack development configuration. Note that only changes
// to CSS are currently hot reloaded. JS changes will refresh the browser.
hot: true,
// It is important to tell WebpackDevServer to use the same "root" path
// as we specified in the config. In development, we always serve from /.
publicPath: '/',
// WebpackDevServer is noisy by default so we emit custom message instead
// by listening to the compiler events with `compiler.hooks[...].tap` calls above.
quiet: true,
// Reportedly, this avoids CPU overload on some systems.
// https://github.com/facebook/create-react-app/issues/293
// src/node_modules is not ignored to support absolute imports
// https://github.com/facebook/create-react-app/issues/1065
watchOptions: {
ignored: ignoredFiles(paths.appSrc),
},
// Enable HTTPS if the HTTPS environment variable is set to 'true'
https: protocol === 'https',
host,
overlay: false,
historyApiFallback: {
// Paths with dots should still use the history fallback.
// See https://github.com/facebook/create-react-app/issues/387.
disableDotRule: true,
},
public: allowedHost,
proxy,
before(app, server) {
if (fs.existsSync(paths.proxySetup)) {
// This registers user provided middleware for proxy reasons
require(paths.proxySetup)(app);
}
// This lets us fetch source contents from webpack for the error overlay
app.use(evalSourceMapMiddleware(server));
// This lets us open files from the runtime error overlay.
app.use(errorOverlayMiddleware());
// This service worker file is effectively a 'no-op' that will reset any
// previous service worker registered for the same host:port combination.
// We do this in development to avoid hitting the production cache if
// it used the same host and port.
// https://github.com/facebook/create-react-app/issues/2272#issuecomment-302832432
app.use(noopServiceWorkerMiddleware());
},
};
};
+187
View File
@@ -0,0 +1,187 @@
{
"name": "tachi-client",
"version": "2.1.7",
"private": true,
"license": "AGPL3",
"scripts": {
"start": "node scripts/start.js -o",
"build": "node scripts/build.js",
"test": "node scripts/test.js",
"lint": "eslint src --fix"
},
"browserslist": {
"production": [
">0.2%",
"not dead",
"not op_mini all",
"ie 11"
],
"development": [
"last 1 chrome version",
"last 1 firefox version",
"last 1 safari version",
"last 1 edge version",
"IE 11"
]
},
"dependencies": {
"@babel/core": "7.1.0",
"@fortawesome/fontawesome-free": "5.13.0",
"@material-ui/core": "4.9.14",
"@material-ui/icons": "4.9.1",
"@material-ui/lab": "4.0.0-alpha.53",
"@material-ui/pickers": "3.2.10",
"@material-ui/styles": "4.9.14",
"@nivo/bar": "0.79.1",
"@nivo/core": "0.79.0",
"@nivo/line": "0.79.1",
"@svgr/webpack": "4.3.2",
"@tanem/svg-injector": "8.0.50",
"@types/aes-js": "3.1.1",
"@types/react-google-recaptcha": "2.1.2",
"apexcharts": "3.19.2",
"babel-loader": "8.0.6",
"babel-plugin-named-asset-import": "0.3.7",
"babel-preset-react-app": "9.1.2",
"bootstrap": "4.6.0",
"case-sensitive-paths-webpack-plugin": "2.2.0",
"crypto-js": "4.1.1",
"css-loader": "2.1.1",
"deepmerge": "4.2.2",
"dotenv": "6.2.0",
"dotenv-expand": "5.1.0",
"eslint-plugin-jsx-a11y": "6.3.1",
"file-loader": "3.0.1",
"formik": "2.2.9",
"fs-extra": "7.0.1",
"hash.js": "1.1.7",
"html-webpack-plugin": "4.5.2",
"humanize-duration": "3.27.0",
"json-2-csv": "3.14.4",
"luxon": "2.0.1",
"mini-css-extract-plugin": "0.8.0",
"nanoid": "3.1.23",
"optimize-css-assets-webpack-plugin": "5.0.3",
"perfect-scrollbar": "1.5.0",
"pnp-webpack-plugin": "1.5.0",
"postcss-flexbugs-fixes": "4.1.0",
"postcss-loader": "3.0.0",
"postcss-normalize": "7.0.1",
"postcss-preset-env": "6.7.0",
"postcss-safe-parser": "4.0.1",
"pretty-bytes": "5.6.0",
"prop-types": "15.6.0",
"react": "17.0.2",
"react-async": "10.0.1",
"react-bootstrap": "1.6.1",
"react-dom": "16.12.0",
"react-google-recaptcha": "2.1.0",
"react-hot-toast": "2.0.0",
"react-markdown": "7.0.1",
"react-perfect-scrollbar": "1.5.8",
"react-query": "3.18.1",
"react-router": "5.2.0",
"react-router-dom": "5.1.2",
"resolve": "1.12.0",
"resolve-url-loader": "3.1.0",
"rg-stats": "^0.2.0",
"sync-fetch": "^0.3.1",
"tachi-common": "0.15.2"
},
"devDependencies": {
"@types/humanize-duration": "3.25.1",
"@types/luxon": "1.27.1",
"@types/node": "16.0.0",
"@types/react": "17.0.13",
"@types/react-dom": "17.0.8",
"@types/react-router": "5.1.15",
"@types/react-router-dom": "5.1.7",
"@types/react-table": "7.7.2",
"@typescript-eslint/eslint-plugin": "4.28.3",
"@typescript-eslint/parser": "4.28.3",
"copyfiles": "2.1.1",
"esbuild-loader": "2.13.1",
"eslint": "7.30.0",
"eslint-config-prettier": "8.3.0",
"eslint-plugin-import": "2.23.4",
"eslint-plugin-prettier": "3.4.0",
"eslint-plugin-react": "7.14.3",
"eslint-plugin-react-hooks": "1.7.0",
"is-wsl": "1.1.0",
"prettier": "1.19.1",
"react-dev-utils": "9.1.0",
"sass": "1.32.8",
"sass-loader": "7.2.0",
"serve": "11.2.0",
"style-loader": "1.0.0",
"terser-webpack-plugin": "1.4.1",
"ts-pnp": "1.1.4",
"typescript": "4.3.5",
"url-loader": "2.1.0",
"webpack": "4.41.0",
"webpack-cli": "3.3.11",
"webpack-dev-server": "3.2.1",
"webpack-manifest-plugin": "2.1.1",
"webpack-messages": "2.0.4",
"webpack-rtl-plugin": "2.0.0",
"workbox-webpack-plugin": "4.3.1"
},
"jest": {
"roots": [
"<rootDir>/src"
],
"collectCoverageFrom": [
"src/**/*.{js,jsx,ts,tsx}",
"!src/**/*.d.ts"
],
"setupFiles": [
"react-app-polyfill/jsdom"
],
"setupFilesAfterEnv": [],
"testMatch": [
"<rootDir>/src/**/__tests__/**/*.{js,jsx,ts,tsx}",
"<rootDir>/src/**/*.{spec,test}.{js,jsx,ts,tsx}"
],
"testEnvironment": "jest-environment-jsdom-fourteen",
"transform": {
".+\\.(js|jsx|ts|tsx)$": "<rootDir>/node_modules/babel-jest",
".+\\.css$": "<rootDir>/config/jest/cssTransform.js",
"(?!.*\\.(js|jsx|ts|tsx|css|json)$)": "<rootDir>/config/jest/fileTransform.js"
},
"transformIgnorePatterns": [
"[/\\\\]node_modules[/\\\\].+\\.(js|jsx|ts|tsx)$",
".+\\.module\\.(css|sass|scss)$"
],
"modulePaths": [
"/home/zkldi/repos/projects/Tachi/tachi-client/src"
],
"moduleNameMapper": {
"react-native$": "react-native-web",
".+\\.module\\.(css|sass|scss)$": "identity-obj-proxy"
},
"moduleFileExtensions": [
"web.js",
"js",
"web.ts",
"ts",
"web.tsx",
"tsx",
"json",
"web.jsx",
"jsx",
"node"
],
"watchPlugins": [
"jest-watch-typeahead/filename",
"jest-watch-typeahead/testname"
]
},
"babel": {
"presets": [
"react-app"
]
},
"eslintConfig": {
"extends": "react-app"
}
}
+12382
View File
File diff suppressed because it is too large Load Diff
+33
View File
@@ -0,0 +1,33 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>Tachi</title>
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="icon" type="image/x-icon" href="%REACT_APP_CDN_URL%/logos/favicon.ico" />
<link href="//fonts.googleapis.com/css?family=Poppins:300,400,500,600,700%7CRoboto:300,400,500,600,700"
rel="stylesheet" />
<link href="https://fonts.googleapis.com/icon?family=Material+Icons" rel="stylesheet" />
<link rel="stylesheet" href="https://fonts.googleapis.com/css?family=Roboto:300,400,500" />
<script crossorigin="anonymous"
src="https://polyfill.io/v3/polyfill.min.js?features=es2017%2Cdefault%2Ces2015%2Ces2016%2CIntl"></script>
</head>
<body id="kt_body">
<noscript>
You have JavaScript disabled, probably.<br />
I also use a JavaScript blocker!<br />
This site uses React to render its contents.<br />
We don't use JS to track you or try to do anything malicious. You'll have
to enable JS in order for this site to work.
</noscript>
<div id="root"></div>
<div id="layout-portal"></div>
</body>
</html>
+1
View File
@@ -0,0 +1 @@
# We have no special rules. This is probably OK!
+201
View File
@@ -0,0 +1,201 @@
'use strict';
// Do this as the first thing so that any code reading it knows the right env.
process.env.BABEL_ENV = 'production';
process.env.NODE_ENV = 'production';
// Makes the script crash on unhandled rejections instead of silently
// ignoring them. In the future, promise rejections that are not handled will
// terminate the Node.js process with a non-zero exit code.
process.on('unhandledRejection', err => {
throw err;
});
// Ensure environment variables are read.
require('../config/env');
const path = require('path');
const chalk = require('react-dev-utils/chalk');
const fs = require('fs-extra');
const webpack = require('webpack');
const configFactory = require('../config/webpack.config');
const paths = require('../config/paths');
const checkRequiredFiles = require('react-dev-utils/checkRequiredFiles');
const formatWebpackMessages = require('react-dev-utils/formatWebpackMessages');
const printHostingInstructions = require('react-dev-utils/printHostingInstructions');
const FileSizeReporter = require('react-dev-utils/FileSizeReporter');
const printBuildError = require('react-dev-utils/printBuildError');
const measureFileSizesBeforeBuild =
FileSizeReporter.measureFileSizesBeforeBuild;
const printFileSizesAfterBuild = FileSizeReporter.printFileSizesAfterBuild;
const useYarn = fs.existsSync(paths.yarnLockFile);
// These sizes are pretty large. We'll warn for bundles exceeding them.
const WARN_AFTER_BUNDLE_GZIP_SIZE = 512 * 1024;
const WARN_AFTER_CHUNK_GZIP_SIZE = 1024 * 1024;
const isInteractive = process.stdout.isTTY;
// Warn and crash if required files are missing
if (!checkRequiredFiles([paths.appHtml, paths.appIndexJs])) {
process.exit(1);
}
// Generate configuration
const config = configFactory('production');
// We require that you explicitly set browsers and do not fall back to
// browserslist defaults.
const { checkBrowsers } = require('react-dev-utils/browsersHelper');
checkBrowsers(paths.appPath, isInteractive)
.then(() => {
// First, read the current file sizes in build directory.
// This lets us display how much they changed later.
return measureFileSizesBeforeBuild(paths.appBuild);
})
.then(previousFileSizes => {
// Remove all content but keep the directory so that
// if you're in it, you don't end up in Trash
fs.emptyDirSync(paths.appBuild);
// Merge with the public folder
copyPublicFolder();
// Start the webpack build
return build(previousFileSizes);
})
.then(
({ stats, previousFileSizes, warnings }) => {
if (warnings.length) {
console.log(chalk.yellow('Compiled with warnings.\n'));
console.log(warnings.join('\n\n'));
console.log(
'\nSearch for the ' +
chalk.underline(chalk.yellow('keywords')) +
' to learn more about each warning.'
);
console.log(
'To ignore, add ' +
chalk.cyan('// eslint-disable-next-line') +
' to the line before.\n'
);
} else {
console.log(chalk.green('Compiled successfully.\n'));
}
// Force build script to shut up since ereti doesn't like it
// console.log('File sizes after gzip:\n');
// printFileSizesAfterBuild(
// stats,
// previousFileSizes,
// paths.appBuild,
// WARN_AFTER_BUNDLE_GZIP_SIZE,
// WARN_AFTER_CHUNK_GZIP_SIZE
// );
// console.log();
// const appPackage = require(paths.appPackageJson);
// const publicUrl = paths.publicUrl;
// const publicPath = config.output.publicPath;
// const buildFolder = path.relative(process.cwd(), paths.appBuild);
// printHostingInstructions(
// appPackage,
// publicUrl,
// publicPath,
// buildFolder,
// useYarn
// );
},
err => {
const tscCompileOnError = process.env.TSC_COMPILE_ON_ERROR === 'true';
if (tscCompileOnError) {
console.log(chalk.yellow(
'Compiled with the following type errors (you may want to check these before deploying your app):\n'
));
printBuildError(err);
} else {
console.log(chalk.red('Failed to compile.\n'));
printBuildError(err);
process.exit(1);
}
}
)
.catch(err => {
if (err && err.message) {
console.log(err.message);
}
process.exit(1);
});
// Create the production build and print the deployment instructions.
function build(previousFileSizes) {
// We used to support resolving modules according to `NODE_PATH`.
// This now has been deprecated in favor of jsconfig/tsconfig.json
// This lets you use absolute paths in imports inside large monorepos:
if (process.env.NODE_PATH) {
console.log(
chalk.yellow(
'Setting NODE_PATH to resolve modules absolutely has been deprecated in favor of setting baseUrl in jsconfig.json (or tsconfig.json if you are using TypeScript) and will be removed in a future major release of create-react-app.'
)
);
console.log();
}
console.log('Creating an optimized production build...');
const compiler = webpack(config);
return new Promise((resolve, reject) => {
compiler.run((err, stats) => {
let messages;
if (err) {
if (!err.message) {
return reject(err);
}
messages = formatWebpackMessages({
errors: [err.message],
warnings: [],
});
} else {
messages = formatWebpackMessages(
stats.toJson({ all: false, warnings: true, errors: true })
);
}
if (messages.errors.length) {
// Only keep the first error. Others are often indicative
// of the same problem, but confuse the reader with noise.
if (messages.errors.length > 1) {
messages.errors.length = 1;
}
return reject(new Error(messages.errors.join('\n\n')));
}
if (
process.env.CI &&
(typeof process.env.CI !== 'string' ||
process.env.CI.toLowerCase() !== 'false') &&
messages.warnings.length
) {
console.log(
chalk.yellow(
'\nTreating warnings as errors because process.env.CI = true.\n' +
'Most CI servers set it automatically.\n'
)
);
return reject(new Error(messages.warnings.join('\n\n')));
}
return resolve({
stats,
previousFileSizes,
warnings: messages.warnings,
});
});
});
}
function copyPublicFolder() {
fs.copySync(paths.appPublic, paths.appBuild, {
dereference: true,
filter: file => file !== paths.appHtml,
});
}
+145
View File
@@ -0,0 +1,145 @@
'use strict';
// Do this as the first thing so that any code reading it knows the right env.
process.env.BABEL_ENV = 'development';
process.env.NODE_ENV = 'development';
// Makes the script crash on unhandled rejections instead of silently
// ignoring them. In the future, promise rejections that are not handled will
// terminate the Node.js process with a non-zero exit code.
process.on('unhandledRejection', err => {
throw err;
});
// Ensure environment variables are read.
require('../config/env');
const fs = require('fs');
const chalk = require('react-dev-utils/chalk');
const webpack = require('webpack');
const WebpackDevServer = require('webpack-dev-server');
const clearConsole = require('react-dev-utils/clearConsole');
const checkRequiredFiles = require('react-dev-utils/checkRequiredFiles');
const {
choosePort,
createCompiler,
prepareProxy,
prepareUrls,
} = require('react-dev-utils/WebpackDevServerUtils');
const paths = require('../config/paths');
const configFactory = require('../config/webpack.config');
const createDevServerConfig = require('../config/webpackDevServer.config');
const useYarn = fs.existsSync(paths.yarnLockFile);
const isInteractive = process.stdout.isTTY;
// Warn and crash if required files are missing
if (!checkRequiredFiles([paths.appHtml, paths.appIndexJs])) {
process.exit(1);
}
// Tools like Cloud9 rely on this.
const DEFAULT_PORT = parseInt(process.env.PORT, 10) || 3000;
const HOST = process.env.HOST || '0.0.0.0';
if (process.env.HOST) {
console.log(
chalk.cyan(
`Attempting to bind to HOST environment variable: ${chalk.yellow(
chalk.bold(process.env.HOST)
)}`
)
);
console.log(
`If this was unintentional, check that you haven't mistakenly set it in your shell.`
);
console.log(
`Learn more here: ${chalk.yellow('https://bit.ly/CRA-advanced-config')}`
);
console.log();
}
// We require that you explicitly set browsers and do not fall back to
// browserslist defaults.
const { checkBrowsers } = require('react-dev-utils/browsersHelper');
checkBrowsers(paths.appPath, isInteractive)
.then(() => {
// We attempt to use the default port but if it is busy, we offer the user to
// run on a different port. `choosePort()` Promise resolves to the next free port.
return choosePort(HOST, DEFAULT_PORT);
})
.then(port => {
if (port == null) {
// We have not found a port.
return;
}
const config = configFactory('development');
const protocol = process.env.HTTPS === 'true' ? 'https' : 'http';
const appName = require(paths.appPackageJson).name;
const useTypeScript = fs.existsSync(paths.appTsConfig);
const tscCompileOnError = process.env.TSC_COMPILE_ON_ERROR === 'true';
const urls = prepareUrls(protocol, HOST, port);
const devSocket = {
warnings: warnings =>
devServer.sockWrite(devServer.sockets, 'warnings', warnings),
errors: errors =>
devServer.sockWrite(devServer.sockets, 'errors', errors),
};
// Create a webpack compiler that is configured with custom messages.
const compiler = createCompiler({
appName,
config,
devSocket,
urls,
useYarn,
useTypeScript,
tscCompileOnError,
webpack,
});
// Load proxy config
const proxySetting = require(paths.appPackageJson).proxy;
const proxyConfig = prepareProxy(proxySetting, paths.appPublic);
// Serve webpack assets generated by the compiler over a web server.
const serverConfig = createDevServerConfig(
proxyConfig,
urls.lanUrlForConfig
);
const devServer = new WebpackDevServer(compiler, serverConfig);
// Launch WebpackDevServer.
devServer.listen(port, HOST, err => {
if (err) {
return console.log(err);
}
if (isInteractive) {
clearConsole();
}
// We used to support resolving modules according to `NODE_PATH`.
// This now has been deprecated in favor of jsconfig/tsconfig.json
// This lets you use absolute paths in imports inside large monorepos:
if (process.env.NODE_PATH) {
console.log(
chalk.yellow(
'Setting NODE_PATH to resolve modules absolutely has been deprecated in favor of setting baseUrl in jsconfig.json (or tsconfig.json if you are using TypeScript) and will be removed in a future major release of create-react-app.'
)
);
console.log();
}
console.log(chalk.cyan('Starting the development server...\n'));
});
['SIGINT', 'SIGTERM'].forEach(function(sig) {
process.on(sig, function() {
devServer.close();
process.exit();
});
});
})
.catch(err => {
if (err && err.message) {
console.log(err.message);
}
process.exit(1);
});
+53
View File
@@ -0,0 +1,53 @@
'use strict';
// Do this as the first thing so that any code reading it knows the right env.
process.env.BABEL_ENV = 'test';
process.env.NODE_ENV = 'test';
process.env.PUBLIC_URL = '';
// Makes the script crash on unhandled rejections instead of silently
// ignoring them. In the future, promise rejections that are not handled will
// terminate the Node.js process with a non-zero exit code.
process.on('unhandledRejection', err => {
throw err;
});
// Ensure environment variables are read.
require('../config/env');
const jest = require('jest');
const execSync = require('child_process').execSync;
let argv = process.argv.slice(2);
function isInGitRepository() {
try {
execSync('git rev-parse --is-inside-work-tree', { stdio: 'ignore' });
return true;
} catch (e) {
return false;
}
}
function isInMercurialRepository() {
try {
execSync('hg --cwd . root', { stdio: 'ignore' });
return true;
} catch (e) {
return false;
}
}
// Watch unless on CI or explicitly running all tests
if (
!process.env.CI &&
argv.indexOf('--watchAll') === -1 &&
argv.indexOf('--watchAll=false') === -1
) {
// https://github.com/facebook/create-react-app/issues/5210
const hasSourceControl = isInGitRepository() || isInMercurialRepository();
argv.push(hasSourceControl ? '--watch' : '--watchAll');
}
jest.run(argv);
+114
View File
@@ -0,0 +1,114 @@
{
"root": true,
"env": {
"browser": true,
"es6": true
},
"globals": {
"process": "readonly"
},
"plugins": [
"@typescript-eslint",
"import"
],
"ignorePatterns": [
"**/*.js",
"react-app-env.d.ts"
],
"parser": "@typescript-eslint/parser",
"parserOptions": {
"sourceType": "module",
"ecmaVersion": 2020
},
"extends": [
"eslint:recommended",
"plugin:react/recommended",
"plugin:@typescript-eslint/recommended",
"plugin:prettier/recommended",
"plugin:import/typescript",
"plugin:import/warnings",
"plugin:import/errors"
],
"rules": {
"prettier/prettier": [
"error",
{
"printWidth": 100,
"tabWidth": 4,
"semi": true,
"singleQuote": false,
"trailingComma": "es5",
"bracketSpacing": true,
"endOfLine": "auto",
"useTabs": true
}
],
"@typescript-eslint/explicit-module-boundary-types": "off",
"@typescript-eslint/no-non-null-assertion": "off",
"@typescript-eslint/no-unused-vars": [
"warn",
{
"argsIgnorePattern": "logger"
}
],
"@typescript-eslint/explicit-function-return-type": "off",
"react/no-unescaped-entities": [
"error",
{
"forbid": [
">",
"}"
]
}
],
"react/display-name": "off",
"import/order": "error",
"import/no-unresolved": "off",
"import/no-named-as-default": "off",
"react/prop-types": "off",
"require-await": "warn",
"no-await-in-loop": "warn",
"no-unused-labels": "error",
"prefer-const": "error",
"eqeqeq": "error",
"no-eval": "error",
"no-var": "error",
"no-new-object": "error",
"quote-props": [
"error",
"as-needed"
],
"no-array-constructor": "error",
"prefer-template": "error",
"prefer-promise-reject-errors": "error",
"no-useless-escape": "error",
"no-loop-func": "error",
"prefer-rest-params": "error",
"default-param-last": "error",
"no-new-func": "error",
"arrow-body-style": "error",
"no-useless-constructor": "error",
"no-duplicate-imports": "error",
"dot-notation": "error",
"space-before-blocks": "error",
"no-prototype-builtins": "off",
"prefer-arrow-callback": "error",
"curly": "error",
"no-template-curly-in-string": "error",
"no-implied-eval": "error",
"yoda": "error",
"no-labels": "error",
"no-useless-concat": "error",
"radix": "error",
"no-octal-escape": "error",
"no-param-reassign": "error",
"no-proto": "error",
"no-return-await": "error",
"no-throw-literal": "error",
"no-unmodified-loop-condition": "error",
"no-unused-expressions": "error",
"no-useless-call": "error",
"no-useless-return": "error",
"require-unicode-regexp": "error"
}
}
File diff suppressed because one or more lines are too long
+269
View File
@@ -0,0 +1,269 @@
/*
Flaticon icon font: Flaticon
Creation date: 20/03/2017 20:02
*/
@font-face {
font-family: "Flaticon";
src: url("./font/Flaticon.eot");
src: url("./font/Flaticon.eot?#iefix") format("embedded-opentype"),
url("./font/Flaticon.woff") format("woff"),
url("./font/Flaticon.ttf") format("truetype"),
url("./font/Flaticon.svg#Flaticon") format("svg");
font-weight: normal;
font-style: normal;
}
[class^="flaticon-"]:before,
[class*=" flaticon-"]:before {
font-family: Flaticon;
font-style: normal;
font-weight: normal;
font-variant: normal;
line-height: 1;
text-decoration: inherit;
text-rendering: optimizeLegibility;
text-transform: none;
-moz-osx-font-smoothing: grayscale;
-webkit-font-smoothing: antialiased;
font-smoothing: antialiased;
}
.flaticon-email-black-circular-button:before { content: "\f100"; }
.flaticon-map:before { content: "\f101"; }
.flaticon-alert-off:before { content: "\f102"; }
.flaticon-alert:before { content: "\f103"; }
.flaticon-computer:before { content: "\f104"; }
.flaticon-responsive:before { content: "\f105"; }
.flaticon-presentation:before { content: "\f106"; }
.flaticon-arrows:before { content: "\f107"; }
.flaticon-rocket:before { content: "\f108"; }
.flaticon-reply:before { content: "\f109"; }
.flaticon-gift:before { content: "\f10a"; }
.flaticon-confetti:before { content: "\f10b"; }
.flaticon-piggy-bank:before { content: "\f10c"; }
.flaticon-support:before { content: "\f10d"; }
.flaticon-delete:before { content: "\f10e"; }
.flaticon-eye:before { content: "\f10f"; }
.flaticon-multimedia:before { content: "\f110"; }
.flaticon-whatsapp:before { content: "\f111"; }
.flaticon-multimedia-2:before { content: "\f112"; }
.flaticon-email:before { content: "\f113"; }
.flaticon-presentation-1:before { content: "\f114"; }
.flaticon-trophy:before { content: "\f115"; }
.flaticon-psd:before { content: "\f116"; }
.flaticon-layer:before { content: "\f117"; }
.flaticon-doc:before { content: "\f118"; }
.flaticon-file:before { content: "\f119"; }
.flaticon-network:before { content: "\f11a"; }
.flaticon-bus-stop:before { content: "\f11b"; }
.flaticon-globe:before { content: "\f11c"; }
.flaticon-upload:before { content: "\f11d"; }
.flaticon-squares:before { content: "\f11e"; }
.flaticon-technology:before { content: "\f11f"; }
.flaticon-up-arrow:before { content: "\f120"; }
.flaticon-browser:before { content: "\f121"; }
.flaticon-speech-bubble:before { content: "\f122"; }
.flaticon-coins:before { content: "\f123"; }
.flaticon-open-box:before { content: "\f124"; }
.flaticon-speech-bubble-1:before { content: "\f125"; }
.flaticon-attachment:before { content: "\f126"; }
.flaticon-photo-camera:before { content: "\f127"; }
.flaticon-skype-logo:before { content: "\f128"; }
.flaticon-linkedin-logo:before { content: "\f129"; }
.flaticon-twitter-logo:before { content: "\f12a"; }
.flaticon-facebook-letter-logo:before { content: "\f12b"; }
.flaticon-calendar-with-a-clock-time-tools:before { content: "\f12c"; }
.flaticon-youtube:before { content: "\f12d"; }
.flaticon-add-circular-button:before { content: "\f12e"; }
.flaticon-more-v2:before { content: "\f12f"; }
.flaticon-search:before { content: "\f130"; }
.flaticon-search-magnifier-interface-symbol:before { content: "\f131"; }
.flaticon-questions-circular-button:before { content: "\f132"; }
.flaticon-refresh:before { content: "\f133"; }
.flaticon-logout:before { content: "\f134"; }
.flaticon-event-calendar-symbol:before { content: "\f135"; }
.flaticon-laptop:before { content: "\f136"; }
.flaticon-tool:before { content: "\f137"; }
.flaticon-graphic:before { content: "\f138"; }
.flaticon-symbol:before { content: "\f139"; }
.flaticon-graphic-1:before { content: "\f13a"; }
.flaticon-clock:before { content: "\f13b"; }
.flaticon-squares-1:before { content: "\f13c"; }
.flaticon-black:before { content: "\f13d"; }
.flaticon-book:before { content: "\f13e"; }
.flaticon-cogwheel:before { content: "\f13f"; }
.flaticon-exclamation:before { content: "\f140"; }
.flaticon-add-label-button:before { content: "\f141"; }
.flaticon-delete-1:before { content: "\f142"; }
.flaticon-interface:before { content: "\f143"; }
.flaticon-more:before { content: "\f144"; }
.flaticon-warning-sign:before { content: "\f145"; }
.flaticon-calendar:before { content: "\f146"; }
.flaticon-instagram-logo:before { content: "\f147"; }
.flaticon-linkedin:before { content: "\f148"; }
.flaticon-facebook-logo-button:before { content: "\f149"; }
.flaticon-twitter-logo-button:before { content: "\f14a"; }
.flaticon-cancel:before { content: "\f14b"; }
.flaticon-exclamation-square:before { content: "\f14c"; }
.flaticon-buildings:before { content: "\f14d"; }
.flaticon-danger:before { content: "\f14e"; }
.flaticon-technology-1:before { content: "\f14f"; }
.flaticon-letter-g:before { content: "\f150"; }
.flaticon-interface-1:before { content: "\f151"; }
.flaticon-circle:before { content: "\f152"; }
.flaticon-pin:before { content: "\f153"; }
.flaticon-close:before { content: "\f154"; }
.flaticon-clock-1:before { content: "\f155"; }
.flaticon-apps:before { content: "\f156"; }
.flaticon-user:before { content: "\f157"; }
.flaticon-menu-button:before { content: "\f158"; }
.flaticon-settings:before { content: "\f159"; }
.flaticon-home:before { content: "\f15a"; }
.flaticon-clock-2:before { content: "\f15b"; }
.flaticon-lifebuoy:before { content: "\f15c"; }
.flaticon-cogwheel-1:before { content: "\f15d"; }
.flaticon-paper-plane:before { content: "\f15e"; }
.flaticon-statistics:before { content: "\f15f"; }
.flaticon-diagram:before { content: "\f160"; }
.flaticon-line-graph:before { content: "\f161"; }
.flaticon-customer:before { content: "\f162"; }
.flaticon-visible:before { content: "\f163"; }
.flaticon-shopping-basket:before { content: "\f164"; }
.flaticon-price-tag:before { content: "\f165"; }
.flaticon-businesswoman:before { content: "\f166"; }
.flaticon-medal:before { content: "\f167"; }
.flaticon-like:before { content: "\f168"; }
.flaticon-edit:before { content: "\f169"; }
.flaticon-avatar:before { content: "\f16a"; }
.flaticon-download:before { content: "\f16b"; }
.flaticon-home-1:before { content: "\f16c"; }
.flaticon-mail:before { content: "\f16d"; }
.flaticon-mail-1:before { content: "\f16e"; }
.flaticon-warning:before { content: "\f16f"; }
.flaticon-cart:before { content: "\f170"; }
.flaticon-bag:before { content: "\f171"; }
.flaticon-pie-chart:before { content: "\f172"; }
.flaticon-graph:before { content: "\f173"; }
.flaticon-interface-2:before { content: "\f174"; }
.flaticon-chat:before { content: "\f175"; }
.flaticon-envelope:before { content: "\f176"; }
.flaticon-chat-1:before { content: "\f177"; }
.flaticon-interface-3:before { content: "\f178"; }
.flaticon-background:before { content: "\f179"; }
.flaticon-file-1:before { content: "\f17a"; }
.flaticon-interface-4:before { content: "\f17b"; }
.flaticon-multimedia-3:before { content: "\f17c"; }
.flaticon-list:before { content: "\f17d"; }
.flaticon-time:before { content: "\f17e"; }
.flaticon-profile:before { content: "\f17f"; }
.flaticon-imac:before { content: "\f180"; }
.flaticon-medical:before { content: "\f181"; }
.flaticon-music:before { content: "\f182"; }
.flaticon-plus:before { content: "\f183"; }
.flaticon-exclamation-1:before { content: "\f184"; }
.flaticon-info:before { content: "\f185"; }
.flaticon-menu-1:before { content: "\f186"; }
.flaticon-menu-2:before { content: "\f187"; }
.flaticon-share:before { content: "\f188"; }
.flaticon-interface-5:before { content: "\f189"; }
.flaticon-signs:before { content: "\f18a"; }
.flaticon-tabs:before { content: "\f18b"; }
.flaticon-multimedia-4:before { content: "\f18c"; }
.flaticon-upload-1:before { content: "\f18d"; }
.flaticon-web:before { content: "\f18e"; }
.flaticon-placeholder:before { content: "\f18f"; }
.flaticon-placeholder-1:before { content: "\f190"; }
.flaticon-layers:before { content: "\f191"; }
.flaticon-interface-6:before { content: "\f192"; }
.flaticon-interface-7:before { content: "\f193"; }
.flaticon-interface-8:before { content: "\f194"; }
.flaticon-tool-1:before { content: "\f195"; }
.flaticon-settings-1:before { content: "\f196"; }
.flaticon-alarm:before { content: "\f197"; }
.flaticon-search-1:before { content: "\f198"; }
.flaticon-time-1:before { content: "\f199"; }
.flaticon-stopwatch:before { content: "\f19a"; }
.flaticon-folder:before { content: "\f19b"; }
.flaticon-folder-1:before { content: "\f19c"; }
.flaticon-folder-2:before { content: "\f19d"; }
.flaticon-folder-3:before { content: "\f19e"; }
.flaticon-file-2:before { content: "\f19f"; }
.flaticon-list-1:before { content: "\f1a0"; }
.flaticon-list-2:before { content: "\f1a1"; }
.flaticon-calendar-1:before { content: "\f1a2"; }
.flaticon-time-2:before { content: "\f1a3"; }
.flaticon-interface-9:before { content: "\f1a4"; }
.flaticon-app:before { content: "\f1a5"; }
.flaticon-suitcase:before { content: "\f1a6"; }
.flaticon-grid-menu-v2:before { content: "\f1a7"; }
.flaticon-more-v6:before { content: "\f1a8"; }
.flaticon-more-v5:before { content: "\f1a9"; }
.flaticon-add:before { content: "\f1aa"; }
.flaticon-multimedia-5:before { content: "\f1ab"; }
.flaticon-more-v4:before { content: "\f1ac"; }
.flaticon-placeholder-2:before { content: "\f1ad"; }
.flaticon-map-location:before { content: "\f1ae"; }
.flaticon-users:before { content: "\f1af"; }
.flaticon-profile-1:before { content: "\f1b0"; }
.flaticon-lock:before { content: "\f1b1"; }
.flaticon-sound:before { content: "\f1b2"; }
.flaticon-star:before { content: "\f1b3"; }
.flaticon-placeholder-3:before { content: "\f1b4"; }
.flaticon-bell:before { content: "\f1b5"; }
.flaticon-paper-plane-1:before { content: "\f1b6"; }
.flaticon-users-1:before { content: "\f1b7"; }
.flaticon-more-1:before { content: "\f1b8"; }
.flaticon-up-arrow-1:before { content: "\f1b9"; }
.flaticon-grid-menu:before { content: "\f1ba"; }
.flaticon-alarm-1:before { content: "\f1bb"; }
.flaticon-earth-globe:before { content: "\f1bc"; }
.flaticon-alert-1:before { content: "\f1bd"; }
.flaticon-internet:before { content: "\f1be"; }
.flaticon-user-ok:before { content: "\f1bf"; }
.flaticon-user-add:before { content: "\f1c0"; }
.flaticon-user-settings:before { content: "\f1c1"; }
.flaticon-truck:before { content: "\f1c2"; }
.flaticon-analytics:before { content: "\f1c3"; }
.flaticon-notes:before { content: "\f1c4"; }
.flaticon-tea-cup:before { content: "\f1c5"; }
.flaticon-exclamation-2:before { content: "\f1c6"; }
.flaticon-technology-2:before { content: "\f1c7"; }
.flaticon-location:before { content: "\f1c8"; }
.flaticon-edit-1:before { content: "\f1c9"; }
.flaticon-home-2:before { content: "\f1ca"; }
.flaticon-dashboard:before { content: "\f1cb"; }
.flaticon-information:before { content: "\f1cc"; }
.flaticon-light:before { content: "\f1cd"; }
.flaticon-car:before { content: "\f1ce"; }
.flaticon-business:before { content: "\f1cf"; }
.flaticon-squares-2:before { content: "\f1d0"; }
.flaticon-signs-1:before { content: "\f1d1"; }
.flaticon-mark:before { content: "\f1d2"; }
.flaticon-squares-3:before { content: "\f1d3"; }
.flaticon-comment:before { content: "\f1d4"; }
.flaticon-shapes:before { content: "\f1d5"; }
.flaticon-clipboard:before { content: "\f1d6"; }
.flaticon-squares-4:before { content: "\f1d7"; }
.flaticon-delete-2:before { content: "\f1d8"; }
.flaticon-bell-1:before { content: "\f1d9"; }
.flaticon-list-3:before { content: "\f1da"; }
.flaticon-infinity:before { content: "\f1db"; }
.flaticon-chat-2:before { content: "\f1dc"; }
.flaticon-calendar-2:before { content: "\f1dd"; }
.flaticon-signs-2:before { content: "\f1de"; }
.flaticon-time-3:before { content: "\f1df"; }
.flaticon-calendar-3:before { content: "\f1e0"; }
.flaticon-interface-10:before { content: "\f1e1"; }
.flaticon-interface-11:before { content: "\f1e2"; }
.flaticon-folder-4:before { content: "\f1e3"; }
.flaticon-alert-2:before { content: "\f1e4"; }
.flaticon-cogwheel-2:before { content: "\f1e5"; }
.flaticon-graphic-2:before { content: "\f1e6"; }
.flaticon-rotate:before { content: "\f1e7"; }
.flaticon-feed:before { content: "\f1e8"; }
.flaticon-safe-shield-protection:before { content: "\f1e9"; }
.flaticon-security:before { content: "\f1ea"; }
.flaticon-download-1:before { content: "\f1eb"; }
.flaticon-pie-chart-1:before { content: "\f1ec"; }
.flaticon-notepad:before { content: "\f1ed"; }
Binary file not shown.
File diff suppressed because it is too large Load Diff

After

Width:  |  Height:  |  Size: 549 KiB

Binary file not shown.
Binary file not shown.
+718
View File
@@ -0,0 +1,718 @@
/*
Flaticon icon font: Flaticon
Creation date: 20/03/2017 20:02
*/
@font-face {
font-family: "Flaticon2";
src: url("./font/Flaticon2.eot");
src: url("./font/Flaticon2.eot?#iefix") format("embedded-opentype"),
url("./font/Flaticon2.woff") format("woff"),
url("./font/Flaticon2.ttf") format("truetype"),
url("./font/Flaticon2.svg#Flaticon2") format("svg");
font-weight: normal;
font-style: normal;
}
[class^="flaticon2-"]:before,
[class*=" flaticon2-"]:before {
font-family: Flaticon2;
font-style: normal;
font-weight: normal;
font-variant: normal;
line-height: 1;
text-decoration: inherit;
text-rendering: optimizeLegibility;
text-transform: none;
-moz-osx-font-smoothing: grayscale;
-webkit-font-smoothing: antialiased;
font-smoothing: antialiased;
}
.flaticon2-notification:before {
content: "\f100";
}
.flaticon2-settings:before {
content: "\f101";
}
.flaticon2-search:before {
content: "\f102";
}
.flaticon2-delete:before {
content: "\f103";
}
.flaticon2-psd:before {
content: "\f104";
}
.flaticon2-list:before {
content: "\f105";
}
.flaticon2-box:before {
content: "\f106";
}
.flaticon2-download:before {
content: "\f107";
}
.flaticon2-shield:before {
content: "\f108";
}
.flaticon2-paperplane:before {
content: "\f109";
}
.flaticon2-avatar:before {
content: "\f10a";
}
.flaticon2-bell:before {
content: "\f10b";
}
.flaticon2-fax:before {
content: "\f10c";
}
.flaticon2-chart2:before {
content: "\f10d";
}
.flaticon2-supermarket:before {
content: "\f10e";
}
.flaticon2-phone:before {
content: "\f10f";
}
.flaticon2-envelope:before {
content: "\f110";
}
.flaticon2-pin:before {
content: "\f111";
}
.flaticon2-chat:before {
content: "\f112";
}
.flaticon2-chart:before {
content: "\f113";
}
.flaticon2-infographic:before {
content: "\f114";
}
.flaticon2-grids:before {
content: "\f115";
}
.flaticon2-menu:before {
content: "\f116";
}
.flaticon2-plus:before {
content: "\f117";
}
.flaticon2-list-1:before {
content: "\f118";
}
.flaticon2-talk:before {
content: "\f119";
}
.flaticon2-file:before {
content: "\f11a";
}
.flaticon2-user:before {
content: "\f11b";
}
.flaticon2-line-chart:before {
content: "\f11c";
}
.flaticon2-percentage:before {
content: "\f11d";
}
.flaticon2-menu-1:before {
content: "\f11e";
}
.flaticon2-paper-plane:before {
content: "\f11f";
}
.flaticon2-menu-2:before {
content: "\f120";
}
.flaticon2-shopping-cart:before {
content: "\f121";
}
.flaticon2-pie-chart:before {
content: "\f122";
}
.flaticon2-box-1:before {
content: "\f123";
}
.flaticon2-map:before {
content: "\f124";
}
.flaticon2-favourite:before {
content: "\f125";
}
.flaticon2-checking:before {
content: "\f126";
}
.flaticon2-safe:before {
content: "\f127";
}
.flaticon2-heart-rate-monitor:before {
content: "\f128";
}
.flaticon2-layers:before {
content: "\f129";
}
.flaticon2-delivery-package:before {
content: "\f12a";
}
.flaticon2-sms:before {
content: "\f12b";
}
.flaticon2-image-file:before {
content: "\f12c";
}
.flaticon2-plus-1:before {
content: "\f12d";
}
.flaticon2-send:before {
content: "\f12e";
}
.flaticon2-graphic-design:before {
content: "\f12f";
}
.flaticon2-cup:before {
content: "\f130";
}
.flaticon2-website:before {
content: "\f131";
}
.flaticon2-gift:before {
content: "\f132";
}
.flaticon2-chronometer:before {
content: "\f133";
}
.flaticon2-browser:before {
content: "\f134";
}
.flaticon2-digital-marketing:before {
content: "\f135";
}
.flaticon2-calendar:before {
content: "\f136";
}
.flaticon2-calendar-1:before {
content: "\f137";
}
.flaticon2-rocket:before {
content: "\f138";
}
.flaticon2-analytics:before {
content: "\f139";
}
.flaticon2-pie-chart-1:before {
content: "\f13a";
}
.flaticon2-pie-chart-2:before {
content: "\f13b";
}
.flaticon2-analytics-1:before {
content: "\f13c";
}
.flaticon2-google-drive-file:before {
content: "\f13d";
}
.flaticon2-pie-chart-3:before {
content: "\f13e";
}
.flaticon2-poll-symbol:before {
content: "\f13f";
}
.flaticon2-gear:before {
content: "\f140";
}
.flaticon2-magnifier-tool:before {
content: "\f141";
}
.flaticon2-add:before {
content: "\f142";
}
.flaticon2-cube:before {
content: "\f143";
}
.flaticon2-gift-1:before {
content: "\f144";
}
.flaticon2-list-2:before {
content: "\f145";
}
.flaticon2-shopping-cart-1:before {
content: "\f146";
}
.flaticon2-calendar-2:before {
content: "\f147";
}
.flaticon2-laptop:before {
content: "\f148";
}
.flaticon2-cube-1:before {
content: "\f149";
}
.flaticon2-layers-1:before {
content: "\f14a";
}
.flaticon2-chat-1:before {
content: "\f14b";
}
.flaticon2-copy:before {
content: "\f14c";
}
.flaticon2-paper:before {
content: "\f14d";
}
.flaticon2-hospital:before {
content: "\f14e";
}
.flaticon2-calendar-3:before {
content: "\f14f";
}
.flaticon2-speaker:before {
content: "\f150";
}
.flaticon2-pie-chart-4:before {
content: "\f151";
}
.flaticon2-schedule:before {
content: "\f152";
}
.flaticon2-expand:before {
content: "\f153";
}
.flaticon2-menu-3:before {
content: "\f154";
}
.flaticon2-download-1:before {
content: "\f155";
}
.flaticon2-help:before {
content: "\f156";
}
.flaticon2-list-3:before {
content: "\f157";
}
.flaticon2-notepad:before {
content: "\f158";
}
.flaticon2-graph:before {
content: "\f159";
}
.flaticon2-browser-1:before {
content: "\f15a";
}
.flaticon2-photograph:before {
content: "\f15b";
}
.flaticon2-browser-2:before {
content: "\f15c";
}
.flaticon2-hourglass:before {
content: "\f15d";
}
.flaticon2-mail:before {
content: "\f15e";
}
.flaticon2-cardiogram:before {
content: "\f15f";
}
.flaticon2-document:before {
content: "\f160";
}
.flaticon2-contract:before {
content: "\f161";
}
.flaticon2-graph-1:before {
content: "\f162";
}
.flaticon2-graphic:before {
content: "\f163";
}
.flaticon2-position:before {
content: "\f164";
}
.flaticon2-soft-icons:before {
content: "\f165";
}
.flaticon2-circle-vol-2:before {
content: "\f166";
}
.flaticon2-rocket-1:before {
content: "\f167";
}
.flaticon2-lorry:before {
content: "\f168";
}
.flaticon2-cd:before {
content: "\f169";
}
.flaticon2-file-1:before {
content: "\f16a";
}
.flaticon2-reload:before {
content: "\f16b";
}
.flaticon2-placeholder:before {
content: "\f16c";
}
.flaticon2-refresh:before {
content: "\f16d";
}
.flaticon2-medical-records:before {
content: "\f16e";
}
.flaticon2-rectangular:before {
content: "\f16f";
}
.flaticon2-medical-records-1:before {
content: "\f170";
}
.flaticon2-indent-dots:before {
content: "\f171";
}
.flaticon2-search-1:before {
content: "\f172";
}
.flaticon2-edit:before {
content: "\f173";
}
.flaticon2-new-email:before {
content: "\f174";
}
.flaticon2-calendar-4:before {
content: "\f175";
}
.flaticon2-console:before {
content: "\f176";
}
.flaticon2-open-text-book:before {
content: "\f177";
}
.flaticon2-download-2:before {
content: "\f178";
}
.flaticon2-zig-zag-line-sign:before {
content: "\f179";
}
.flaticon2-tools-and-utensils:before {
content: "\f17a";
}
.flaticon2-crisp-icons:before {
content: "\f17b";
}
.flaticon2-trash:before {
content: "\f17c";
}
.flaticon2-lock:before {
content: "\f17d";
}
.flaticon2-bell-1:before {
content: "\f17e";
}
.flaticon2-setup:before {
content: "\f17f";
}
.flaticon2-menu-4:before {
content: "\f180";
}
.flaticon2-architecture-and-city:before {
content: "\f181";
}
.flaticon2-shelter:before {
content: "\f182";
}
.flaticon2-add-1:before {
content: "\f183";
}
.flaticon2-checkmark:before {
content: "\f184";
}
.flaticon2-circular-arrow:before {
content: "\f185";
}
.flaticon2-user-outline-symbol:before {
content: "\f186";
}
.flaticon2-rhombus:before {
content: "\f187";
}
.flaticon2-crisp-icons-1:before {
content: "\f188";
}
.flaticon2-soft-icons-1:before {
content: "\f189";
}
.flaticon2-hexagonal:before {
content: "\f18a";
}
.flaticon2-time:before {
content: "\f18b";
}
.flaticon2-contrast:before {
content: "\f18c";
}
.flaticon2-telegram-logo:before {
content: "\f18d";
}
.flaticon2-hangouts-logo:before {
content: "\f18e";
}
.flaticon2-analytics-2:before {
content: "\f18f";
}
.flaticon2-wifi:before {
content: "\f190";
}
.flaticon2-protected:before {
content: "\f191";
}
.flaticon2-drop:before {
content: "\f192";
}
.flaticon2-mail-1:before {
content: "\f193";
}
.flaticon2-delivery-truck:before {
content: "\f194";
}
.flaticon2-writing:before {
content: "\f195";
}
.flaticon2-calendar-5:before {
content: "\f196";
}
.flaticon2-protection:before {
content: "\f197";
}
.flaticon2-calendar-6:before {
content: "\f198";
}
.flaticon2-calendar-7:before {
content: "\f199";
}
.flaticon2-calendar-8:before {
content: "\f19a";
}
.flaticon2-bell-2:before {
content: "\f19b";
}
.flaticon2-hourglass-1:before {
content: "\f19c";
}
.flaticon2-next:before {
content: "\f19d";
}
.flaticon2-chat-2:before {
content: "\f19e";
}
.flaticon2-correct:before {
content: "\f19f";
}
.flaticon2-photo-camera:before {
content: "\f1a0";
}
.flaticon2-fast-next:before {
content: "\f1a1";
}
.flaticon2-fast-back:before {
content: "\f1a2";
}
.flaticon2-down:before {
content: "\f1a3";
}
.flaticon2-back:before {
content: "\f1a4";
}
.flaticon2-up:before {
content: "\f1a5";
}
.flaticon2-arrow-down:before {
content: "\f1a6";
}
.flaticon2-arrow-up:before {
content: "\f1a7";
}
.flaticon2-accept:before {
content: "\f1a8";
}
.flaticon2-sort:before {
content: "\f1a9";
}
.flaticon2-arrow:before {
content: "\f1aa";
}
.flaticon2-back-1:before {
content: "\f1ab";
}
.flaticon2-add-square:before {
content: "\f1ac";
}
.flaticon2-quotation-mark:before {
content: "\f1ad";
}
.flaticon2-clip-symbol:before {
content: "\f1ae";
}
.flaticon2-check-mark:before {
content: "\f1af";
}
.flaticon2-folder:before {
content: "\f1b0";
}
.flaticon2-cancel-music:before {
content: "\f1b1";
}
.flaticon2-cross:before {
content: "\f1b2";
}
.flaticon2-pen:before {
content: "\f1b3";
}
.flaticon2-email:before {
content: "\f1b4";
}
.flaticon2-graph-2:before {
content: "\f1b5";
}
.flaticon2-open-box:before {
content: "\f1b6";
}
.flaticon2-files-and-folders:before {
content: "\f1b7";
}
.flaticon2-ui:before {
content: "\f1b8";
}
.flaticon2-sheet:before {
content: "\f1b9";
}
.flaticon2-dashboard:before {
content: "\f1ba";
}
.flaticon2-user-1:before {
content: "\f1bb";
}
.flaticon2-group:before {
content: "\f1bc";
}
.flaticon2-black-back-closed-envelope-shape:before {
content: "\f1bd";
}
.flaticon2-left-arrow:before {
content: "\f1be";
}
.flaticon2-sort-alphabetically:before {
content: "\f1bf";
}
.flaticon2-sort-down:before {
content: "\f1c0";
}
.flaticon2-rubbish-bin:before {
content: "\f1c1";
}
.flaticon2-rubbish-bin-delete-button:before {
content: "\f1c2";
}
.flaticon2-calendar-9:before {
content: "\f1c3";
}
.flaticon2-tag:before {
content: "\f1c4";
}
.flaticon2-refresh-button:before {
content: "\f1c5";
}
.flaticon2-refresh-arrow:before {
content: "\f1c6";
}
.flaticon2-reload-1:before {
content: "\f1c7";
}
.flaticon2-refresh-1:before {
content: "\f1c8";
}
.flaticon2-left-arrow-1:before {
content: "\f1c9";
}
.flaticon2-reply:before {
content: "\f1ca";
}
.flaticon2-reply-1:before {
content: "\f1cb";
}
.flaticon2-printer:before {
content: "\f1cc";
}
.flaticon2-print:before {
content: "\f1cd";
}
.flaticon2-shrink:before {
content: "\f1ce";
}
.flaticon2-resize:before {
content: "\f1cf";
}
.flaticon2-arrow-1:before {
content: "\f1d0";
}
.flaticon2-size:before {
content: "\f1d1";
}
.flaticon2-arrow-2:before {
content: "\f1d2";
}
.flaticon2-cancel:before {
content: "\f1d3";
}
.flaticon2-exclamation:before {
content: "\f1d4";
}
.flaticon2-line:before {
content: "\f1d5";
}
.flaticon2-warning:before {
content: "\f1d6";
}
.flaticon2-information:before {
content: "\f1d7";
}
.flaticon2-layers-2:before {
content: "\f1d8";
}
.flaticon2-file-2:before {
content: "\f1d9";
}
.flaticon2-bell-3:before {
content: "\f1da";
}
.flaticon2-bell-4:before {
content: "\f1db";
}
.flaticon2-bell-5:before {
content: "\f1dc";
}
.flaticon2-bell-alarm-symbol:before {
content: "\f1dd";
}
.flaticon2-world:before {
content: "\f1de";
}
.flaticon2-graphic-1:before {
content: "\f1df";
}
.flaticon2-send-1:before {
content: "\f1e0";
}
.flaticon2-location:before {
content: "\f1e1";
}
.flaticon2-pin-1:before {
content: "\f1e2";
}
.flaticon2-start-up:before {
content: "\f1e3";
}
.flaticon2-right-arrow:before {
content: "\f1e4";
}
Binary file not shown.
File diff suppressed because it is too large Load Diff

After

Width:  |  Height:  |  Size: 276 KiB

Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,344 @@
<?xml version="1.0" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd" >
<!--
2020-8-3: Created with FontForge (http://fontforge.org)
-->
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" version="1.1">
<metadata>
Created by FontForge 20170731 at Mon Aug 3 14:46:08 2020
By root
</metadata>
<defs>
<font id="Flaticon" horiz-adv-x="512" >
<font-face
font-family="Flaticon"
font-weight="400"
font-stretch="normal"
units-per-em="512"
panose-1="2 0 5 3 0 0 0 0 0 0"
ascent="448"
descent="-64"
bbox="-0.00102858 -64.0044 512.002 448.004"
underline-thickness="25.6"
underline-position="-51.2"
unicode-range="U+0020-F143"
/>
<missing-glyph />
<glyph glyph-name="space" unicode=" " horiz-adv-x="200"
/>
<glyph glyph-name="040-bold-wide-arrow-down" unicode="&#xf127;"
d="M493.998 156.272l-197.646 -197.332c-23.2617 -23.209 -58.0762 -23.209 -81.2324 0l-197.227 197.332c-23.1572 23.1045 -24.1504 59.4346 -1.15039 82.3828c22.4775 22.4775 60.376 20.0723 82.1738 -1.77734l96.3916 -96.3916s0.314453 243.123 0.314453 243.228
c0 16.1006 6.27246 31.625 17.9814 42.6553c28.2275 26.4502 77.626 19.3936 96.3916 -14.4795c4.75684 -8.57324 7.26562 -18.3477 7.21387 -28.1758l-0.208984 -243.228s96.2871 96.3408 96.3398 96.3926c21.9023 21.9014 59.6436 24.0977 82.0166 1.77734
c23 -22.9492 21.7979 -59.2783 -1.35938 -82.3838z" />
<glyph glyph-name="020-bold-close" unicode="&#xf113;"
d="M494.692 347.152l-155.133 -155.149l155.133 -155.142c23.0752 -23.084 23.0752 -60.4775 0 -83.5615c-11.5293 -11.5293 -26.6455 -17.2979 -41.7539 -17.2979c-15.1338 0 -30.251 5.75977 -41.7715 17.2979l-155.167 155.159l-155.155 -155.157
c-11.5273 -11.5293 -26.6455 -17.2988 -41.7666 -17.2988c-15.1182 0 -30.2246 5.76074 -41.7637 17.2988c-23.0752 23.0732 -23.0752 60.4678 0 83.5615l155.129 155.142l-155.138 155.147c-23.0752 23.0752 -23.0752 60.4766 0 83.5518
c23.0713 23.0576 60.4512 23.0576 83.5303 0l155.162 -155.148l155.151 155.148c23.084 23.0576 60.4678 23.0576 83.5342 0c23.084 -23.0752 23.084 -60.4766 0.00878906 -83.5518z" />
<glyph glyph-name="033-bold-double-arrow-back" unicode="&#xf120;"
d="M444.667 -64c-18.3604 0 -36.6133 7.9668 -49.1182 23.2656l-156.373 191.985c-18.9922 23.3193 -18.9922 56.7139 0 79.9805l157.64 193.462c22.0527 27.0645 61.9375 31.1787 89.0547 9.07422c27.0645 -22.0527 31.1797 -61.9375 9.07422 -89.0547l-125.035 -153.472
l123.77 -151.942c22.1055 -27.1172 18.043 -66.9492 -9.07422 -89.0547c-11.7129 -9.60156 -25.9043 -14.2441 -39.9375 -14.2441zM173.493 -40.6807l-156.425 191.932c-18.9932 23.2666 -18.9932 56.7148 0 79.9805l157.692 193.462
c22.0518 27.0654 61.9375 31.1797 89.0537 9.0752c27.0645 -22.0527 31.1797 -61.9385 9.07422 -89.0557l-125.035 -153.472l123.769 -151.941c22.1064 -27.1172 18.043 -66.9492 -9.07324 -89.0547c-11.7129 -9.60156 -25.9043 -14.2441 -39.9375 -14.2441
c-18.3604 0 -36.6143 7.96582 -49.1182 23.3184z" />
<glyph glyph-name="022-hide" unicode="&#xf115;"
d="M88.7676 90.915c-29.5654 19.6895 -55.8066 44.6426 -77.123 74.0605l-11.6445 16.0713l11.6436 16.0684c56.6553 78.1855 148.002 124.862 244.355 124.862c19.6465 0 39.0576 -2.04297 58.0361 -5.79004l-16.4365 -16.4424l-32.7871 -32.7803l-29.1162 -29.1162
c-5.00098 5.09961 -11.9561 8.27637 -19.6631 8.27637c-15.2314 0 -27.582 -12.3506 -27.582 -27.583c0 -7.71094 3.17676 -14.6611 8.27637 -19.668l-32.041 -32.0361c-5.79004 12.6865 -9.09766 26.7402 -9.09766 41.5938c0 14.624 3.19922 28.4814 8.81836 41.0127
c-36.5986 -14.6846 -69.5918 -38.0225 -95.7129 -68.3975c17.2373 -20.043 37.4746 -36.999 59.75 -50.457zM410.969 278.855c34.5771 -20.7197 65.166 -48.3242 89.3857 -81.7344l11.6445 -16.0703l-11.6445 -16.0703
c-56.6504 -78.1914 -148.002 -124.866 -244.355 -124.866c-25.3037 0 -50.2363 3.2959 -74.3135 9.45996l46.873 46.873c9.06445 -1.02441 18.2227 -1.56152 27.4404 -1.56152c72.0137 0 140.619 31.8701 187.307 86.1602
c-20.4688 23.7979 -45.1484 43.2852 -72.4785 57.668zM256 108.019c-5.00098 0 -9.875 0.482422 -14.6787 1.18848l113.896 113.903c0.707031 -4.80371 1.19531 -9.67773 1.19531 -14.6787c0 -55.4561 -44.9561 -100.413 -100.412 -100.413zM222.678 113.797
l-14.1738 -14.168l-44.8242 -44.8242l-79.5605 -79.5596c-6.05762 -6.06348 -14.0049 -9.09277 -21.9473 -9.09277c-7.94141 0 -15.8887 3.03418 -21.9463 9.09277c-12.1201 12.1211 -12.1201 31.7725 0 43.8936l62.833 62.8281l40.3223 40.3223l29.6367 29.6357
l39.4023 39.4023l30.8359 30.8359l43.083 43.083l26.1758 26.1758l20.3799 20.3799l96.9502 96.9551c6.05664 6.06348 14.0039 9.0918 21.9453 9.0918c7.94238 0 15.8887 -3.0332 21.9473 -9.0918c12.1201 -12.1211 12.1201 -31.7734 0 -43.8936l-77.6494 -77.6543
l-40.9854 -40.9854l-4.46973 -4.46973z" />
<glyph glyph-name="045-bold-long-arrow-down" unicode="&#xf12c;"
d="M234.709 -54.3906l-124.051 160.021c-12.8184 16.126 -1.24023 40.5225 19.4336 40.5225h43.0039c13.6445 0 23.1553 9.92383 23.1553 23.5684v254.297c0 13.6455 12.4043 23.9824 26.4629 23.9824h66.5723c14.0586 0 26.877 -10.3369 26.877 -23.9824v-254.297
c0 -13.6445 9.10059 -23.5684 22.7412 -23.5684h42.5938c21.0879 0 32.6699 -24.3965 19.8477 -40.9355l-127.354 -159.607c-9.92383 -12.8184 -29.3584 -12.8184 -39.2822 0z" />
<glyph glyph-name="044-bold-long-arrow-up" unicode="&#xf12b;"
d="M222.654 -64c-14.0127 0 -26.3779 11.54 -26.7861 25.1416v253.476c0 13.6006 -9.47949 26.3779 -23.0801 26.3779h-42.4521c-20.6074 0 -32.1484 23.0801 -19.3711 39.1543l123.646 158.268c9.8916 12.7773 29.2627 12.7773 39.1543 0l127.356 -157.855
c12.7725 -16.4863 0.820312 -39.5664 -19.7881 -39.5664h-42.4512c-13.6016 0 -22.6689 -12.7773 -22.6689 -26.3779v-253.063c0 -14.0137 -13.1895 -25.5537 -26.79 -25.5537h-66.7695z" />
<glyph glyph-name="056-bold-sort" unicode="&#xf137;"
d="M63 415c34.7383 0 63 -28.2617 63 -63s-28.2617 -63 -63 -63s-63 28.2617 -63 63s28.2617 63 63 63zM63 255c34.7383 0 63 -28.2617 63 -63s-28.2617 -63 -63 -63s-63 28.2617 -63 63s28.2617 63 63 63zM63 95c34.7383 0 63 -28.2617 63 -63s-28.2617 -63 -63 -63
s-63 28.2617 -63 63s28.2617 63 63 63zM465 399c25.916 0 47 -21.084 47 -47s-21.084 -47 -47 -47h-258c-25.916 0 -47 21.084 -47 47s21.084 47 47 47h258zM465 239c25.916 0 47 -21.084 47 -47s-21.084 -47 -47 -47h-258c-25.916 0 -47 21.084 -47 47s21.084 47 47 47h258
zM465 79c25.916 0 47 -21.084 47 -47s-21.084 -47 -47 -47h-258c-25.916 0 -47 21.084 -47 47s21.084 47 47 47h258z" />
<glyph glyph-name="036-bold-arrow-next" unicode="&#xf123;"
d="M115.479 37.6113l125.141 125.144c16.1064 16.1113 16.1064 42.1309 0 58.2383l-124.728 125.549c-23.9541 23.9541 -23.1279 63.1895 2.06543 86.3174c23.541 21.8887 61.124 19.4111 83.8398 -3.30371l194.938 -195.351
c11.5635 -11.5684 17.3457 -26.8457 17.3457 -42.127s-5.78223 -30.5615 -17.3457 -42.126l-195.351 -195.351c-22.7158 -23.1318 -59.8857 -25.1973 -83.8398 -3.30371c-25.6064 23.124 -26.0195 62.3594 -2.06543 86.3135z" />
<glyph glyph-name="025-up-and-down" unicode="&#xf118;"
d="M324.96 81.918c11.125 0 15.6797 -7.83398 10.1631 -17.4912l-69.1426 -121.179c-5.51074 -9.66309 -14.4561 -9.66309 -19.9678 0l-69.1377 121.173c-5.51172 9.66309 -0.963867 17.4971 10.1621 17.4971h137.923zM265.985 440.752l69.1357 -121.179
c5.51172 -9.66309 0.963867 -17.4971 -10.1621 -17.4971h-137.923c-11.125 0 -15.6738 7.83398 -10.1621 17.4971l69.1436 121.179c5.51172 9.66406 14.4561 9.66406 19.9678 0z" />
<glyph glyph-name="061-wrench" unicode="&#xf13c;"
d="M224.37 249.804c8.10547 -20.3594 19.9971 -38.3818 35.6797 -54.0684c15.6904 -15.6836 33.71 -27.5762 54.0625 -35.6816l-212.52 -212.526c-7.6875 -7.6875 -17.0361 -11.5273 -28.0479 -11.5273c-10.8008 0 -20.2529 3.83984 -28.3574 11.5273l-33.0303 33.6553
c-7.89453 7.48535 -11.8428 16.8311 -11.8428 28.0508c0 11.0107 3.94922 20.4658 11.8428 28.3574zM107.515 15.1533c3.94531 3.94824 5.91992 8.62207 5.91992 14.0225c0 5.39746 -1.97461 10.0781 -5.91992 14.0303c-3.94824 3.93945 -8.62305 5.91699 -14.0254 5.91699
c-5.40527 0 -10.0752 -1.97754 -14.0225 -5.91699c-3.94727 -3.95312 -5.92188 -8.63379 -5.92188 -14.0303c0 -5.40137 1.97461 -10.0742 5.92188 -14.0225c3.94727 -3.94434 8.62109 -5.9209 14.0225 -5.9209c5.39941 0 10.0732 1.97656 14.0254 5.9209zM509.041 303.404
c1.7627 -2.08008 2.65234 -4.67773 2.64453 -7.78613c0 -8.09961 -2.38867 -19.1133 -7.16309 -33.0303c-9.76367 -27.8379 -26.8574 -50.4307 -51.2627 -67.7773c-24.4092 -17.3428 -51.2617 -26.0225 -80.5566 -26.0225c-38.4326 0 -71.3086 13.6631 -98.624 40.9775
c-27.3174 27.3174 -40.9785 60.1953 -40.9785 98.6279s13.6533 71.3105 40.9785 98.626c27.3154 27.3174 60.1914 40.9805 98.624 40.9805c12.0459 0 24.6699 -1.71484 37.8633 -5.1416c13.1846 -3.42773 24.3525 -8.25879 33.4971 -14.4932
c3.32324 -2.28516 4.98535 -5.19336 4.98535 -8.72266c0 -3.53223 -1.66211 -6.44141 -4.98535 -8.72559l-91.3027 -52.668v-69.8037l60.1465 -33.3447c1.03613 0.624023 9.24121 5.66113 24.6191 15.1162c15.3701 9.45508 29.4492 17.8672 42.2236 25.2402
c12.7812 7.375 20.1025 11.0645 21.9707 11.0645c3.11133 0 5.55469 -1.03613 7.32031 -3.11719z" />
<glyph glyph-name="013-long-arrow-up" unicode="&#xf10c;"
d="M460.62 255.076c10.9229 -10.9189 10.9639 -28.6846 0.0458984 -39.5986l-16.7256 -16.7842c-5.27441 -5.26562 -12.2002 -8.16309 -19.7012 -8.16309s-14.3447 2.89746 -19.6104 8.16309l-108.64 109.011v-343.678c0 -15.4346 -13.3125 -28.0264 -28.7471 -28.0264
h-23.7266c-15.4355 0 -27.5234 12.5918 -27.5234 28.0264v342.462l-108.027 -107.795c-5.2666 -5.26562 -12.5381 -8.16309 -20.0352 -8.16309c-7.50098 0 -14.5312 2.89746 -19.8018 8.16309l-16.7754 16.7842c-10.9141 10.9102 -10.9141 28.6797 0 39.5986
l184.766 184.761c5.27051 5.27051 12.2969 8.16309 19.793 8.16309c7.64746 0 14.6777 -2.89258 19.9473 -8.16309z" />
<glyph glyph-name="057-eye" unicode="&#xf138;"
d="M115.543 25.0312c-41.5381 21.9756 -77.9375 53.917 -105.158 92.3613c-13.8467 19.5635 -13.8467 45.7871 0 65.2979c27.2207 38.4443 63.6201 70.3857 105.158 92.3613c21.2422 11.2246 43.7422 19.8779 66.9766 25.7002
c23.917 6.03125 48.6191 9.07324 73.4805 9.07324c24.8604 0 49.5635 -3.09473 73.4795 -9.07324c23.2354 -5.82227 45.7881 -14.4756 66.9766 -25.7002c41.5391 -21.9756 77.9385 -53.917 105.158 -92.3613c13.8467 -19.5625 13.8467 -45.7861 0 -65.2979
c-27.2197 -38.4443 -63.6191 -70.3857 -105.158 -92.3613c-21.2412 -11.2236 -43.7412 -19.8779 -66.9766 -25.6992c-23.916 -6.03223 -48.6191 -9.07422 -73.4795 -9.07422c-24.8613 0 -49.5635 3.09473 -73.4805 9.07422
c-23.2344 5.82129 -45.7871 14.4756 -66.9766 25.6992zM256 189.456c-21.7666 0 -39.4414 -17.6738 -39.4414 -39.4404c0 -21.7656 17.6748 -39.4414 39.4414 -39.4414c21.7656 0 39.4404 17.6758 39.4404 39.4414c0 21.7666 -17.6748 39.4404 -39.4404 39.4404z
M369.445 224.02c-7.08105 3.77637 -14.4238 7.18555 -21.9238 10.2803c20.4023 -22.1855 32.833 -51.7139 32.833 -84.2314s-12.4307 -62.0996 -32.833 -84.2314c7.5 2.98828 14.791 6.39746 21.9238 10.1738c33.3574 17.623 62.5703 43.2178 84.5469 74.0049
c-21.9766 30.7871 -51.1895 56.3818 -84.5469 74.0039zM142.555 224.02c-33.4102 -17.6221 -62.624 -43.2168 -84.5469 -74.1094c21.9756 -30.7871 51.1895 -56.3818 84.5469 -74.0049c7.13281 -3.72266 14.4756 -7.13184 21.9238 -10.1748
c-20.4033 22.1865 -32.833 51.7139 -32.833 84.2324c0 32.5176 12.4297 62.0986 32.832 84.2324c-7.5 -2.99023 -14.79 -6.39941 -21.9229 -10.1758zM22.5 290.104c-8.49609 13.4795 -4.40527 31.3115 9.07422 39.7559c66.5039 41.8018 144.127 63.8818 224.426 63.8818
c80.2979 0 157.921 -22.0801 224.426 -63.9346c13.4785 -8.44336 17.5703 -26.2764 9.07324 -39.7559c-8.44434 -13.4785 -26.2773 -17.5693 -39.7559 -9.07324c-57.3252 35.9795 -124.302 55.0186 -193.743 55.0186s-136.418 -19.0391 -193.744 -55.0186
c-4.77246 -2.98926 -10.0703 -4.40527 -15.3145 -4.40527c-9.59863 0 -18.9863 4.8252 -24.4414 13.5312z" />
<glyph glyph-name="003-double-arrow-down" unicode="&#xf102;"
d="M501.497 220.17c14.0039 -13.999 14.0039 -36.6934 0 -50.6982l-220.149 -220.155c-6.99902 -6.98535 -16.1748 -10.4922 -25.3496 -10.4922s-18.3486 3.49219 -25.3457 10.4922l-220.149 220.155c-14.0049 14.0049 -14.0049 36.6992 0 50.6982
c14.002 14 36.7031 14 50.7012 0l194.793 -194.794l194.794 194.794c13.998 13.9932 36.707 13.9932 50.7061 0zM230.652 163.817l-220.147 220.149c-14.0059 14.0078 -14.0059 36.71 0 50.7109c14.001 13.998 36.7021 13.998 50.7002 0l194.793 -194.794l194.794 194.794
c13.9971 13.998 36.7002 13.998 50.7051 0c14.0039 -14.001 14.0039 -36.7031 0 -50.7109l-220.149 -220.149c-6.99902 -6.98438 -16.1748 -10.4912 -25.3496 -10.4912s-18.3496 3.50684 -25.3457 10.4912z" />
<glyph glyph-name="004-double-arrow-up" unicode="&#xf103;"
d="M10.499 163.834c-13.999 13.9971 -13.999 36.6885 0 50.6943l220.156 220.157c6.99512 6.98828 16.1699 10.4951 25.3457 10.4951c9.16992 0 18.3447 -3.5 25.3438 -10.4951l220.149 -220.156c13.9971 -14.0059 13.9971 -36.6973 0 -50.6943
c-14.0039 -14.0039 -36.707 -14.0039 -50.7109 0l-194.782 194.786l-194.794 -194.787c-14.0059 -13.9971 -36.707 -13.9971 -50.708 0zM281.345 220.18l220.156 -220.148c13.9971 -14.0049 13.9971 -36.708 0 -50.7119c-14.0039 -13.9971 -36.707 -13.9971 -50.7109 0
l-194.789 194.793l-194.794 -194.793c-14.0059 -13.9971 -36.707 -13.9971 -50.708 0c-13.999 13.9971 -13.999 36.7002 0 50.7041l220.156 220.156c6.99512 6.98926 16.1699 10.4951 25.3457 10.4951c9.16992 0 18.3447 -3.50586 25.3438 -10.4951z" />
<glyph glyph-name="035-bold-arrow-down" unicode="&#xf122;"
d="M213.944 51.2607l-195.347 195.351c-23.1279 22.7148 -25.1934 60.2988 -3.30371 83.8398c23.124 25.6064 62.3594 26.0195 86.3174 2.06445l125.14 -125.14c16.1074 -16.1074 42.1309 -16.1074 58.2334 0l125.553 125.14
c23.9502 23.9551 63.1855 23.1289 86.3135 -2.06445c21.8936 -23.541 19.4111 -61.125 -3.30371 -83.8398l-195.351 -195.351c-11.5635 -11.5684 -26.8447 -17.3457 -42.126 -17.3457s-30.5586 5.77734 -42.126 17.3457z" />
<glyph glyph-name="059-menu" unicode="&#xf13a;"
d="M0 367.839c0 22.8379 18.5352 41.373 41.374 41.373h429.252c22.8389 0 41.374 -18.5352 41.374 -41.373c0 -22.8389 -18.5352 -41.374 -41.374 -41.374h-429.252c-22.8389 0 -41.374 18.5352 -41.374 41.374zM470.626 233.374c22.8594 0 41.374 -18.5361 41.374 -41.374
s-18.5352 -41.374 -41.374 -41.374h-429.252c-22.8389 0 -41.374 18.5361 -41.374 41.374s18.5352 41.374 41.374 41.374h429.252zM470.626 57.5352c22.8594 0 41.374 -18.5352 41.374 -41.374c0 -22.8379 -18.5352 -41.373 -41.374 -41.373h-429.252
c-22.8389 0 -41.374 18.5352 -41.374 41.373c0 22.8389 18.5352 41.374 41.374 41.374h429.252z" />
<glyph glyph-name="019-solid-plus" unicode="&#xf112;"
d="M482.872 418.875c19.4062 -19.4072 29.1279 -42.8877 29.1279 -70.4385v-312.873c0 -27.2637 -9.70605 -50.6709 -29.1172 -70.2207c-19.4131 -19.5625 -42.8838 -29.332 -70.4355 -29.332h-312.894c-27.5527 0 -51.0361 9.77148 -70.4424 29.332
c-19.4102 19.5488 -29.1113 42.9561 -29.1113 70.2207v312.873c0 27.5508 9.70605 51.0312 29.1113 70.4365c19.4004 19.4102 42.8857 29.1162 70.4424 29.1162h312.883c27.5508 0 51.0332 -9.70605 70.4355 -29.1143zM455.105 35.5498v312.879
c0 11.8506 -4.1543 21.9268 -12.4434 30.2207c-8.28809 8.29883 -18.3682 12.4443 -30.2148 12.4443h-312.894c-11.8516 0 -21.9277 -4.15234 -30.2207 -12.4443c-8.29688 -8.29395 -12.4404 -18.3701 -12.4404 -30.2207v-312.879
c0 -11.8525 4.14355 -21.9316 12.4404 -30.2207c8.30078 -8.30078 18.3691 -12.4443 30.2207 -12.4443h312.883c11.8477 0 21.9268 4.14355 30.2148 12.4443c8.29395 8.28906 12.4541 18.3682 12.4541 30.2207zM384 220.435c4.14355 0 7.55371 -1.33691 10.2158 -4.00098
c2.66309 -2.66113 3.99023 -6.0752 3.99023 -10.2217v-28.4424c0 -4.14941 -1.3252 -7.55762 -3.99023 -10.2158c-2.66211 -2.65918 -6.07227 -4.00098 -10.2158 -4.00098h-99.5527v-99.5508c0 -4.15039 -1.33105 -7.55762 -4.00586 -10.2168
c-2.65234 -2.66309 -6.06152 -3.99414 -10.2178 -3.99414h-28.4414c-4.14648 0 -7.55566 1.33105 -10.2227 3.99414c-2.66797 2.65918 -3.99707 6.06641 -3.99707 10.2168v99.5527h-99.5547c-4.14648 0 -7.55762 1.3418 -10.2227 4
c-2.66797 2.65918 -3.99707 6.06641 -3.99707 10.2168v28.4414c0 4.14746 1.33398 7.55566 3.99707 10.2227c2.66211 2.66699 6.07617 3.99707 10.2227 3.99707h99.5547v99.5547c0 4.14648 1.3291 7.55566 3.99707 10.2227c2.66699 2.66797 6.07129 3.99707 10.2227 3.99707
h28.4414c4.15527 0 7.56543 -1.3291 10.2178 -3.99707c2.6748 -2.66699 4.00586 -6.07129 4.00586 -10.2227v-99.5527h99.5527z" />
<glyph glyph-name="024-copy" unicode="&#xf117;"
d="M171.358 388.651c-10.2393 0.0947266 -18.8438 -7.29883 -20.5186 -17.1338l-0.21582 -3.38086c-0.0244141 -0.375 -0.0556641 -0.749023 -0.0927734 -1.11914c-0.0185547 -0.182617 -0.0380859 -0.365234 -0.0605469 -0.546875l-0.110352 -2.91797l-0.383789 0.0146484
c-3.11328 -14.0332 -16.0664 -24.0977 -30.8584 -23.1514c-14.2666 0.911133 -25.5312 11.7598 -27.4834 25.3711l-0.580078 0.0214844l0.200195 5.26172c1.64453 43.2402 37.3857 77.3291 80.6553 76.9258l257.706 -2.39746
c43.6035 -0.405273 78.8428 -35.668 79.2227 -79.2705l2.2627 -260.714c0.358398 -41.1729 -31.3164 -75.5479 -72.3809 -78.5527l-0.0214844 0.291016c-13.458 -0.0146484 -25.6543 9.18945 -28.8848 22.8535c-3.77051 15.9482 6.10156 31.9346 22.0508 35.7041
l4.40527 1.04199l0.106445 -0.449219c8.86816 1.60449 15.46 9.3916 15.3789 18.5957l-2.2627 260.714c-0.0976562 11.2432 -9.18555 20.3359 -20.4277 20.4414zM59.3477 320.252h270.892c32.7754 0 59.3477 -26.5713 59.3477 -59.3486v-242.879
c0 -32.7764 -26.5723 -59.3477 -59.3477 -59.3477h-270.892c-32.7764 0 -59.3477 26.5713 -59.3477 59.3477v242.879c0 32.7773 26.5713 59.3486 59.3477 59.3486zM63.7627 238.525v-197.009c0 -11.1113 9.00781 -20.1182 20.1182 -20.1182h221.824
c11.1113 0 20.1182 9.00684 20.1182 20.1182v197.009c0 11.1113 -9.00684 20.1182 -20.1182 20.1182h-221.824c-11.1104 0 -20.1182 -9.00684 -20.1182 -20.1182z" />
<glyph glyph-name="012-long-arrow-down" unicode="&#xf10b;"
d="M460.663 168.548c10.9219 -10.918 10.9219 -28.6885 0 -39.6025l-184.758 -184.77c-5.26172 -5.26172 -12.3047 -8.17578 -19.8008 -8.17578h-0.0791016c-7.55566 0 -14.6152 2.91406 -19.877 8.17578l-184.794 184.773
c-10.915 10.918 -10.9609 28.6807 -0.0458984 39.5986l16.6787 16.7754c5.27051 5.27441 12.1133 8.19238 19.6064 8.19238c7.50098 0 14.1357 -2.91797 19.4102 -8.19238l108.215 -109.006v343.664c0 15.4434 14.124 28.0186 29.5586 28.0186h23.7314
c15.4385 0 26.7109 -12.5752 26.7109 -28.0186v-342.444l108.432 107.786c5.26562 5.27441 12.9414 8.19238 20.4424 8.19238c7.49316 0 14.5322 -2.91797 19.7979 -8.19238z" />
<glyph glyph-name="005-long-arrow-back" unicode="&#xf104;"
d="M483.22 232.15c15.4307 0 28.7803 -12.2588 28.7803 -27.6816v-23.7139c0 -15.4229 -12.9668 -28.7217 -28.3975 -28.7217h-343.406l108.919 -108.544c5.27344 -5.2666 8.16699 -11.9219 8.16699 -19.4111c0 -7.49219 -2.89355 -14.5186 -8.16699 -19.7803
l-16.7588 -16.7666c-5.26953 -5.26172 -12.292 -8.16309 -19.7803 -8.16309c-7.49707 0 -14.5195 2.89746 -19.7891 8.16309l-184.624 184.624c-5.29102 5.28223 -8.18359 12.333 -8.16309 19.8721c-0.0205078 7.49609 2.87207 14.5439 8.16309 19.835l184.624 184.606
c5.26953 5.27051 12.2959 8.16309 19.7891 8.16309c7.48828 0 14.5107 -2.91406 19.7803 -8.18359l16.7588 -16.7666c5.27344 -5.28223 8.16699 -12.3721 8.16699 -19.8643c0 -7.49316 -2.89355 -14.6318 -8.16699 -19.9014l-107.703 -107.94h342.606z" />
<glyph glyph-name="028-bold-triangle-right" unicode="&#xf11b;"
d="M157.852 440.652l231.302 -231.299c9.5 -9.5 9.5 -25.1953 0 -34.6992l-231.302 -231.302c-15.6953 -15.6963 -42.1299 -4.54395 -42.1299 17.3477v462.604c0 21.8906 26.4346 33.043 42.1299 17.3477z" />
<glyph glyph-name="067-clock" unicode="&#xf142;"
d="M256 448c141.161 0 256 -114.839 256 -256s-114.839 -256 -256 -256s-256 114.839 -256 256s114.839 256 256 256zM256 -8.04395c110.307 0 200.044 89.7373 200.044 200.044c0 110.302 -89.7373 200.044 -200.044 200.044s-200.044 -89.7422 -200.044 -200.044
c0 -110.307 89.7373 -200.044 200.044 -200.044zM278.159 191.692l74.4209 -74.418c8.47266 -8.4668 8.4668 -22.1973 0 -30.6699c-8.46582 -8.47168 -22.1963 -8.47168 -30.6689 0l-80.7734 80.7676c-4.50391 4.50488 -6.53516 10.4922 -6.25 16.3896
c-0.0166016 0.341797 -0.100586 0.661133 -0.100586 1.00195v114.225c0 11.9805 9.70898 21.6885 21.6885 21.6885c11.9756 0 21.6836 -9.70801 21.6836 -21.6885v-107.296z" />
<glyph glyph-name="065-calendar" unicode="&#xf140;"
d="M128 448c10.0986 0 18.2852 -8.18652 18.2852 -18.2861v-36.5713h-36.5713v36.5713c0 10.0996 8.1875 18.2861 18.2861 18.2861zM384 448c10.0986 0 18.2861 -8.18652 18.2852 -18.2861v-36.5713h-36.5713v36.5713c0 10.0996 8.18652 18.2861 18.2861 18.2861z
M457.143 393.143c30.2969 0 54.8574 -24.5596 54.8574 -54.8564v-347.429c0 -30.2969 -24.5605 -54.8574 -54.8574 -54.8574h-402.285c-30.2969 0 -54.8574 24.5605 -54.8574 54.8574v347.429c0 30.2969 24.5605 54.8564 54.8574 54.8564h54.8555v-73.1426
c0 -10.0996 8.1875 -18.2861 18.2861 -18.2861c10.0996 0 18.2861 8.18652 18.2861 18.2861v73.1426h219.429v-73.1426c0 -10.0996 8.18652 -18.2861 18.2861 -18.2861c10.0986 0 18.2861 8.18652 18.2861 18.2861v73.1426h54.8564zM475.428 -9.14258v237.714h-438.856
v-237.714c0 -10.0996 8.18652 -18.2861 18.2861 -18.2861h402.284c10.0996 0 18.2861 8.18652 18.2861 18.2861zM146.285 192.001c10.0996 0 18.2861 -8.1875 18.2861 -18.2861c0 -10.0996 -8.18652 -18.2861 -18.2861 -18.2861h-36.5713
c-10.0986 0 -18.2861 8.18652 -18.2861 18.2861c0 10.0986 8.1875 18.2861 18.2861 18.2861h36.5713zM274.285 192.001c10.0996 0 18.2861 -8.1875 18.2861 -18.2861c0 -10.0996 -8.18652 -18.2861 -18.2861 -18.2861h-36.5703
c-10.0996 0 -18.2861 8.18652 -18.2861 18.2861c0 10.0986 8.18652 18.2861 18.2861 18.2861h36.5703zM402.286 192.001c10.0977 0 18.2861 -8.1875 18.2861 -18.2861c0 -10.0996 -8.1875 -18.2861 -18.2861 -18.2861h-36.5713
c-10.0996 0 -18.2861 8.18652 -18.2861 18.2861c0 10.0986 8.18652 18.2861 18.2861 18.2861h36.5713zM146.285 118.857c10.0996 0 18.2861 -8.18652 18.2861 -18.2861s-8.18652 -18.2861 -18.2861 -18.2861h-36.5713c-10.0986 0 -18.2861 8.18652 -18.2861 18.2861
s8.1875 18.2861 18.2861 18.2861h36.5713zM274.285 118.857c10.0996 0 18.2861 -8.18652 18.2861 -18.2861s-8.18652 -18.2861 -18.2861 -18.2861h-36.5703c-10.0996 0 -18.2861 8.18652 -18.2861 18.2861s8.18652 18.2861 18.2861 18.2861h36.5703zM402.286 118.857
c10.0977 0 18.2842 -8.18652 18.2861 -18.2861c0 -10.0996 -8.1875 -18.2861 -18.2861 -18.2861h-36.5713c-10.0996 0 -18.2861 8.18652 -18.2861 18.2861s8.18652 18.2861 18.2861 18.2861h36.5713zM146.285 45.7139c10.0996 0 18.2861 -8.18555 18.2861 -18.2842
c0 -10.0996 -8.18652 -18.2861 -18.2861 -18.2861h-36.5713c-10.0986 0 -18.2861 8.18555 -18.2861 18.2842c0 10.0996 8.1875 18.2861 18.2861 18.2861h36.5713zM274.285 45.7139c10.0996 0 18.2861 -8.18652 18.2861 -18.2861
c0 -10.0986 -8.18652 -18.2852 -18.2861 -18.2852h-36.5703c-10.0996 0 -18.2861 8.18652 -18.2861 18.2852c0 10.0996 8.18652 18.2861 18.2861 18.2861h36.5703zM402.286 45.7139c10.0977 0 18.2861 -8.18652 18.2861 -18.2861
c0 -10.0986 -8.1875 -18.2852 -18.2861 -18.2852h-36.5713c-10.0996 0 -18.2861 8.18652 -18.2861 18.2852c0 10.0996 8.18652 18.2861 18.2861 18.2861h36.5713z" />
<glyph glyph-name="050-more-ver" unicode="&#xf131;" horiz-adv-x="511"
d="M301.254 429.255c24.9941 -24.9932 24.9941 -65.5156 0 -90.5098c-24.9922 -24.9932 -65.5156 -24.9932 -90.5078 0c-24.9941 24.9941 -24.9941 65.5166 0 90.5098c24.9922 24.9941 65.5137 24.9941 90.5078 0zM301.254 237.254
c24.9941 -24.9932 24.9941 -65.5156 0 -90.5088c-24.9922 -24.9941 -65.5156 -24.9941 -90.5078 0c-24.9941 24.9932 -24.9941 65.5156 0 90.5088c24.9922 24.9941 65.5137 24.9941 90.5078 0zM301.254 45.2559c24.9941 -24.9941 24.9941 -65.5166 0 -90.5098
c-24.9922 -24.9941 -65.5156 -24.9941 -90.5078 0c-24.9941 24.9932 -24.9941 65.5156 0 90.5098c24.9922 24.9932 65.5137 24.9932 90.5078 0z" />
<glyph glyph-name="002-double-arrow-back" unicode="&#xf101;"
d="M279.698 -1.64453c13.916 -13.917 13.916 -36.4873 0 -50.4023c-13.916 -13.9287 -36.4873 -13.9287 -50.4092 0l-218.859 218.85c-6.9541 6.95801 -10.4297 16.0801 -10.4297 25.2002c0 9.11914 3.48145 18.2402 10.4297 25.1943l218.859 218.854
c13.9219 13.9219 36.4932 13.9219 50.4092 0c13.916 -13.917 13.916 -36.4844 0 -50.4072l-193.647 -193.641zM307.916 192.003l193.648 -193.647c13.916 -13.915 13.916 -36.4863 0 -50.4023c-13.916 -13.9287 -36.4883 -13.9287 -50.4102 0l-218.858 218.85
c-6.94824 6.95801 -10.4307 16.0781 -10.4307 25.2002c0 9.11914 3.47559 18.2334 10.4307 25.1943l218.858 218.854c13.9219 13.9219 36.4941 13.9219 50.4102 0c13.916 -13.916 13.916 -36.4854 0 -50.4072z" />
<glyph glyph-name="062-gear" unicode="&#xf13d;"
d="M487.322 236.052c14.2949 -2.86719 24.6777 -15.5225 24.6777 -30.1045v-27.873c0 -14.6035 -10.3828 -27.2588 -24.6787 -30.1279l-38.7695 -7.74121c-10.0146 -2.00684 -18.3496 -8.92871 -22.3232 -18.5342c-4.11621 -9.97266 -3.0918 -20.7246 2.56055 -29.2041
l21.9141 -32.8701c8.11035 -12.165 6.49219 -28.4463 -3.85059 -38.7686l-19.7012 -19.6807c-10.2812 -10.3223 -26.583 -11.9609 -38.748 -3.85059l-32.8701 21.8936c-8.47949 5.67285 -19.21 6.71777 -28.7949 2.72363
c-10.0156 -4.11719 -16.9375 -12.4521 -18.9443 -22.4668l-7.74121 -38.748c-2.86719 -14.3164 -15.5234 -24.6992 -30.126 -24.6992h-27.8525c-14.6035 0 -27.2588 10.3828 -30.1279 24.6787l-7.74121 38.7695c-2.00684 10.0146 -8.92871 18.3496 -18.5342 22.3027
c-9.95312 4.13672 -20.7051 3.13281 -29.2041 -2.55957l-32.8701 -21.8936c-12.1445 -8.10938 -28.4463 -6.45117 -38.7686 3.85059l-19.6807 19.7012c-10.3428 10.3018 -11.9609 26.583 -3.85059 38.748l21.8936 32.8701c5.67285 8.47949 6.69629 19.2314 2.72363 28.7949
c-4.11719 10.0156 -12.4521 16.9375 -22.4668 18.9443l-38.748 7.74121c-14.3164 2.86719 -24.6992 15.5234 -24.6992 30.126v27.873c0 14.582 10.3828 27.2373 24.6777 30.1064l38.7695 7.74121c10.0146 2.00684 18.3496 8.92871 22.3027 18.5342
c4.13672 9.97266 3.11328 20.7246 -2.55957 29.2041l-21.8936 32.8701c-8.11035 12.165 -6.49219 28.4463 3.85059 38.7686l19.7012 19.7012c10.3018 10.3223 26.6035 11.9199 38.748 3.83008l32.8701 -21.8936c8.47949 -5.69336 19.2314 -6.69629 28.7949 -2.72363
c10.0156 4.11719 16.9375 12.4521 18.9443 22.4668l7.74121 38.748c2.84668 14.3164 15.5234 24.6992 30.126 24.6992h27.8525c14.6035 0 27.2803 -10.3828 30.1279 -24.6787l7.74121 -38.7695c2.00684 -10.0146 8.92871 -18.3496 18.5342 -22.3027
c9.93164 -4.0957 20.6836 -3.0918 29.2041 2.55957l32.8701 21.8936c12.1436 8.10938 28.4463 6.49219 38.748 -3.83008l19.7012 -19.7217c10.3428 -10.3018 11.9609 -26.583 3.85059 -38.748l-21.9141 -32.8701c-5.65234 -8.47949 -6.67578 -19.2314 -2.70312 -28.7949
c4.11719 -10.0156 12.4521 -16.9375 22.4668 -18.9443zM256 120.32c39.5264 0 71.6797 32.1533 71.6797 71.6797s-32.1533 71.6797 -71.6797 71.6797s-71.6797 -32.1533 -71.6797 -71.6797s32.1533 -71.6797 71.6797 -71.6797z" />
<glyph glyph-name="042-bold-wide-arrow-next" unicode="&#xf129;"
d="M202.954 -45.5859c-24.6055 24.5547 -24.6055 64.4268 0 89.0332l85.5693 85.6221h-217.202c-34.7842 0 -62.957 28.1729 -62.957 62.957s28.1729 62.957 62.957 62.957h217.202l-85.5693 85.5693c-24.6055 24.6055 -24.6055 64.4268 0 89.0322
c24.6064 24.5537 64.4795 24.5537 89.085 0l193.069 -193.069v0c4.19727 -4.19727 7.81641 -9.02441 10.7021 -14.2178c3.88281 -6.97754 6.13867 -14.6377 7.29297 -22.5078c0.628906 -4.0918 0.628906 -8.60352 0.367188 -12.748
c-0.945312 -14.585 -8.08008 -29.2754 -18.3633 -39.5586v0l-193.068 -193.069c-12.3291 -12.2764 -28.3838 -18.415 -44.543 -18.415c-16.1582 0 -32.2646 6.13965 -44.542 18.415z" />
<glyph glyph-name="006-arrow-next" unicode="&#xf105;"
d="M398.23 211.979c5.28223 -5.28711 8.18359 -12.3467 8.16699 -19.8848c0.0166016 -7.50977 -2.88477 -14.5732 -8.16699 -19.8643l-228.059 -228.05c-5.26953 -5.27441 -12.3047 -8.17969 -19.8057 -8.17969s-14.5312 2.90527 -19.8057 8.17969l-16.7793 16.7705
c-5.26953 5.27832 -8.17871 12.3086 -8.17871 19.8145c0 7.49609 2.90918 14.5273 8.17871 19.8008l191.541 191.541l-191.328 191.328c-10.9189 10.9141 -10.9189 28.6797 0 39.6104l16.7793 16.7754c5.26953 5.27441 12.3047 8.17969 19.8057 8.17969
s14.5352 -2.90527 19.8057 -8.17969z" />
<glyph glyph-name="032-bold-double-arrow-next" unicode="&#xf11f;"
d="M26.2568 -49.7578c-27.1133 22.1016 -31.1748 61.9805 -9.125 89.04l125.016 153.448l-123.803 151.972c-22.0498 27.1133 -17.9873 66.9912 9.12598 89.0938c27.1133 22.0488 66.9385 17.9873 89.041 -9.12598l156.349 -191.954
c18.9902 -23.3154 18.9902 -56.7061 0 -79.9688l-157.562 -193.433c-12.501 -15.3496 -30.7529 -23.3145 -49.1094 -23.3145c-14.0312 0 -28.168 4.69434 -39.9316 14.2422zM338.533 424.671l156.297 -191.903c18.9902 -23.3154 18.9902 -56.7051 0 -79.9678
l-157.562 -193.433c-12.502 -15.3496 -30.7529 -23.3154 -49.1094 -23.3154c-14.0312 0 -28.1689 4.69531 -39.9316 14.2432c-27.0605 22.0488 -31.1748 61.9277 -9.07324 89.041l125.017 153.448l-123.75 151.971c-22.1025 27.1133 -18.041 66.9395 9.07227 89.041
c27.1133 22.0498 66.9912 17.9883 89.041 -9.125z" />
<glyph glyph-name="038-bold-arrow-up" unicode="&#xf125;"
d="M496.702 53.2354c-23.124 -25.1934 -62.3594 -26.0156 -86.7266 -1.64844l-125.14 125.141c-16.1074 16.1064 -42.1309 16.1064 -58.2334 0l-125.14 -125.141c-23.959 -23.9541 -63.1895 -23.1318 -86.3184 2.06543c-21.8887 23.541 -19.4111 61.124 3.30469 83.8398
l195.346 195.351c11.5684 11.5635 26.8457 17.3457 42.127 17.3457s30.5615 -5.78223 42.126 -17.3457l195.351 -195.764c23.1318 -22.7158 25.1973 -59.8857 3.30371 -83.8438z" />
<glyph glyph-name="051-bold-more-ver" unicode="&#xf132;"
d="M256.017 311.012c-37.751 0 -68.2695 30.6523 -68.2695 68.5049c0 37.8193 30.5186 68.4834 68.2695 68.4834c37.6621 0 68.2139 -30.6523 68.2139 -68.4834c0 -37.8408 -30.5635 -68.5049 -68.2139 -68.5049zM256.028 123.512
c-37.752 0 -68.2812 30.6523 -68.2812 68.5049c0 37.8076 30.5186 68.5059 68.2695 68.5059c37.6621 0 68.2139 -30.6865 68.2363 -68.5059c-0.0224609 -37.875 -30.5967 -68.5049 -68.2246 -68.5049zM256.028 -64c-37.752 0 -68.2812 30.6523 -68.2812 68.5059
c0 37.8525 30.5186 68.4941 68.2695 68.4941c37.6621 0.0224609 68.2139 -30.6533 68.2139 -68.4941c0 -37.8418 -30.5742 -68.5059 -68.2021 -68.5059z" />
<glyph glyph-name="029-bold-triangle-top" unicode="&#xf11c;"
d="M273.35 325.153l231.298 -231.302c15.6963 -15.7002 4.54395 -42.1299 -17.3477 -42.1299h-462.604c-21.8906 0 -33.043 26.4297 -17.3477 42.1299l231.303 231.302c9.5 9.5 25.1953 9.5 34.6992 0z" />
<glyph glyph-name="043-bold-wide-arrow-back" unicode="&#xf12a;"
d="M220.246 -45.998l-197.331 197.646c-23.209 23.2617 -23.209 58.0752 0 81.2334l197.331 197.227c23.1572 23.1572 59.4346 24.1504 82.4355 1.15039c22.4775 -22.4775 20.0723 -60.376 -1.77734 -82.1738l-96.3916 -96.3916s243.123 -0.313477 243.228 -0.313477
c16.0996 0 31.625 -6.27344 42.6543 -17.9824c26.4512 -28.2275 19.3945 -77.626 -14.4795 -96.3916c-8.57227 -4.75781 -18.3477 -7.2666 -28.1748 -7.21484l-243.227 0.208984s96.3389 -96.2871 96.3906 -96.3389c21.9023 -21.9023 24.0986 -59.6445 1.77734 -82.0166
c-23.001 -23.001 -59.2783 -21.7988 -82.4355 1.3584z" />
<glyph glyph-name="007-arrow-back" unicode="&#xf106;"
d="M206.682 191.892l191.325 -191.325c10.9189 -10.915 10.9189 -28.6885 0 -39.6074l-16.7793 -16.7754c-5.27344 -5.27832 -12.3047 -8.18359 -19.8057 -8.18359c-7.50488 0 -14.5352 2.90527 -19.8057 8.18359l-227.844 227.832
c-5.29492 5.29004 -8.18848 12.3457 -8.16699 19.8887c-0.0214844 7.50977 2.87598 14.5732 8.16699 19.8594l228.057 228.053c5.26953 5.27832 12.2998 8.18359 19.8008 8.18359s14.54 -2.90527 19.8057 -8.18359l16.7842 -16.7754
c5.27344 -5.26953 8.1748 -12.3008 8.1748 -19.8057c0 -7.50098 -2.90137 -14.5361 -8.1748 -19.7969z" />
<glyph glyph-name="063-info" unicode="&#xf13e;"
d="M173.997 259.018c0 17.457 14.1445 31.6543 31.6543 31.6543h51.9521h30.1299h18.5098c17.457 0 31.6543 -14.1445 31.6543 -31.6543v-291.362c0 -17.457 -14.1445 -31.6543 -31.6543 -31.6543h-57.1055c-17.458 0 -31.6553 14.1445 -31.6553 31.6543v202.602h-11.8311
c-17.457 0 -31.6543 14.1455 -31.6543 31.6553v57.1055zM217.482 387.74c0 33.2803 26.9795 60.2598 60.2607 60.2598c33.2803 0 60.2598 -26.9795 60.2598 -60.2598c0 -33.2812 -26.9795 -60.2598 -60.2598 -60.2598c-33.2812 0 -60.2607 26.9785 -60.2607 60.2598z" />
<glyph glyph-name="055-drag" unicode="&#xf136;"
d="M42.6689 277.331h42.6621v-42.6621h-42.6621v-42.6689h-42.6689v42.6689c0 23.4561 19.2061 42.6621 42.6689 42.6621zM469.331 448c23.4629 0 42.6689 -19.2061 42.6689 -42.6689v-255.955c0 -23.4561 -19.2061 -42.6689 -42.6689 -42.6689h-127.975v42.624h-42.6689
v-42.624h-85.3564c-23.4561 0 -42.6621 19.2129 -42.6621 42.6689v85.293h42.6621v42.6621h-42.6621v128c0 23.4629 19.2061 42.6689 42.6621 42.6689h256zM298.688 192h42.6689v42.6689c0 23.4561 -19.2129 42.6621 -42.6689 42.6621h-42.6875v-42.6621h42.6875v-42.6689z
M128 -63.9814v42.6689h85.3311v-42.6689h-85.3311zM298.688 21.3311h42.6689v-42.6621c0 -23.4629 -19.2129 -42.6689 -42.6689 -42.6689h-42.6875v42.6689h42.6875v42.6621zM42.6689 -21.3311h42.6621v-42.6689h-42.6621c-23.4629 0 -42.6689 19.2061 -42.6689 42.6689
v42.6621h42.6689v-42.6621zM0 64v85.3311h42.6689v-85.3311h-42.6689z" />
<glyph glyph-name="014-plus" unicode="&#xf10d;"
d="M483.969 231.996c15.4434 0 28.0312 -12.4873 28.0312 -27.9346v-24.1934c0 -15.4307 -12.5879 -27.8691 -28.0312 -27.8691h-187.966v-188.324c0 -15.4307 -12.5049 -27.6729 -27.9482 -27.6729h-24.0557c-15.4434 0 -27.9971 12.2383 -27.9971 27.6729v188.324h-188
c-15.4395 0 -28.002 12.4346 -28.002 27.8691v24.1934c0 15.4473 12.5664 27.9346 28.002 27.9346h187.995v187.646c0 15.4434 12.5586 28.3555 27.998 28.3555h24.0557c15.4434 0 27.9473 -12.9121 27.9473 -28.3555v-187.646h188h-0.0292969z" />
<glyph glyph-name="016-round" unicode="&#xf10f;"
d="M483.799 152.03c0.0546875 0 0.108398 -0.00390625 0.163086 -0.00390625h0.0566406zM503.679 143.631c5.32031 -5.29102 8.32129 -12.4502 8.32129 -19.9229v-139.634c0 -15.4268 -12.6797 -27.9023 -28.1055 -27.9023h-23.7061
c-15.4268 0 -28.1309 12.4756 -28.1309 27.9023v22.6738c-47.9658 -44.998 -111.121 -70.6318 -176.249 -70.6318c-141.092 0 -255.809 114.788 -255.809 255.884s114.792 255.884 255.884 255.884c105.613 0 201.765 -66.3193 239.266 -165.015
c7.83008 -20.6006 -2.55957 -43.7363 -23.1641 -51.5576c-4.5625 -1.74023 -9.33691 -2.62207 -14.1865 -2.62207c-16.4922 0 -31.5146 10.3643 -37.376 25.7832c-25.7793 67.8711 -91.9023 113.473 -164.535 113.473c-97.0137 0 -175.937 -78.9277 -175.937 -175.945
s78.9355 -175.945 175.944 -175.945c48.9023 0 95.3369 20.3262 128.7 56.0537l-40.6396 0.00878906c-7.44727 0 -14.415 2.91797 -19.71 8.2168s-8.10449 12.3174 -8.10449 19.7637v23.7061c0 15.4229 12.333 28.2227 27.7598 28.2227h140.061
c7.41504 -0.0449219 14.4482 -3.10352 19.7168 -8.39551z" />
<glyph glyph-name="027-bold-triangle-bottom" unicode="&#xf11a;"
d="M487.3 332.278c21.8916 0 33.0439 -26.4346 17.3525 -42.1299l-231.303 -231.302c-9.50391 -9.5 -25.1992 -9.5 -34.6992 0l-231.303 231.302c-15.6953 15.6953 -4.54297 42.1299 17.3477 42.1299h462.604z" />
<glyph glyph-name="048-bold-check-1" unicode="&#xf12f;"
d="M231.762 18.5566c-23.2129 -23.2129 -60.8789 -23.2129 -84.0801 0l-130.271 130.271c-23.2139 23.2021 -23.2139 60.8672 0 84.0693c23.2012 23.2129 60.8672 23.2129 84.0801 0l77.6162 -77.6045c5.85938 -5.84863 15.3711 -5.84863 21.2412 0l210.162 210.161
c23.2021 23.2139 60.8672 23.2139 84.0801 0c11.1475 -11.1465 17.4102 -26.2715 17.4102 -42.0342s-6.2627 -30.8877 -17.4102 -42.0352z" />
<glyph glyph-name="001-double-arrow-next" unicode="&#xf100;"
d="M425.955 192.005l-193.651 193.641c-13.916 13.9219 -13.916 36.4912 0 50.4072c13.916 13.9219 36.4844 13.9219 50.4062 0l218.856 -218.854c6.95801 -6.96191 10.4336 -16.0762 10.4336 -25.1943c0 -9.12207 -3.48145 -18.2432 -10.4336 -25.2012l-218.856 -218.852
c-13.9219 -13.9287 -36.4912 -13.9287 -50.4062 0c-13.916 13.916 -13.916 36.4873 0 50.4023zM10.4365 -1.64551l193.656 193.65l-193.656 193.641c-13.916 13.9229 -13.916 36.4912 0 50.4072c13.916 13.9219 36.4785 13.9219 50.4072 0l218.857 -218.854
c6.95117 -6.95508 10.4277 -16.0762 10.4277 -25.1953c0 -9.12207 -3.46973 -18.2422 -10.4277 -25.2012l-218.857 -218.85c-13.9297 -13.9287 -36.4912 -13.9287 -50.4072 0c-13.916 13.915 -13.916 36.4854 0 50.4023z" />
<glyph glyph-name="021-solid-minus" unicode="&#xf114;"
d="M482.883 418.875c19.4053 -19.4072 29.1172 -42.8877 29.1172 -70.4385v-312.873c0 -27.2637 -9.70605 -50.6709 -29.1172 -70.2207c-19.4131 -19.5625 -42.8838 -29.332 -70.4355 -29.332h-312.894c-27.5527 0 -51.0361 9.77148 -70.4424 29.332
c-19.4102 19.5488 -29.1113 42.9561 -29.1113 70.2207v312.873c0 27.5508 9.70605 51.0312 29.1113 70.4365c19.4004 19.4102 42.8857 29.1162 70.4424 29.1162h312.894c27.5518 0 51.0225 -9.70605 70.4355 -29.1143zM455.105 35.5498v312.879
c0 11.8506 -4.1543 21.9268 -12.4434 30.2207c-8.28809 8.29883 -18.3682 12.4443 -30.2148 12.4443h-312.894c-11.8516 0 -21.9277 -4.15234 -30.2207 -12.4443c-8.29688 -8.29395 -12.4404 -18.3701 -12.4404 -30.2207v-312.879
c0 -11.8525 4.14355 -21.9316 12.4404 -30.2207c8.30078 -8.30078 18.3691 -12.4443 30.2207 -12.4443h312.883c11.8477 0 21.9268 4.14355 30.2148 12.4443c8.29395 8.28906 12.4541 18.3682 12.4541 30.2207zM384 220.435c4.14355 0 7.55371 -1.33691 10.2158 -3.99707
c2.66309 -2.66211 3.99023 -6.07617 3.99023 -10.2227v-28.4414c0 -4.15039 -1.3252 -7.55762 -3.99023 -10.2168c-2.66211 -2.65918 -6.07227 -4.00098 -10.2158 -4.00098h-255.998c-4.14648 0 -7.55566 1.3418 -10.2227 4.00098
c-2.66797 2.65918 -3.99707 6.06641 -3.99707 10.2168v28.4414c0 4.14648 1.33398 7.55566 3.99707 10.2227c2.66016 2.66602 6.07617 3.99707 10.2227 3.99707h255.998z" />
<glyph glyph-name="018-refresh" unicode="&#xf111;"
d="M499.001 425.004c8.67676 -3.79004 12.999 -10.3457 13.001 -19.6758v-149.335c0 -5.77832 -2.10938 -10.7773 -6.32715 -14.999c-4.22461 -4.22266 -9.22266 -6.33203 -15 -6.33203h-149.331c-9.33789 0 -15.8936 4.44336 -19.6777 13.333
c-3.77051 8.66504 -2.2207 16.333 4.67188 23.001l46.0098 45.9922c-32.8896 30.4453 -71.6699 45.668 -116.337 45.668c-23.1074 0 -45.1689 -4.50195 -66.1641 -13.5c-21.002 -9.00098 -39.166 -21.168 -54.501 -36.502c-15.334 -15.3301 -27.5 -33.499 -36.498 -54.4961
c-8.99512 -20.9951 -13.5 -43.0566 -13.5 -66.168c0 -23.1123 4.50098 -45.1631 13.5 -66.1631c9.00195 -21.0049 21.168 -39.166 36.498 -54.502c15.3301 -15.334 33.499 -27.5 54.501 -36.4971c21 -9.00098 43.0566 -13.501 66.1641 -13.501
c26.4453 0 51.4443 5.77344 74.9971 17.3242c23.5566 11.5645 43.4453 27.8936 59.667 49.0088c1.55762 2.22168 4.11133 3.55957 7.66602 4.00293c3.11328 0 5.88184 -0.998047 8.33496 -2.99512l45.6592 -45.999c1.99805 -1.77832 3.06055 -4.05566 3.16504 -6.83203
c0.114258 -2.77734 -0.719727 -5.27832 -2.49414 -7.5c-24.2275 -29.335 -53.5615 -52.0605 -88.0029 -68.166c-34.4395 -16.1123 -70.7822 -24.167 -109.002 -24.167c-34.665 0 -67.7754 6.78027 -99.3311 20.3359c-31.5576 13.5566 -58.7803 31.7783 -81.668 54.6611
c-22.8906 22.8887 -41.1094 50.1104 -54.668 81.6689c-13.5547 31.5566 -20.334 64.6699 -20.334 99.3301c0 34.6699 6.7793 67.7764 20.333 99.334c13.5547 31.5547 31.7783 58.7744 54.668 81.665c22.8877 22.8896 50.1104 41.1084 81.668 54.6631
c31.5547 13.5537 64.665 20.334 99.3311 20.334c32.667 0 64.2793 -6.16602 94.835 -18.499c30.5566 -12.333 57.7217 -29.7207 81.499 -52.166l43.333 43.0039c6.44824 6.88965 14.2236 8.43945 23.334 4.66895z" />
<glyph glyph-name="023-code" unicode="&#xf116;"
d="M81.0293 55.4961l-76.4912 119.716c-6.05078 9.46582 -6.05078 21.5898 0 31.0557l76.4971 119.712c8.57715 13.4229 26.4004 17.3457 39.8291 8.77344c13.4229 -8.57715 17.3506 -26.4062 8.77344 -39.8281l-66.5703 -104.185l66.5645 -104.195
c8.57617 -13.4189 4.64844 -31.252 -8.77441 -39.8301c-4.80371 -3.07422 -10.1855 -4.53906 -15.498 -4.53906c-9.51758 0 -18.8281 4.70605 -24.3301 13.3203zM391.143 46.7217c-13.4229 8.57715 -17.3506 26.4131 -8.77539 39.8301l66.5762 104.189l-66.5703 104.185
c-8.57617 13.416 -4.64844 31.251 8.77441 39.8281c13.4102 8.58398 31.2568 4.65527 39.8291 -8.77344l76.4854 -119.712c6.05078 -9.46582 6.05078 -21.5898 0 -31.0557l-76.4922 -119.712c-5.50098 -8.61914 -14.8174 -13.3184 -24.3291 -13.3184
c-5.31152 0 -10.6875 1.46289 -15.498 4.53906zM156.143 -16.2793c-14.3633 6.88184 -20.4199 24.1113 -13.5332 38.4746l174.828 364.552c6.88086 14.3623 24.1055 20.4189 38.4736 13.5322c14.3623 -6.88184 20.4189 -24.1104 13.5322 -38.4736l-174.822 -364.553
c-4.95996 -10.3428 -15.2803 -16.376 -26.0254 -16.376c-4.18164 0 -8.42676 0.918945 -12.4531 2.84375z" />
<glyph glyph-name="017-reload" unicode="&#xf110;"
d="M503.758 139.743c5.32812 -5.29492 8.24219 -12.3213 8.24219 -19.7979v-139.738c0 -15.4395 -12.5088 -28.21 -27.9521 -28.21h-23.7178c-15.4434 0 -28.3311 12.7705 -28.3311 28.21v26.2451c-47.999 -45.4854 -109.727 -70.4062 -176.465 -70.4062
c-108.698 0 -205.683 68.8906 -241.581 171.437c-3.53027 10.0781 -2.92188 20.9385 1.71484 30.5615c4.63281 9.62012 12.7373 16.8594 22.8232 20.3926c4.27539 1.49512 8.72559 2.23145 13.2168 2.23145c16.9707 0 32.1436 -10.8018 37.7549 -26.832
c24.6797 -70.502 91.4854 -117.951 166.237 -117.951c47.3076 0 92.4561 20.1133 125.282 52.1152h-37.0312c-7.44629 0 -14.4482 3.08496 -19.7471 8.37988c-5.30762 5.30273 -8.2041 12.4795 -8.2041 19.9385v23.7227c0 15.4346 12.5205 27.96 27.9512 27.96h140.001
c7.47656 0 14.499 -2.92188 19.8057 -8.25781zM498.084 276.521c3.53027 -10.0859 2.91797 -20.9375 -1.71484 -30.5654c-4.63281 -9.62402 -12.7334 -16.8594 -22.8232 -20.3926c-4.2793 -1.49902 -8.7207 -2.23145 -13.2158 -2.23145
c-16.9756 0 -32.1445 10.7979 -37.7549 26.8281c-24.6807 70.5059 -91.4814 117.955 -166.233 117.955c-47.3086 0 -92.46 -20.1094 -125.286 -52.1113h37.0215c15.4434 0 27.9229 -12.8828 27.9229 -28.3223v-23.7266c0 -15.4355 -12.4795 -27.9521 -27.9229 -27.9521
h-140.075l0.0830078 0.0625c-7.47559 0 -14.5146 2.91797 -19.8184 8.25391c-5.32812 5.30371 -8.2666 12.2637 -8.2666 19.7354v139.734c0 15.4346 12.5498 28.2139 27.9893 28.2139h23.7227c15.4434 0 28.2891 -12.7793 28.2891 -28.2139v-26.2451
c47.999 45.4844 109.738 70.4102 176.481 70.4102c108.698 0 205.704 -68.8906 241.602 -171.433z" />
<glyph glyph-name="047-bold-long-arrow-next" unicode="&#xf12e;"
d="M502.649 209.764c12.8037 -9.91211 12.3906 -29.3242 -0.826172 -38.8232l-159.427 -123.907c-16.1084 -12.8037 -40.8896 -1.23926 -40.8896 19.4121v42.541c0 13.626 -9.08691 24.3682 -22.7158 24.3682h-254.01c-13.6299 0 -24.7812 11.5654 -24.7812 24.7822v66.9092
c0 14.043 11.1514 23.9551 24.7812 23.9551h254.01c13.6289 0 22.7158 11.9775 22.7158 25.6074v42.542c0 21.0635 24.7812 32.6289 41.3027 19.8252z" />
<glyph glyph-name="066-calendar-today" unicode="&#xf141;"
d="M128 448c10.0986 0 18.2852 -8.18652 18.2852 -18.2861v-36.5713h-36.5713v36.5713c0 10.0996 8.1875 18.2861 18.2861 18.2861zM384 448c10.0986 0 18.2861 -8.18652 18.2852 -18.2861v-36.5713h-36.5713v36.5713c0 10.0996 8.18652 18.2861 18.2861 18.2861z
M457.143 393.143c30.2969 0 54.8574 -24.5596 54.8574 -54.8564v-347.429c0 -30.2969 -24.5605 -54.8574 -54.8574 -54.8574h-402.285c-30.2969 0 -54.8574 24.5605 -54.8574 54.8574v347.429c0 30.2969 24.5605 54.8564 54.8574 54.8564h54.8555v-73.1426
c0 -10.0996 8.1875 -18.2861 18.2861 -18.2861c10.0996 0 18.2861 8.18652 18.2861 18.2861v73.1426h219.429v-73.1426c0 -10.0996 8.18652 -18.2861 18.2861 -18.2861c10.0986 0 18.2861 8.18652 18.2861 18.2861v73.1426h54.8564zM475.428 -9.14258v237.714h-438.856
v-237.714c0 -10.0996 8.18652 -18.2861 18.2861 -18.2861h402.284c10.0996 0 18.2861 8.18652 18.2861 18.2861zM378.651 186.634c7.13965 -7.14551 7.13574 -18.7256 -0.00878906 -25.8652l-146.286 -146.286c-7.14062 -7.13965 -18.7148 -7.13965 -25.8555 0
l-73.1436 73.1426c-7.14551 7.14062 -7.14941 18.7207 -0.00976562 25.8652c7.13965 7.14551 18.7197 7.15039 25.8652 0.00976562l60.2158 -60.2148l133.357 133.358c7.14551 7.13867 18.7246 7.13574 25.8652 -0.00976562z" />
<glyph glyph-name="041-bold-wide-arrow-up" unicode="&#xf128;"
d="M300.509 421.076l193.039 -193.039c24.6016 -24.6016 24.6016 -64.416 0 -89.0176c-12.3271 -12.2754 -28.3789 -18.4131 -44.5361 -18.4131c-16.1553 0 -32.2598 6.13867 -44.5342 18.4131l-85.5566 85.5557v-217.169c0 -34.7783 -28.1689 -62.9473 -62.9482 -62.9473
c-34.7783 0 -62.9473 28.1689 -62.9473 62.9473v217.169l-85.5557 -85.5557c-24.6016 -24.6025 -64.417 -24.6025 -89.0186 0c-24.6016 24.6016 -24.6016 64.416 0 89.0176l193.039 193.039v0c6.1377 6.1377 13.4814 10.9639 21.5068 14.1113
c11.2256 4.40625 23.8672 5.55957 35.6699 3.04199c2.67578 -0.577148 5.35156 -1.31152 7.92188 -2.20312c8.91797 -3.09473 17.2061 -8.23535 23.9199 -14.9502v0z" />
<glyph glyph-name="015-arrow-up" unicode="&#xf10e;"
d="M503.814 106.176c10.9141 -10.918 10.9141 -28.6885 0 -39.6074l-16.7842 -16.7871c-5.27344 -5.26562 -12.2998 -8.1709 -19.8008 -8.1709c-7.50488 0 -14.5361 2.90527 -19.8057 8.1709l-191.537 191.545l-191.325 -191.324
c-5.27344 -5.27051 -12.3047 -8.17969 -19.8057 -8.17969s-14.5352 2.90918 -19.8057 8.17969l-16.7793 16.7793c-5.27344 5.26953 -8.1709 12.3047 -8.1709 19.8057s2.90137 14.5312 8.1709 19.8057l227.844 227.835c5.26172 5.26953 12.292 8.16211 19.793 8.16211
c7.65527 0 14.6816 -2.89258 19.9512 -8.16211z" />
<glyph glyph-name="034-bold-double-arrow-down" unicode="&#xf121;"
d="M23.3037 331.784c-27.0615 22.1035 -31.1758 61.9307 -9.07324 89.0449c22.0498 27.1143 61.9307 31.1768 89.0449 9.12598l151.978 -123.808l153.455 125.021c27.1143 22.0498 66.9941 17.9883 89.0439 -9.12598c22.1035 -27.1152 18.041 -66.9424 -9.07324 -89.0449
l-193.439 -157.569c-11.6582 -9.49512 -25.7959 -14.2432 -39.9863 -14.2432s-28.3271 4.74805 -39.9854 14.2432zM23.3037 109.226c-27.0615 22.0508 -31.1758 61.9297 -9.07324 89.0459c22.0498 27.0615 61.9307 31.1758 89.0449 9.07227l151.978 -123.755
l153.455 124.969c27.1143 22.1025 66.9414 18.04 89.0439 -9.07422c22.1035 -27.1143 18.041 -66.9414 -9.07324 -89.0439l-193.439 -157.569c-11.6582 -9.49512 -25.7959 -14.2432 -39.9863 -14.2432s-28.3271 4.74805 -39.9854 14.2432z" />
<glyph glyph-name="046-bold-long-arrow-back" unicode="&#xf12d;"
d="M9.62109 170.731c-12.8281 9.92773 -12.8281 29.3818 0 39.3135l159.321 127.457c16.5527 12.8281 40.5547 0.827148 40.5547 -19.8643v-42.623c0 -13.6562 10.7549 -25.6572 24.4111 -25.6572h254.5c13.6602 0 23.5879 -10.3457 23.5879 -24.001v-66.626
c0 -13.6553 -9.92773 -25.2432 -23.5879 -25.2432h-254.5c-13.6523 0 -24.4111 -10.7588 -24.4111 -24.415v-43.0371c0 -20.6914 -24.002 -32.2822 -40.1406 -19.4502z" />
<glyph glyph-name="009-check" unicode="&#xf108;"
d="M503.808 339.275c10.9229 -10.915 10.9229 -28.6514 -0.00390625 -39.5537l-254.734 -254.709c-0.137695 -0.225586 -0.366211 -0.446289 -0.636719 -0.711914l-16.7793 -16.6426c-5.26172 -5.25293 -12.293 -7.87988 -19.7979 -7.87988h-0.0869141
c-7.55957 0 -14.5898 2.62695 -19.8604 7.87988l-16.7754 16.4971c-0.270508 0.265625 -0.50293 0.557617 -0.723633 0.869141l-166.238 166.229c-5.27344 5.27441 -8.1709 12.3047 -8.1709 19.8018c0 7.49219 2.89746 14.5273 8.1709 19.8096l16.7754 16.7676
c5.27441 5.26562 12.3008 8.1709 19.7979 8.1709c7.50098 0 14.5312 -2.90527 19.8008 -8.1709l147.235 -147.24l235.658 235.653c5.26953 5.27441 12.2998 8.17578 19.8008 8.17578s14.5273 -2.90137 19.7979 -8.17578z" />
<glyph glyph-name="011-minus" unicode="&#xf10a;"
d="M483.969 232.001c15.4434 0 28.0312 -12.6299 28.0312 -28.0645v-23.7314c0 -15.4346 -12.5879 -28.2061 -28.0312 -28.2061h-455.967c-15.4395 0 -28.002 12.7715 -28.002 28.2061v23.7314c0 15.4346 12.5664 28.0645 28.002 28.0645h455.967z" />
<glyph glyph-name="037-bold-arrow-back" unicode="&#xf124;"
d="M396.727 346.389l-125.144 -125.553c-16.1074 -16.1113 -16.1074 -42.1309 0 -58.2373l124.727 -125.141c23.9541 -23.9502 23.1318 -63.1846 -2.06543 -86.3135c-23.541 -21.8926 -61.124 -19.4111 -83.8389 3.30469l-195.351 195.351
c-11.5645 11.5635 -17.3467 26.8447 -17.3467 42.126s5.78223 30.5576 17.3467 42.126l195.764 195.351c22.7148 23.1279 59.8848 25.1934 83.8438 3.30371c25.6016 -23.124 26.0146 -62.3594 2.06445 -86.3174z" />
<glyph glyph-name="060-menu-grid" unicode="&#xf13b;"
d="M0 382.101c0 36.3955 29.5039 65.8994 65.8994 65.8994c36.3945 0 65.8994 -29.5039 65.8994 -65.8994c0 -36.3945 -29.5049 -65.8994 -65.8994 -65.8994c-36.3955 0 -65.8994 29.5049 -65.8994 65.8994zM190.102 382.101c0 36.3955 29.5039 65.8994 65.8994 65.8994
c36.3945 0 65.8984 -29.5039 65.8984 -65.8994c0 -36.3945 -29.5039 -65.8994 -65.8984 -65.8994c-36.3955 0 -65.8994 29.5049 -65.8994 65.8994zM380.203 382.101c0 36.3955 29.5039 65.8994 65.8984 65.8994c36.3955 0 65.8994 -29.5039 65.8994 -65.8994
c0 -36.3945 -29.5039 -65.8994 -65.8994 -65.8994c-36.3945 0 -65.8984 29.5049 -65.8984 65.8994zM0 191.999c0 36.3955 29.5039 65.8994 65.8994 65.8994c36.3945 0 65.8994 -29.5039 65.8994 -65.8994c0 -36.3945 -29.5049 -65.8984 -65.8994 -65.8984
c-36.3955 0 -65.8994 29.5039 -65.8994 65.8984zM190.102 191.999c0 36.3955 29.5039 65.8994 65.8994 65.8994c36.3945 0 65.8984 -29.5039 65.8984 -65.8994c0 -36.3945 -29.5039 -65.8984 -65.8984 -65.8984c-36.3955 0 -65.8994 29.5039 -65.8994 65.8984z
M380.203 191.999c0 36.3955 29.5039 65.8994 65.8984 65.8994c36.3955 0 65.8994 -29.5039 65.8994 -65.8994c0 -36.3945 -29.5039 -65.8984 -65.8994 -65.8984c-36.3945 0 -65.8984 29.5039 -65.8984 65.8984zM0 1.89844c0 36.3945 29.5039 65.8984 65.8994 65.8984
c36.3945 0 65.8994 -29.5039 65.8994 -65.8984c0 -36.3955 -29.5049 -65.8994 -65.8994 -65.8994c-36.3955 0 -65.8994 29.5039 -65.8994 65.8994zM190.102 1.89844c0 36.3945 29.5039 65.8984 65.8994 65.8984c36.3945 0 65.8984 -29.5039 65.8984 -65.8984
c0 -36.3955 -29.5039 -65.8994 -65.8984 -65.8994c-36.3955 0 -65.8994 29.5039 -65.8994 65.8994zM380.203 1.89844c0 36.3945 29.5039 65.8984 65.8984 65.8984c36.3955 0 65.8994 -29.5039 65.8994 -65.8984c0 -36.3955 -29.5039 -65.8994 -65.8994 -65.8994
c-36.3945 0 -65.8984 29.5039 -65.8984 65.8994z" />
<glyph glyph-name="052-more-hor" unicode="&#xf133;"
d="M493.254 237.255c24.9941 -24.9932 24.9941 -65.5156 0 -90.5088c-24.9932 -24.9941 -65.5156 -24.9941 -90.5098 0c-24.9932 24.9932 -24.9932 65.5156 0 90.5088c24.9941 24.9941 65.5166 24.9941 90.5098 0zM301.255 237.255
c24.9941 -24.9932 24.9941 -65.5156 0 -90.5088c-24.9932 -24.9941 -65.5156 -24.9941 -90.5088 0c-24.9941 24.9932 -24.9941 65.5156 0 90.5088c24.9932 24.9941 65.5156 24.9941 90.5088 0zM109.256 237.255c24.9932 -24.9932 24.9932 -65.5156 0 -90.5088
c-24.9941 -24.9941 -65.5166 -24.9941 -90.5098 0c-24.9941 24.9932 -24.9941 65.5156 0 90.5088c24.9932 24.9941 65.5156 24.9941 90.5098 0z" />
<glyph glyph-name="008-long-arrow-next" unicode="&#xf107;"
d="M503.816 211.893c5.29492 -5.28613 8.2002 -12.3584 8.18359 -19.8965c0.0205078 -7.51367 -2.88867 -14.582 -8.17969 -19.877l-184.901 -184.9c-5.27734 -5.27832 -12.3125 -8.19238 -19.8135 -8.19238c-7.50879 0 -14.54 2.87598 -19.8184 8.1543l-16.7871 16.7383
c-5.27441 5.27344 -8.17969 12.2207 -8.17969 19.7178c0 7.50488 2.90527 14.3525 8.17969 19.6309l109.088 108.709h-343.928c-15.4512 0 -27.6602 13.3164 -27.6602 28.7725v23.7383c0 15.4561 12.209 27.5527 27.6602 27.5527h342.704l-107.868 108.105
c-5.27441 5.27051 -8.17969 12.5459 -8.17969 20.0469c0 7.50586 2.90527 14.5439 8.17969 19.8145l16.7871 16.791c5.27832 5.27441 12.3086 8.17578 19.8184 8.17578c7.50098 0 14.5352 -2.89746 19.8135 -8.17578z" />
<glyph glyph-name="010-arrow-down" unicode="&#xf109;"
d="M503.816 317.933c5.27832 -5.2666 8.18359 -12.4014 8.18359 -19.9062c-0.00878906 -7.50098 -2.90527 -14.7354 -8.18359 -20.0059l-227.834 -228.245c-5.26953 -5.26562 -12.292 -8.9834 -19.7969 -8.9834h-0.0878906c-7.56738 0 -14.5898 3.71289 -19.8594 8.9834
l-228.046 228.865c-10.9229 10.915 -10.9229 28.6934 0 39.6084l16.7754 16.7832c5.27344 5.27441 12.3086 8.17578 19.8096 8.17578s14.5322 -2.90137 19.8018 -8.17578l191.535 -191.535l191.314 191.323c5.27832 5.27344 12.3096 8.17969 19.8105 8.17969
c7.50488 0 14.5273 -2.90625 19.8057 -8.17969z" />
<glyph glyph-name="031-bold-double-arrow-up" unicode="&#xf11e;"
d="M63.3213 -59.9365c-18.3584 0 -36.5596 7.96582 -49.1152 23.3184c-22.0527 27.1172 -17.9902 67 9.12598 89.0518l191.927 156.422c23.2656 18.9922 56.7129 18.9922 79.9785 0l193.457 -157.635c27.0635 -22.0527 31.1777 -61.9355 9.07324 -89.0527
c-12.5029 -15.3516 -30.7568 -23.3184 -49.1162 -23.3184c-14.0322 0 -28.1709 4.69531 -39.9365 14.2441l-153.467 125.032l-151.99 -123.818c-11.7119 -9.60156 -25.9033 -14.2441 -39.9365 -14.2441zM63.3213 162.113c-18.3584 0 -36.5596 7.9668 -49.1152 23.3184
c-22.0527 27.1172 -17.9902 67 9.12598 89.1055l191.927 156.369c23.2656 18.9922 56.7129 18.9922 79.9785 0l193.457 -157.636c27.0635 -22.0518 31.1777 -61.9355 9.07324 -89.0518c-12.5029 -15.3525 -30.7568 -23.3184 -49.1162 -23.3184
c-14.0322 0 -28.1709 4.69434 -39.9365 14.2441l-153.467 125.032l-151.99 -123.819c-11.7119 -9.60156 -25.9033 -14.2441 -39.9365 -14.2441z" />
<glyph glyph-name="039-bold-check" unicode="&#xf126;"
d="M123.439 7.74023l-102.97 103.797c-27.293 27.293 -27.293 71.9502 0 99.248c27.293 27.293 71.9551 27.293 99.248 0l24.3984 -24.8125c16.1279 -16.1279 42.1807 -16.1279 58.3086 0l190.226 190.226c27.7021 28.1201 73.6045 27.707 100.897 -1.6543
c26.0527 -27.707 23.9854 -71.9551 -2.89453 -98.834l-267.966 -267.97c-13.2324 -13.2334 -31.0146 -20.6768 -49.624 -20.6768c-18.6084 0 -36.3906 7.44336 -49.624 20.6768z" />
<glyph glyph-name="064-calendar-2" unicode="&#xf13f;"
d="M128 166.4v51.1992h51.2002v-51.1992h-51.2002zM230.4 166.4v51.1992h51.1992v-51.1992h-51.1992zM332.8 166.4v51.1992h51.2002v-51.1992h-51.2002zM128 64v51.2002h51.2002v-51.2002h-51.2002zM230.4 64v51.2002h51.1992v-51.2002h-51.1992zM332.8 64v51.2002h51.2002
v-51.2002h-51.2002zM486.4 396.8c12.7998 0 25.5996 -10.2402 25.5996 -25.5996v-409.601c0 -15.3594 -12.7998 -25.5996 -25.5996 -25.5996h-460.801c-15.3594 0 -25.5996 10.2402 -25.5996 25.5996v409.601c0 15.3594 10.2402 25.5996 25.5996 25.5996h51.2002v25.6006
c0 15.3594 10.2402 25.5996 25.6006 25.5996c15.3594 0 25.5996 -10.2402 25.5996 -25.5996v-25.6006h256v25.6006c0 15.3594 10.2402 25.5996 25.5996 25.5996c15.3604 0 25.6006 -10.2402 25.6006 -25.5996v-25.6006h51.2002zM460.8 -12.7998v307.2h-409.6v-307.2h409.6z
" />
<glyph glyph-name="030-bold-triangle-left" unicode="&#xf11d;"
d="M354.148 440.652c15.7002 15.6953 42.1299 4.54297 42.1299 -17.3525v-462.604c0 -21.8906 -26.4297 -33.043 -42.1299 -17.3477l-231.302 231.303c-9.5 9.50391 -9.5 25.1992 0 34.6992z" />
<glyph glyph-name="068-068-more-1-2" unicode="&#xf143;"
d="M403.643 237.255c24.9932 -24.9912 24.9932 -65.5127 0 -90.5059c-24.9922 -24.9932 -65.5137 -24.9932 -90.5059 0c-24.9932 24.9932 -24.9932 65.5146 0 90.5059c24.9922 24.9932 65.5137 24.9932 90.5059 0zM197.937 237.255
c24.9932 -24.9912 24.9932 -65.5127 0 -90.5059s-65.5137 -24.9932 -90.5059 0c-24.9932 24.9932 -24.9932 65.5146 0 90.5059c24.9922 24.9932 65.5127 24.9932 90.5059 0z" />
<glyph glyph-name="058-outline-info" unicode="&#xf139;"
d="M256 448c141.213 0 256 -114.787 256 -256s-114.787 -256 -256 -256s-256 114.787 -256 256s114.787 256 256 256zM256 -2.06445c106.942 0 194.064 87.1221 194.064 194.064s-87.1221 194.064 -194.064 194.064s-194.064 -87.1221 -194.064 -194.064
s87.1221 -194.064 194.064 -194.064zM268.387 353.032c13.626 0 24.7744 -11.1484 24.7744 -24.7744v-169.29c0 -13.626 -11.1484 -24.7744 -24.7744 -24.7744h-24.7734c-13.626 0 -24.7744 11.1484 -24.7744 24.7744v169.29c0 13.626 11.1484 24.7744 24.7744 24.7744
h24.7734zM218.839 70.1934c0 20.5234 16.6377 37.1611 37.1611 37.1611s37.1611 -16.6377 37.1611 -37.1611s-16.6377 -37.1611 -37.1611 -37.1611s-37.1611 16.6377 -37.1611 37.1611z" />
<glyph glyph-name="053-bold-more-hor" unicode="&#xf134;"
d="M136.978 192.024c0 -37.7559 -30.6465 -68.2695 -68.4883 -68.2695c-37.8301 0.0126953 -68.4893 30.5254 -68.4893 68.2695c0 37.6465 30.6719 68.2207 68.4893 68.2207c37.8418 0 68.4883 -30.5742 68.4883 -68.2207zM324.477 192.024
c0 -37.7559 -30.6475 -68.2695 -68.4766 -68.2695s-68.4893 30.5137 -68.4893 68.2695c0 37.6465 30.6719 68.2207 68.4893 68.2207c37.8418 0 68.4766 -30.5742 68.4766 -68.2207zM511.987 192.024c0 -37.7559 -30.6465 -68.2695 -68.4883 -68.2695
c-37.8301 0 -68.4766 30.5137 -68.4766 68.2695c0 37.6465 30.6709 68.2207 68.4766 68.2207c37.8418 0 68.4883 -30.5742 68.4883 -68.2207z" />
<glyph glyph-name="026-left-and-right" unicode="&#xf119;"
d="M504.753 201.983c9.66406 -5.51074 9.66406 -14.4502 0 -19.9678l-121.18 -69.1367c-9.66309 -5.5127 -17.4971 -0.963867 -17.4971 10.1621v137.924c0 11.125 7.83398 15.6738 17.4971 10.1621zM128.427 271.127c9.66406 5.51172 17.4922 0.962891 17.498 -10.1621
v-137.93c0 -11.125 -7.83398 -15.6738 -17.498 -10.1621l-121.179 69.1426c-9.66406 5.51172 -9.66406 14.4561 0 19.9678z" />
<glyph glyph-name="054-bold-menu" unicode="&#xf135;"
d="M462.451 423.226c27.252 0 49.5488 -22.2969 49.5488 -49.5479c0 -27.252 -22.2969 -49.5488 -49.5488 -49.5488h-412.902c-27.252 0 -49.5488 22.2969 -49.5488 49.5488c0 27.251 22.2969 49.5479 49.5488 49.5479h412.902zM462.451 241.549
c27.252 0 49.5488 -22.2969 49.5488 -49.5488s-22.2969 -49.5488 -49.5488 -49.5488h-412.902c-27.252 0 -49.5488 22.2969 -49.5488 49.5488s22.2969 49.5488 49.5488 49.5488h412.902zM462.451 59.8711c27.252 0 49.5488 -22.2969 49.5488 -49.5488
c0 -27.251 -22.2969 -49.5479 -49.5488 -49.5479h-412.902c-27.252 0 -49.5488 22.2969 -49.5488 49.5479c0 27.252 22.2969 49.5488 49.5488 49.5488h412.902z" />
<glyph glyph-name="049-close" unicode="&#xf130;"
d="M312.391 192l191.426 -191.434c5.26562 -5.27441 8.1709 -12.3047 8.1709 -19.8018s-2.90527 -14.5312 -8.1709 -19.8018l-16.7754 -16.7705c-5.27441 -5.27832 -12.3047 -8.17578 -19.8018 -8.17578h-0.0078125c-7.49316 0 -14.5234 2.89746 -19.7979 8.17578
l-191.43 191.43l-191.438 -191.43c-5.26562 -5.27832 -12.3008 -8.17578 -19.8018 -8.17578c-7.48828 0 -14.5273 2.89746 -19.793 8.17578l-16.7793 16.7705c-5.27051 5.27441 -8.17188 12.3047 -8.17188 19.8018s2.90137 14.5273 8.17188 19.8057l191.434 191.43
l-191.434 191.438c-10.9229 10.9102 -10.9229 28.6758 0 39.5986l16.7832 16.7705c5.26562 5.2832 12.3008 8.17578 19.793 8.17578c7.50098 0 14.5361 -2.89258 19.8018 -8.17578l191.438 -191.425l191.43 191.425c5.27344 5.2832 12.3047 8.17578 19.7969 8.17578
c7.50488 0 14.5361 -2.89258 19.8096 -8.17578l16.7754 -16.7705c5.27441 -5.26172 8.17969 -12.3008 8.17969 -19.8018c-0.00878906 -7.49707 -2.91406 -14.5273 -8.17969 -19.7969z" />
</font>
</defs></svg>

After

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,102 @@
/*
Keenthemes Icons Font: Ki
Creation date: 03/08/2020
*/
@font-face {
font-family: "Ki";
src: url("./Ki.eot");
src: url("./Ki.eot?#iefix") format("embedded-opentype"),
url("./Ki.woff") format("woff"),
url("./Ki.ttf") format("truetype"),
url("./Ki.svg#Ki") format("svg");
font-weight: normal;
font-style: normal;
}
.ki {
font-size: 1rem;
}
.ki:before {
font-family: "Ki";
font-style: normal;
font-weight: normal;
font-variant: normal;
line-height: 1;
text-decoration: inherit;
text-rendering: optimizeLegibility;
text-transform: none;
-moz-osx-font-smoothing: grayscale;
-webkit-font-smoothing: antialiased;
font-smoothing: antialiased;
}
.ki-double-arrow-next:before { content: "\f100"; }
.ki-double-arrow-back:before { content: "\f101"; }
.ki-double-arrow-down:before { content: "\f102"; }
.ki-double-arrow-up:before { content: "\f103"; }
.ki-long-arrow-back:before { content: "\f104"; }
.ki-arrow-next:before { content: "\f105"; }
.ki-arrow-back:before { content: "\f106"; }
.ki-long-arrow-next:before { content: "\f107"; }
.ki-check:before { content: "\f108"; }
.ki-arrow-down:before { content: "\f109"; }
.ki-minus:before { content: "\f10a"; }
.ki-long-arrow-down:before { content: "\f10b"; }
.ki-long-arrow-up:before { content: "\f10c"; }
.ki-plus:before { content: "\f10d"; }
.ki-arrow-up:before { content: "\f10e"; }
.ki-round:before { content: "\f10f"; }
.ki-reload:before { content: "\f110"; }
.ki-refresh:before { content: "\f111"; }
.ki-solid-plus:before { content: "\f112"; }
.ki-bold-close:before { content: "\f113"; }
.ki-solid-minus:before { content: "\f114"; }
.ki-hide:before { content: "\f115"; }
.ki-code:before { content: "\f116"; }
.ki-copy:before { content: "\f117"; }
.ki-up-and-down:before { content: "\f118"; }
.ki-left-and-right:before { content: "\f119"; }
.ki-bold-triangle-bottom:before { content: "\f11a"; }
.ki-bold-triangle-right:before { content: "\f11b"; }
.ki-bold-triangle-top:before { content: "\f11c"; }
.ki-bold-triangle-left:before { content: "\f11d"; }
.ki-bold-double-arrow-up:before { content: "\f11e"; }
.ki-bold-double-arrow-next:before { content: "\f11f"; }
.ki-bold-double-arrow-back:before { content: "\f120"; }
.ki-bold-double-arrow-down:before { content: "\f121"; }
.ki-bold-arrow-down:before { content: "\f122"; }
.ki-bold-arrow-next:before { content: "\f123"; }
.ki-bold-arrow-back:before { content: "\f124"; }
.ki-bold-arrow-up:before { content: "\f125"; }
.ki-bold-check:before { content: "\f126"; }
.ki-bold-wide-arrow-down:before { content: "\f127"; }
.ki-bold-wide-arrow-up:before { content: "\f128"; }
.ki-bold-wide-arrow-next:before { content: "\f129"; }
.ki-bold-wide-arrow-back:before { content: "\f12a"; }
.ki-bold-long-arrow-up:before { content: "\f12b"; }
.ki-bold-long-arrow-down:before { content: "\f12c"; }
.ki-bold-long-arrow-back:before { content: "\f12d"; }
.ki-bold-long-arrow-next:before { content: "\f12e"; }
.ki-bold-check-1:before { content: "\f12f"; }
.ki-close:before { content: "\f130"; }
.ki-more-ver:before { content: "\f131"; }
.ki-bold-more-ver:before { content: "\f132"; }
.ki-more-hor:before { content: "\f133"; }
.ki-bold-more-hor:before { content: "\f134"; }
.ki-bold-menu:before { content: "\f135"; }
.ki-drag:before { content: "\f136"; }
.ki-bold-sort:before { content: "\f137"; }
.ki-eye:before { content: "\f138"; }
.ki-outline-info:before { content: "\f139"; }
.ki-menu:before { content: "\f13a"; }
.ki-menu-grid:before { content: "\f13b"; }
.ki-wrench:before { content: "\f13c"; }
.ki-gear:before { content: "\f13d"; }
.ki-info:before { content: "\f13e"; }
.ki-calendar-2:before { content: "\f13f"; }
.ki-calendar:before { content: "\f140"; }
.ki-calendar-today:before { content: "\f141"; }
.ki-clock:before { content: "\f142"; }
.ki-dots:before { content: "\f143"; }
@@ -0,0 +1,44 @@
<?xml version="1.0" encoding="iso-8859-1"?>
<!-- Generator: Adobe Illustrator 19.0.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
viewBox="0 0 491.996 491.996" style="enable-background:new 0 0 491.996 491.996;" xml:space="preserve">
<g>
<g>
<path d="M484.132,124.986l-16.116-16.228c-5.072-5.068-11.82-7.86-19.032-7.86c-7.208,0-13.964,2.792-19.036,7.86l-183.84,183.848
L62.056,108.554c-5.064-5.068-11.82-7.856-19.028-7.856s-13.968,2.788-19.036,7.856l-16.12,16.128
c-10.496,10.488-10.496,27.572,0,38.06l219.136,219.924c5.064,5.064,11.812,8.632,19.084,8.632h0.084
c7.212,0,13.96-3.572,19.024-8.632l218.932-219.328c5.072-5.064,7.856-12.016,7.864-19.224
C491.996,136.902,489.204,130.046,484.132,124.986z"/>
</g>
</g>
<g>
</g>
<g>
</g>
<g>
</g>
<g>
</g>
<g>
</g>
<g>
</g>
<g>
</g>
<g>
</g>
<g>
</g>
<g>
</g>
<g>
</g>
<g>
</g>
<g>
</g>
<g>
</g>
<g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.0 KiB

Submodule client/src/_assets/metronic-scss added at 3924275dec
+65
View File
@@ -0,0 +1,65 @@
.example-highlight > pre {
background: none transparent !important;
margin: 0 !important;
padding-top: 0.5rem;
code {
overflow-y: auto;
display: block;
span {
background: none transparent !important;
}
}
}
.json > pre {
background: none #fff !important;
}
.example-code > .json {
background: none #fff !important;
}
.symbol.symbol-45 > svg {
width: 100%;
max-width: 45px;
height: 45px;
}
.react-bootstrap-table {
overflow-x: auto;
}
.react-bootstrap-table {
th {
outline: none;
&.sortable {
.svg-icon-sort {
opacity: 0;
}
&:hover {
cursor: pointer;
.svg-icon-sort {
opacity: 1;
}
}
}
}
.table.table-head-custom thead tr {
.sortable-active {
color: #6993ff !important;
}
}
}
.cursor-default {
cursor: default !important;
}
.display-block {
display: block;
}
+43
View File
@@ -0,0 +1,43 @@
import { MaterialThemeProvider } from "components/layout/misc/MaterialThemeProvider";
import { LoadingScreen } from "components/layout/screens/LoadingScreen";
import { BannedContextProvider } from "context/BannedContext";
import { SubheaderContextProvider } from "context/SubheaderContext";
import { UserContextProvider } from "context/UserContext";
import { UserGameStatsContextProvider } from "context/UserGameStatsContext";
import { UserSettingsContextProvider } from "context/UserSettingsContext";
import React from "react";
import { Toaster } from "react-hot-toast";
import { QueryClient, QueryClientProvider } from "react-query";
import { BrowserRouter } from "react-router-dom";
import { Routes } from "./routes/AppRoutes";
const queryClient = new QueryClient({
defaultOptions: { queries: { staleTime: 1000 * 60 * 5, retry: false } },
});
export default function App({ basename }: { basename: string }) {
return (
<React.StrictMode>
<QueryClientProvider client={queryClient}>
<BannedContextProvider>
<UserContextProvider>
<UserSettingsContextProvider>
<UserGameStatsContextProvider>
<LoadingScreen>
<BrowserRouter basename={basename}>
<MaterialThemeProvider>
<Toaster />
<SubheaderContextProvider>
<Routes />
</SubheaderContextProvider>
</MaterialThemeProvider>
</BrowserRouter>
</LoadingScreen>
</UserGameStatsContextProvider>
</UserSettingsContextProvider>
</UserContextProvider>
</BannedContextProvider>
</QueryClientProvider>
</React.StrictMode>
);
}
+139
View File
@@ -0,0 +1,139 @@
import { APIFetchV1 } from "util/api";
import ApiError from "components/util/ApiError";
import Divider from "components/util/Divider";
import Loading from "components/util/Loading";
import OAuthMoreInfo from "components/util/OAuthMoreInfo";
import useApiQuery from "components/util/query/useApiQuery";
import { UserContext } from "context/UserContext";
import { TachiConfig } from "lib/config";
import React, { useContext, useState } from "react";
import { Link, useParams } from "react-router-dom";
import { APITokenDocument, TachiAPIClientDocument } from "tachi-common";
export default function ClientFileFlowPage() {
const { clientID } = useParams<{ clientID: string }>();
const [createdKey, setCreatedKey] = useState<string | null>(null);
const [showKey, setShowKey] = useState(false);
const { user } = useContext(UserContext);
const { data, isLoading, error } = useApiQuery<Omit<TachiAPIClientDocument, "clientSecret">>(
`/clients/${clientID}`
);
if (error) {
return (
<>
<ApiError error={error} />
<div>
Since you're on this page, this probably wasn't your fault. Report it to the
person who directed you here.
</div>
</>
);
}
if (!data || isLoading) {
return <Loading />;
}
return (
<div className="row">
{createdKey === null ? (
<>
<div className="col-12">
{data.name} is requesting you create an API Key.
<Divider />
</div>
<div className="col-12">
The key will have the following permissions.
<ul>
{data.requestedPermissions.map(e => (
<li key={e}>{e}</li>
))}
</ul>
</div>
<div className="col-12 d-flex" style={{ justifyContent: "space-evenly" }}>
<Link className="btn btn-danger" to="/">
No thanks.
</Link>
<button
className="btn btn-success"
onClick={async () => {
const res = await APIFetchV1<APITokenDocument>(
`/users/${user?.id}/api-tokens/create`,
{
method: "POST",
body: JSON.stringify({
clientID: data.clientID,
}),
headers: {
"Content-Type": "application/json",
},
},
true,
true
);
if (res.success) {
setCreatedKey(
data.apiKeyTemplate
? data.apiKeyTemplate.replace(
"%%TACHI_KEY%%",
res.body.token!
)
: res.body.token
);
}
}}
>
Create the key.
</button>
</div>
<div className="col-12" style={{ fontSize: "1.3rem" }}>
<Divider />
<OAuthMoreInfo client={data} />
</div>
</>
) : (
<>
<div className="col-12 text-center">Created API Key.</div>
<div className="col-12">
<Divider />
{showKey ? (
<>
<code>{createdKey}</code>
<br />
You should copy this token back to the application that requested
this.
</>
) : data.apiKeyFilename ? (
<>
<a
className="btn btn-info"
download={data.apiKeyFilename}
href={`data:text/plain;base64,${window.btoa(createdKey)}`}
>
Download {data.apiKeyFilename}
</a>
<br />
<Link className="mt-8" style={{ fontSize: "0.9rem" }} to="/">
Go Back to {TachiConfig.name}
</Link>
</>
) : (
<button
className="btn btn-danger"
onClick={() => {
setShowKey(true);
}}
>
Reveal key
</button>
)}
</div>
</>
)}
</div>
);
}
+83
View File
@@ -0,0 +1,83 @@
import { ToCDNURL } from "util/api";
import { HistorySafeGoBack } from "util/misc";
import useSetSubheader from "components/layout/header/useSetSubheader";
import React from "react";
import { useHistory } from "react-router-dom";
export function ErrorPage({
statusCode,
customMessage,
}: {
statusCode: number;
customMessage?: string;
}) {
useSetSubheader(statusCode ? statusCode.toString() : "Completely Screwed.");
let message;
const history = useHistory();
if (!customMessage) {
switch (statusCode) {
case 400:
message = "The server refused this request.";
break;
case 401:
message = "Looks like you're not authorised. Try logging in.";
break;
case 403:
message = "Looks like you're not allowed to be here.";
break;
case 404:
message = "This page does not exist. If you think it should exist, let us know!";
break;
case 429:
message = "You're being rate limited. If this was unexpected, please report this.";
break;
case 500:
message = "Looks like the server has failed. This has been reported!";
break;
case undefined:
message =
"Looks like the server has completely crashed. Ah well. Try reloading, but there's no promises.";
break;
default:
message = "An unexpected error has occured. This has been reported!";
break;
}
} else {
message = customMessage;
}
return (
<div
className="fullscreen text-center"
style={
statusCode === 999
? {
background: "red",
backgroundImage: `url(${ToCDNURL("/eggs/michael.png")})`,
backgroundRepeat: "no-repeat",
backgroundPosition: "center",
backgroundSize: "cover",
}
: undefined
}
>
<div className="d-flex flex-column flex-root">
<div className="d-flex flex-row-fluid flex-column bgi-size-cover bgi-position-center bgi-no-repeat p-10 p-sm-30">
<h1
className="font-size-sm-100 font-weight-boldest mt-15"
style={{ fontSize: "150px" }}
>
{statusCode || "Completely Screwed."}
</h1>
<p className="font-size-h3 font-weight-light">{message}</p>
<a className="text-primary" onClick={() => HistorySafeGoBack(history)}>
Go Back.
</a>
</div>
</div>
</div>
);
}
@@ -0,0 +1,87 @@
import { APIFetchV1 } from "util/api";
import { HistorySafeGoBack } from "util/misc";
import CenterPage from "components/util/CenterPage";
import SiteWordmark from "components/util/SiteWordmark";
import { Form, Col, Button } from "react-bootstrap";
import React, { useState } from "react";
import { Link, useHistory } from "react-router-dom";
import MainPageTitleContainer from "components/util/MainPageTitleContainer";
export default function ForgotPasswordPage() {
const [email, setEmail] = useState("");
const history = useHistory();
const [hasResetPassword, setHasResetPassword] = useState(false);
return (
<CenterPage>
<Col lg="6">
<MainPageTitleContainer
title="Forgot Password"
desc="Hey, it happens to all of us :P"
/>
{hasResetPassword ? (
<div>
All good! An email has been sent to {email} IF an account exists with that
email.
<br />
<Link to="/">Go home.</Link>
</div>
) : (
<>
<Form
onSubmit={async e => {
e.preventDefault();
const res = await APIFetchV1(
"/auth/forgot-password",
{
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ email }),
},
true,
true
);
if (res.success) {
setTimeout(() => setHasResetPassword(true), 300);
}
}}
>
<Form.Group>
<Form.Label>Email Address</Form.Label>
<Form.Control
type="email"
value={email}
onChange={e => setEmail(e.target.value)}
/>
</Form.Group>
<Form.Group className="justify-content-center d-flex pt-4">
<span
onClick={() => HistorySafeGoBack(history)}
tabIndex={4}
className="mr-auto btn btn-outline-danger"
>
Back
</span>
<Button
tabIndex={3}
type="submit"
className="ml-auto"
disabled={email === ""}
>
Send Reset Link
</Button>
</Form.Group>
</Form>
<Link to="/screwed">
I signed up with a fake email, how can I recover my account?
</Link>
</>
)}
</Col>
</CenterPage>
);
}
+142
View File
@@ -0,0 +1,142 @@
import { ToCDNURL } from "util/api";
import { Footer } from "components/layout/footer/Footer";
import { Header } from "components/layout/header/Header";
import { HeaderMobile } from "components/layout/header/HeaderMobile";
import Divider from "components/util/Divider";
import LinkButton from "components/util/LinkButton";
import { TachiConfig } from "lib/config";
import React from "react";
// This page is currently unused,
// It's alright, but hey, we could do more.
export default function LandingPage() {
return (
<>
{/* <HeaderMobile /> */}
{/* <Header /> */}
<div className="landing-page" id="kt_content">
<section id="hero" className="hero">
<div
className="hero-bg-image"
style={{
backgroundImage: `url(${ToCDNURL("/game-banners/default")})`,
}}
/>
<div
className="hero-bg-fader"
style={{
backgroundImage: `url(${ToCDNURL("/misc/overlay.png")})`,
}}
/>
<div className="hero-content-wrapper">
<div className="row">
<div className="col-12 col-lg-8 offset-lg-2 d-flex align-items-center">
<div className="row justify-content-center text-center">
<div className="col-12 mb-8">
<div className="d-none d-lg-block">
<img
src={ToCDNURL("/logos/logo-wordmark.png")}
alt={TachiConfig.name}
width="30%"
/>
</div>
<div className="d-block d-lg-none">
<img
src={ToCDNURL("/logos/logo-wordmark.png")}
alt={TachiConfig.name}
width="80%"
/>
</div>
</div>
<div className="col-12">
<h3 className="display-3">
A <b>Supercharged</b> Score Tracker
</h3>
</div>
</div>
</div>
</div>
</div>
</section>
<Divider className="mt-8 mb-16" />
<section id="features" className="features">
<div className="container">
<div className="row">
<FeatureContainer
tagline="No More Spreadsheets."
description={`${TachiConfig.name} analyses your scores for you, showing all the statistics you'll ever want to see.`}
>
<FeatureImage
alt="Picture of scores."
src={ToCDNURL("/hero/scores.png")}
/>
</FeatureContainer>
<FeatureContainer
leftAlign={false}
tagline="Features You'll Actually Use."
description={`${TachiConfig.name} implements the features rhythm gamers already talk about. Break your scores down into sessions, Track goals in real time, break down your folder averages - it's all there!`}
>
foo
</FeatureContainer>
<FeatureContainer
tagline="All Your Scores."
description={`${TachiConfig.name} supports a bunch of your favourite games, and integrates with many existing services to make sure no score is lost to the void.`}
>
foo
</FeatureContainer>
<div
className="col-12 text-center"
style={{ paddingTop: 100, height: 250 }}
>
But hey, this all sounds a bit formal. Let's be honest, if you're a
rhythm gamer, you probably don't need to be sold on this like it's a
startup :P.
<br />
<LinkButton to="/register" className="mt-4 btn-outline-primary">
Register?
</LinkButton>
</div>
</div>
</div>
</section>
</div>
<Footer />
</>
);
}
function FeatureContainer({
tagline,
description,
children,
leftAlign = true,
}: {
tagline: string;
description: string;
children: React.ReactChild;
leftAlign?: boolean;
}) {
return (
<div className="col-12 mb-16">
<div className="row">
{leftAlign && (
<div className="col-12 col-lg-6 align-self-center">
<h1 className="display-4">{tagline}</h1>
<h5>{description}</h5>
</div>
)}
<div className="col-12 d-none d-lg-block col-lg-6 text-center">{children}</div>
{!leftAlign && (
<div className="col-12 col-lg-6 text-right">
<h1 className="display-4">{tagline}</h1>
<h5>{description}</h5>
</div>
)}
</div>
</div>
);
}
function FeatureImage({ src, alt }: { src: string; alt: string }) {
return <img src={src} alt={alt} />;
}
+140
View File
@@ -0,0 +1,140 @@
import { APIFetchV1 } from "util/api";
import { HumaniseError } from "util/humanise-error";
import { HistorySafeGoBack } from "util/misc";
import useSetSubheader from "components/layout/header/useSetSubheader";
import CenterPage from "components/util/CenterPage";
import SiteWordmark from "components/util/SiteWordmark";
import { UserContext } from "context/UserContext";
import { useFormik } from "formik";
import React, { useContext, useState } from "react";
import { Button, Col, Form } from "react-bootstrap";
import ReCAPTCHA from "react-google-recaptcha";
import toast from "react-hot-toast";
import { Link, useHistory } from "react-router-dom";
import { PublicUserDocument } from "tachi-common";
export default function LoginPage() {
useSetSubheader("Login");
const [err, setErr] = useState("");
const { setUser } = useContext(UserContext);
const history = useHistory();
const formik = useFormik({
initialValues: {
username: "",
"!password": "",
captcha: "",
},
onSubmit: async values => {
setErr("");
const rj = await APIFetchV1(
"/auth/login",
{
method: "POST",
body: JSON.stringify(values),
headers: {
"Content-Type": "application/json",
},
},
false,
false
);
if (!rj.success) {
setErr(HumaniseError(rj.description));
return;
}
const userRJ = await APIFetchV1<PublicUserDocument>("/users/me");
if (userRJ.statusCode === 403) {
setErr("You are banned.");
return;
}
if (!userRJ.success) {
console.error("Error retrieving own user?");
setErr("An internal server error has occured.");
return;
}
toast.success("Logged in!");
setTimeout(() => {
setUser(userRJ.body);
localStorage.setItem("isLoggedIn", "true");
HistorySafeGoBack(history);
}, 500);
},
});
return (
<CenterPage>
<Col lg="6">
<SiteWordmark />
<div className="text-center mb-10 mb-lg-20">
<h3 className="font-size-h1">Log In</h3>
<span className="font-weight-bold text-dark-50">Don't have an account?</span>
<Link to="/register" className="font-weight-bold ml-2">
Sign Up!
</Link>
</div>
<Form onSubmit={formik.handleSubmit}>
<Form.Group>
<Form.Label>Username</Form.Label>
<Form.Control
tabIndex={1}
type="text"
id="username"
value={formik.values.username}
onChange={formik.handleChange}
/>
</Form.Group>
<Form.Group>
<Form.Label>Password</Form.Label>
<Form.Control
tabIndex={2}
type="password"
id="!password"
value={formik.values["!password"]}
onChange={formik.handleChange}
/>
</Form.Group>
<Form.Group
style={{ display: err ? "" : "none" }}
className="text-center text-danger"
>
{err}
</Form.Group>
<ReCAPTCHA
sitekey={
process.env.REACT_APP_RECAPTCHA_KEY ??
"6LdI2swUAAAAAArkM0ZQi4SnttilqgAwsJSFw3PX"
}
onChange={v => {
formik.setFieldValue("captcha", v);
}}
/>
<Form.Group className="justify-content-center d-flex pt-4">
<span
onClick={() => history.goBack()}
tabIndex={4}
className="mr-auto btn btn-outline-danger"
>
Back
</span>
<Link to="/forgot-password">Forgot Password</Link>
<Button tabIndex={3} type="submit" className="ml-auto">
Log In
</Button>
</Form.Group>
</Form>
</Col>
</CenterPage>
);
}
@@ -0,0 +1,51 @@
import ApiError from "components/util/ApiError";
import Loading from "components/util/Loading";
import useApiQuery from "components/util/query/useApiQuery";
import React from "react";
import { Link } from "react-router-dom";
import { ErrorPage } from "./ErrorPage";
export default function OAuth2CallbackPage({
counterWeight,
serviceName,
}: {
counterWeight: string;
serviceName: string;
}) {
const query = new URLSearchParams(window.location.search);
const code = query.get("code");
if (!code) {
return <ErrorPage statusCode={400} />;
}
const { data, isLoading, error } = useApiQuery(counterWeight, {
method: "POST",
body: JSON.stringify({ code }),
headers: {
"Content-Type": "application/json",
},
});
if (error) {
<ApiError error={error} />;
}
if (isLoading || !data) {
return (
<div>
We're checking up with this site to get your authentication sorted.
<Loading />
</div>
);
}
return (
<div>
Authentication complete! You can now import with {serviceName}.
<br />
<Link to="/">Go home.</Link>
</div>
);
}
@@ -0,0 +1,130 @@
import { APIFetchV1 } from "util/api";
import CenterLayoutPage from "components/layout/CenterLayoutPage";
import ApiError from "components/util/ApiError";
import Divider from "components/util/Divider";
import Loading from "components/util/Loading";
import OAuthMoreInfo from "components/util/OAuthMoreInfo";
import useApiQuery from "components/util/query/useApiQuery";
import useQueryString from "components/util/useQueryString";
import React from "react";
import { Link } from "react-router-dom";
import { integer, TachiAPIClientDocument } from "tachi-common";
export default function OAuthRequestAuthPage() {
const params = useQueryString();
const clientID = params.get("clientID");
if (!clientID) {
return (
<CenterLayoutPage>
No ClientID. Whoever gave you this link likely messed up somehow. <br />
<Link to="/">Go Home.</Link>
</CenterLayoutPage>
);
}
return (
<CenterLayoutPage>
<OAuthRequestAuthLoader clientID={clientID} />
</CenterLayoutPage>
);
}
function OAuthRequestAuthLoader({ clientID }: { clientID: string }) {
const { isLoading, error, data } = useApiQuery<Omit<TachiAPIClientDocument, "clientSecret">>(
`/clients/${clientID}`
);
if (error) {
return <ApiError error={error} />;
}
if (isLoading || !data) {
return <Loading />;
}
if (!data.redirectUri) {
return (
<>
This client does not support OAuth Flows.
<br />
Whoever gave you this link likely messed up somehow. <br />
<Link to="/">Go Home.</Link>
</>
);
}
return <OAuthRequestAuthMain client={data} />;
}
function OAuthRequestAuthMain({
client,
}: {
client: Omit<TachiAPIClientDocument, "clientSecret">;
}) {
const params = useQueryString();
const context = params.get("context");
return (
<div className="row">
<>
<div className="col-12">
{client.name} is requesting you authenticate with them.
<Divider />
</div>
<div className="col-12">
The key will have the following permissions.
<ul>
{client.requestedPermissions.map(e => (
<li key={e}>{e}</li>
))}
</ul>
</div>
<div className="col-12 d-flex" style={{ justifyContent: "space-evenly" }}>
<Link className="btn btn-danger" to="/">
No thanks.
</Link>
<button
className="btn btn-success"
onClick={async () => {
const tokenRes = await APIFetchV1<{
code: string;
userID: integer;
createdOn: number;
}>(
"/oauth/create-code",
{
method: "POST",
},
false,
true
);
if (!tokenRes.success) {
return;
}
const redirectURL = new URL(client.redirectUri!);
redirectURL.searchParams.set("code", tokenRes.body.code);
if (context) {
redirectURL.searchParams.set("context", context);
}
window.location.href = redirectURL.toString();
}}
>
I want to authenticate with {client.name}.
</button>
</div>
<div className="col-12" style={{ fontSize: "1.3rem" }}>
<Divider />
<OAuthMoreInfo client={client} />
</div>
</>
</div>
);
}
+130
View File
@@ -0,0 +1,130 @@
import useSetSubheader from "components/layout/header/useSetSubheader";
import ExternalLink from "components/util/ExternalLink";
import { TachiConfig } from "lib/config";
import React from "react";
export default function PrivacyPolicyPage() {
useSetSubheader(["Dashboard", "GDPR/Legal Stuff"]);
return (
<div className="privacy-policy">
<h2>What data do we collect?</h2>
<li>Personally identifiable information (email address)</li>
<li>Your submitted scores.</li>
<h2>How do we collect your data?</h2>
<p>
You directly provide {TachiConfig.name} with most of the data we collect. We collect
data and process data when you:
</p>
<li>Register</li>
<li>Submit scores</li>
<h2>How will we use your data?</h2>
<p>{TachiConfig.name} collects your data so that we can:</p>
<li>Login</li>
<li>Manage our score database</li>
<li>Provide you with statistics</li>
<h2>How do we store your data?</h2>
<p>{TachiConfig.name} securely stores your data on our server in Norway.</p>
<p>
{TachiConfig.name} will keep your email for as long as you use the service. If you
elect to terminate your account, your data will be securely erased.
</p>
<h2>Marketing</h2>
<p>
No advertising agencies are associated with this site, nor is your information given
to any of them.
</p>
<h2>What are your data protection rights?</h2>
<p>
{TachiConfig.name} would like to make sure you are fully aware of all of your data
protection rights. Every user is entitled to the following:
</p>
<p>
The right to access - You have the right to request {TachiConfig.name} for copies of
your personal data.
</p>
<p>
The right to rectification - You have the right to request that {TachiConfig.name}{" "}
correct any information you believe is inaccurate. You also have the right to
request
{TachiConfig.name} to complete the information you believe is incomplete.
</p>
<p>
The right to erasure - You have the right to request that {TachiConfig.name} erase
your personal data, under certain conditions.
</p>
<p>
The right to restrict processing - You have the right to request that{" "}
{TachiConfig.name}
restrict the processing of your personal data, under certain conditions.
</p>
<p>
The right to object to processing - You have the right to object to{" "}
{TachiConfig.name}'s processing of your personal data, under certain conditions.
</p>
<p>
The right to data portability - You have the right to request that{" "}
{TachiConfig.name}
transfer the data that we have collected to another organization, or directly to
you, under certain conditions.
</p>
<p>
If you make a request, providing I am not dead, I have one month to respond to you.
If you would like to exercise any of these rights, please contact me at this email:
zkldi.dev@gmail.com
</p>
<h2>Cookies</h2>
<p>
Cookies are text files placed on your computer to collect standard Internet log
information and visitor behavior information. When you visit our websites, we may
collect information from you automatically through cookies or similar technology
</p>
<p> For further information, visit allaboutcookies.org.</p>
<h2>How do we use cookies?</h2>
<p>
{TachiConfig.name} uses cookies in a range of ways to improve your experience on our
website, including, and limited to:
</p>
<li>Keeping you signed in</li>
<h2>What types of cookies do we use?</h2>
<p>
Functionality - {TachiConfig.name} uses these cookies so that we recognize you on
our website and remember your previously selected preferences. These could include
what language you prefer and location you are in. No third-party cookies are used.
</p>
<h2>How to manage cookies</h2>
<p>
You can set your browser not to accept cookies, and the above website tells you how
to remove cookies from your browser. However, in a few cases, some of our website
features may not function as a result.
</p>
<h2>Privacy policies of other websites</h2>
<p>
The {TachiConfig.name} website contains links to other websites. Our privacy policy
applies only to our website, so if you click on a link to another website, you
should read their privacy policy.
</p>
<h2>Changes to our privacy policy</h2>
<p>
{TachiConfig.name} keeps its privacy policy under regular review and places any
updates on this web page. This privacy policy was last updated on 15 September 2020.
</p>
<h2>How to contact us</h2>
<p>
If you have any questions about {TachiConfig.name}'s privacy policy, the data we
hold on you, or you would like to exercise one of your data protection rights,
please do not hesitate to contact us.
</p>
<p>Email us at: ktchidev@gmail.com</p>
<h2>How to contact the appropriate authority</h2>
<p>
Should you wish to report a complaint or if you feel that {TachiConfig.name} has not
addressed your concern in a satisfactory manner, you may contact the Information
Commissioner's Office.
</p>
<p>
Website: <ExternalLink href="https://ico.org.uk">https://ico.org.uk</ExternalLink>
</p>
</div>
);
}
+290
View File
@@ -0,0 +1,290 @@
import { APIFetchV1, ToCDNURL } from "util/api";
import { HumaniseError } from "util/humanise-error";
import { HistorySafeGoBack } from "util/misc";
import useSetSubheader from "components/layout/header/useSetSubheader";
import Divider from "components/util/Divider";
import { UserContext } from "context/UserContext";
import { useFormik } from "formik";
import { ClientConfig, TachiConfig } from "lib/config";
import React, { useContext, useState } from "react";
import { Alert, Button, Col, Form } from "react-bootstrap";
import ReCAPTCHA from "react-google-recaptcha";
import toast from "react-hot-toast";
import { Link, useHistory } from "react-router-dom";
import { PublicUserDocument } from "tachi-common";
import { UseFormik } from "types/react";
// seconds it takes for a user to actually read the rules.
const RULES_READ_TIME = Number(process.env.REACT_APP_RULES_READ_TIME) || 30;
export default function RegisterPage() {
useSetSubheader("Register");
const [err, setErr] = useState("");
// not opened: user has not clicked the rules link
// opened: user has clicked the rules link
// read: atleast 30 seconds have expired.
const [readRules, setReadRules] = useState<"not-opened" | "opened" | "read" | "acknowledged">(
"not-opened"
);
const [disabled, setDisabled] = useState(true);
const [btnText, setBtnText] = useState("I've read the rules. (Click the rules.)");
const { setUser } = useContext(UserContext);
const history = useHistory();
const urlParams = new URLSearchParams(location.search);
const formik = useFormik({
initialValues: {
username: "",
"!password": "",
confPassword: "",
inviteCode: urlParams.get("inviteCode") ?? "",
email: "",
captcha: "temp",
},
onSubmit: async values => {
if (values["!password"] !== values.confPassword) {
setErr("Password and confirm password do not match!");
return;
}
const rj = await APIFetchV1<PublicUserDocument>(
"/auth/register",
{
method: "POST",
body: JSON.stringify({
"!password": values["!password"],
inviteCode: values.inviteCode,
username: values.username,
email: values.email,
captcha: values.captcha,
}),
headers: {
"Content-Type": "application/json",
},
},
false,
true
);
if (!rj.success) {
setErr(HumaniseError(rj.description));
return;
}
toast.success("Created Account, Logged In!");
setTimeout(() => {
setUser(rj.body);
localStorage.setItem("isLoggedIn", "true");
HistorySafeGoBack(history);
}, 500);
},
});
function ReadRulesWait() {
if (readRules !== "not-opened") {
return;
}
setReadRules("opened");
// users have to actually read the rules.
let wait = RULES_READ_TIME;
const tickerRef = setInterval(() => {
wait--;
setBtnText(`I've read the rules (${wait}s)`);
}, 1000);
setTimeout(() => {
setReadRules("read");
setBtnText("I've read the rules.");
setDisabled(false);
clearInterval(tickerRef);
}, RULES_READ_TIME * 1000);
}
return (
<div className="container d-flex flex-column flex-root justify-content-center align-items-center">
<Col lg="6">
<div className="text-center mb-10 mb-lg-10">
<img
src={ToCDNURL("/logos/logo-wordmark.png")}
alt={TachiConfig.name}
width="256px"
/>
</div>
<div className="text-center mb-10 mb-lg-20">
<h3 className="font-size-h1">Register</h3>
<span className="font-weight-bold text-dark-50">Have an account?</span>
<Link to="/login" className="font-weight-bold ml-2">
Sign in!
</Link>
</div>
{readRules === "acknowledged" ? (
<RegisterForm formik={formik} err={err} />
) : (
<div className="text-center">
<div className="mb-8">
<Alert variant="warning">
<b>
If you already have an account. DO NOT MAKE ANOTHER ONE! That
will get both accounts banned.
</b>
</Alert>
<h4>
Hey! Before you make an account, please read the{" "}
<a
href="https://tachi.readthedocs.io/en/latest/user/rules/"
target="_blank"
rel="noopener noreferrer"
onAuxClick={ReadRulesWait}
onClick={() => {
setTimeout(() => ReadRulesWait(), 300);
}}
>
Rules.
</a>
</h4>
<h6>(This link opens in a new tab.)</h6>
</div>
<Divider />
{readRules === "opened" ? (
<p className="mt-4">
Hey, it takes longer than {RULES_READ_TIME} seconds to read the
rules.
<br />I know it sucks to wait around, but the few rules we have are
enforced strictly.
<br />
The last thing you'd want is to accidentally get yourself banned!
</p>
) : (
<></>
)}
<div className="justify-content-center d-flex mt-4">
<Link to="/" tabIndex={-1} className="mr-auto btn btn-outline-danger">
Back
</Link>
<Button
disabled={disabled}
className="ml-auto"
onClick={() => setReadRules("acknowledged")}
>
{btnText}
</Button>
</div>
</div>
)}
</Col>
</div>
);
}
function RegisterForm({
formik,
err,
}: {
formik: UseFormik<{
username: string;
"!password": string;
confPassword: string;
inviteCode: string;
email: string;
captcha: string;
}>;
err: string;
}) {
return (
<Form onSubmit={formik.handleSubmit}>
<Form.Group>
<Form.Label>Username</Form.Label>
<Form.Control
tabIndex={1}
type="text"
id="username"
value={formik.values.username}
onChange={formik.handleChange}
/>
</Form.Group>
<Form.Group>
<Form.Label>Email</Form.Label>
<Form.Control
tabIndex={2}
type="email"
id="email"
value={formik.values.email}
onChange={formik.handleChange}
/>
<Form.Text className="text-muted">
This is used for things like password recovery, and authentication checks. If
this email is fake, and you forget your password,{" "}
<b>You will be permanently locked out of your account.</b>
<br />
We will never use this to send spam!
</Form.Text>
</Form.Group>
<Form.Group>
<Form.Label>Password</Form.Label>
<Form.Control
tabIndex={3}
type="password"
id="!password"
value={formik.values["!password"]}
onChange={formik.handleChange}
/>
</Form.Group>
<Form.Group>
<Form.Label>Confirm Password</Form.Label>
<Form.Control
tabIndex={4}
type="password"
id="confPassword"
value={formik.values.confPassword}
onChange={formik.handleChange}
/>
</Form.Group>
{ClientConfig.MANDATE_LOGIN && (
<Form.Group>
<Form.Label>Invite Code</Form.Label>
<Form.Control
tabIndex={5}
type="text"
id="inviteCode"
value={formik.values.inviteCode}
onChange={formik.handleChange}
/>
</Form.Group>
)}
<ReCAPTCHA
sitekey={
process.env.REACT_APP_RECAPTCHA_KEY ??
"6LdI2swUAAAAAArkM0ZQi4SnttilqgAwsJSFw3PX"
}
onChange={v => {
formik.setFieldValue("captcha", v);
}}
/>
<Form.Group style={{ display: err ? "" : "none" }} className="text-center text-danger">
{err}
</Form.Group>
<Form.Group className="justify-content-center d-flex pt-4">
<Link to="/" tabIndex={7} className="mr-auto btn btn-outline-danger">
Back
</Link>
<Button tabIndex={6} type="submit" className="ml-auto">
Register!
</Button>
</Form.Group>
</Form>
);
}
@@ -0,0 +1,77 @@
import { APIFetchV1 } from "util/api";
import { ShortDelayify } from "util/misc";
import CenterPage from "components/util/CenterPage";
import MainPageTitleContainer from "components/util/MainPageTitleContainer";
import React, { useState } from "react";
import { Button, Form } from "react-bootstrap";
import { ErrorPage } from "./ErrorPage";
export default function ResetPasswordPage() {
const code = new URLSearchParams(window.location.search).get("code");
const [password, setPassword] = useState("");
const [confirmPass, setConfirmPass] = useState("");
if (!code) {
return <ErrorPage statusCode={400} />;
}
return (
<CenterPage>
<MainPageTitleContainer
title="Reset Password"
desc="Pick something you'll remember this time :)"
/>
<Form
onSubmit={async e => {
e.preventDefault();
const res = await APIFetchV1(
"/auth/reset-password",
{
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ code, "!password": password }),
},
true,
true
);
if (res.success) {
ShortDelayify(() => (window.location.href = "/"));
}
}}
>
<Form.Group>
<Form.Label>New Password</Form.Label>
<Form.Control
value={password}
onChange={e => setPassword(e.target.value)}
isValid={password.length >= 8}
type="password"
/>
</Form.Group>
<Form.Group>
<Form.Label>Confirm</Form.Label>
<Form.Control
value={confirmPass}
onChange={e => setConfirmPass(e.target.value)}
isValid={password === confirmPass}
type="password"
/>
</Form.Group>
<Form.Group className="justify-content-center d-flex pt-4">
<Button
tabIndex={3}
type="submit"
className="ml-auto"
disabled={!(password === confirmPass && password.length >= 8)}
>
Reset Password
</Button>
</Form.Group>
</Form>
</CenterPage>
);
}
+65
View File
@@ -0,0 +1,65 @@
import CenterLayoutPage from "components/layout/CenterLayoutPage";
import ApiError from "components/util/ApiError";
import Divider from "components/util/Divider";
import LinkButton from "components/util/LinkButton";
import Loading from "components/util/Loading";
import useApiQuery from "components/util/query/useApiQuery";
import useQueryString from "components/util/useQueryString";
import { UserContext } from "context/UserContext";
import React, { useContext } from "react";
import { ErrorPage } from "./ErrorPage";
import LoginPage from "./LoginPage";
export default function VerifyEmailPage() {
const { user } = useContext(UserContext);
const params = useQueryString();
const code = params.get("code");
if (!code) {
return (
<ErrorPage
statusCode={400}
customMessage="Invalid URL - Missing important parameters."
/>
);
}
if (!user) {
return <LoginPage />;
}
return (
<CenterLayoutPage>
<VerifyEmail code={code} />
</CenterLayoutPage>
);
}
function VerifyEmail({ code }: { code: string }) {
const { data, isLoading, error } = useApiQuery("/auth/verify-email", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
code,
}),
});
if (error) {
return <ApiError error={error} />;
}
if (isLoading || !data) {
return <Loading />;
}
return (
<>
Verified your email.
<Divider />
<LinkButton to="/">Go back home!</LinkButton>
</>
);
}
@@ -0,0 +1,285 @@
import { APIFetchV1 } from "util/api";
import { RFA } from "util/misc";
import { NumericSOV } from "util/sorts";
import { heySplashes } from "util/splashes";
import { ChangeOpacity } from "util/color-opacity";
import { CreateGoalMap } from "util/data";
import useSetSubheader from "components/layout/header/useSetSubheader";
import SessionCard from "components/sessions/SessionCard";
import ApiError from "components/util/ApiError";
import AsyncLoader from "components/util/AsyncLoader";
import Divider from "components/util/Divider";
import GentleLink from "components/util/GentleLink";
import LinkButton from "components/util/LinkButton";
import Loading from "components/util/Loading";
import useApiQuery from "components/util/query/useApiQuery";
import { UserContext } from "context/UserContext";
import { UserGameStatsContext } from "context/UserGameStatsContext";
import { UserSettingsContext } from "context/UserSettingsContext";
import { TachiConfig } from "lib/config";
import React, { useContext } from "react";
import { Alert } from "react-bootstrap";
import { Link } from "react-router-dom";
import { COLOUR_SET, GetGameConfig, PublicUserDocument } from "tachi-common";
import { UGSWithRankingData, UserRecentSummary } from "types/api-returns";
import { FolderInfoComponent } from "./users/games/_game/_playtype/folders/FolderSelectPage";
import { GameStatContainer } from "./users/UserGamesPage";
export function DashboardPage() {
const { settings } = useContext(UserSettingsContext);
useSetSubheader("Dashboard", [settings]);
const { user } = useContext(UserContext);
const { ugs } = useContext(UserGameStatsContext);
if (!user) {
return <DashboardNotLoggedIn />;
}
return (
<>
{ugs?.length ? (
<DashboardLoggedIn user={user} />
) : (
<DashboardLoggedInNoScores user={user} />
)}
</>
);
}
function DashboardLoggedIn({ user }: { user: PublicUserDocument }) {
return (
<div>
<span className="display-4">
{RFA(heySplashes)}, {user.username}.
</span>
<Divider />
<RecentInfo user={user} />
<h1>Here's all your profiles.</h1>
<Divider />
<UserGameStatsInfo user={user} />
</div>
);
}
function RecentInfo({ user }: { user: PublicUserDocument }) {
const { data, isLoading, error } = useApiQuery<UserRecentSummary>(
`/users/${user.id}/recent-summary`
);
if (error) {
return <ApiError error={error} />;
}
if (isLoading || !data) {
return <Loading />;
}
const folderInfoMap = new Map();
for (const folderInfo of data.recentFolderStats) {
folderInfoMap.set(folderInfo.folderID, folderInfo);
}
const goalMap = CreateGoalMap(data.recentGoals);
return (
<>
{data.recentSessions.length !== 0 && (
<>
<Alert
style={{
backgroundColor:
TachiConfig.type === "ktchi" ? "#e61c6e44" : "#527acc44",
color: "white",
}}
>
<div className="text-center">
<h1>Today's Summary</h1>
You've gotten <b>{data.recentPlaycount}</b> new score
{data.recentPlaycount !== 1 ? "s" : ""} today!
</div>
</Alert>
<Divider />
<h1>New Sessions</h1>
You've had <b>{data.recentSessions.length}</b> session
{data.recentSessions.length !== 1 ? "s" : ""} today!
<Divider />
{data.recentSessions.sort(NumericSOV(x => x.timeEnded, true)).map(e => (
<SessionCard sessionID={e.sessionID} key={e.sessionID} />
))}
<Divider />
</>
)}
{data.recentAchievedGoals.length !== 0 && (
<>
<Alert
style={{
backgroundColor: ChangeOpacity(COLOUR_SET.gold, 0.2),
}}
>
<div className="text-center text-white">
<h1>
{RFA([
"Sweet!",
"Nice!",
"Lookin' good!",
"Good Stuff!",
"owo",
"Cool!",
"Awesome!",
"Epic!",
])}
</h1>
You've achieved <b>{data.recentAchievedGoals.length}</b> new goal
{data.recentAchievedGoals.length !== 1 ? "s" : ""} today!
</div>
<Divider />
<div className="text-white">
<ul>
{data.recentAchievedGoals.map(e => (
<GentleLink
to={`/dashboard/games/${e.game}/${e.playtype}/targets/goals/${e.goalID}`}
key={e.goalID}
>
<li>{goalMap.get(e.goalID)?.name}</li>
</GentleLink>
))}
</ul>
</div>
</Alert>
<Divider />
</>
)}
{data.recentFolders.length !== 0 && (
<>
<h1>Here's some folders you checked out recently.</h1>
<Divider />
<div className="row">
{data.recentFolders.map(e => (
<FolderInfoComponent
key={e.folderID}
folder={e}
game={e.game}
playtype={e.playtype}
reqUser={user}
folderStats={folderInfoMap.get(e.folderID)!}
/>
))}
</div>
<Divider />
</>
)}
</>
);
}
function UserGameStatsInfo({ user }: { user: PublicUserDocument }) {
return (
<div className="row">
<AsyncLoader
promiseFn={async () => {
const res = await APIFetchV1<UGSWithRankingData[]>(
`/users/${user.id}/game-stats`
);
if (!res.success) {
throw new Error(res.description);
}
return res.body.sort((a, b) => {
if (a.game === b.game) {
const gameConfig = GetGameConfig(a.game);
return (
gameConfig.validPlaytypes.indexOf(a.playtype) -
gameConfig.validPlaytypes.indexOf(b.playtype)
);
}
const i1 = TachiConfig.games.indexOf(a.game);
const i2 = TachiConfig.games.indexOf(b.game);
return i1 - i2;
});
}}
>
{ugs =>
ugs.map(e => (
<GameStatContainer ugs={e} reqUser={user} key={`${e.game}:${e.playtype}`} />
))
}
</AsyncLoader>
</div>
);
}
function DashboardNotLoggedIn() {
return (
<div style={{ fontSize: "1.3rem" }}>
<h1 className="mb-4">Welcome to {TachiConfig.name}!</h1>
<h4>
Looks like you're not logged in. If you've got an account,{" "}
<Link to="/login">Login!</Link>
</h4>
<Divider />
<h1 className="my-4">I'm New Around Here, What is this?</h1>
<span>
<b>{TachiConfig.name}</b> is a Rhythm Game Score Tracker. That means we...
</span>
<Divider />
<FeatureContainer
tagline="Track Your Scores."
description={`${TachiConfig.name} supports a bunch of your favourite games, and integrates with many existing services to make sure no score is lost to the void. Furthermore, it's backed by an Open-Source API, so your scores are always available!`}
/>
<FeatureContainer
tagline="Analyse Your Scores."
description={`${TachiConfig.name} analyses your scores for you, breaking them down into all the statistics you'll ever need. No more spreadsheets!`}
/>
<FeatureContainer
tagline="Provide Cool Features."
description={`${TachiConfig.name} implements the features rhythm gamers already talk about. Break your scores down into sessions, Showcase your best metrics on your profile, study your progress on folders - it's all there, and done for you!`}
/>
<Divider />
<div className="col-12 text-center" style={{ paddingTop: 50, height: 250 }}>
Interested? You can register right now for <b>free</b>!
<br />
<LinkButton to="/register" className="mt-4 btn-outline-primary">
Register!
</LinkButton>
</div>
</div>
);
}
function FeatureContainer({ tagline, description }: { tagline: string; description: string }) {
return (
<div className="row my-4 mb-16" style={{ lineHeight: "1.3", fontSize: "1.15rem" }}>
<div className="col-12 col-lg-6">
<h1 className="display-4">{tagline}</h1>
<span>{description}</span>
</div>
</div>
);
}
function DashboardLoggedInNoScores({ user }: { user: PublicUserDocument }) {
return (
<div>
<span className="display-4">
Welcome to {TachiConfig.name}, {user.username}!
</span>
<h4 className="mt-4">It looks like you have no scores. Let's get you set up!</h4>
<Divider />
<h4 style={{ lineHeight: 2 }}>
Once you've got some scores imported, we'll analyse your scores.
<br />
You'll get a profile for that game, and a position on the leaderboards!
</h4>
<LinkButton className="btn-outline-primary" to="/dashboard/import">
Import some scores!
</LinkButton>
</div>
);
}
@@ -0,0 +1,46 @@
import useSetSubheader from "components/layout/header/useSetSubheader";
import React from "react";
import { Link } from "react-router-dom";
import { Game, GetGameConfig } from "tachi-common";
export default function PlaytypeSelect({
game,
base,
subheaderCrumbs,
subheaderTitle,
}: {
game: Game;
base: string;
subheaderCrumbs: string[] | string;
subheaderTitle: string;
}) {
const gameConfig = GetGameConfig(game);
useSetSubheader(subheaderCrumbs, [game], subheaderTitle);
return (
<div className="col-12 col-lg-6 mx-auto">
<div className="card card-custom">
<div className="card-header">
<h3>Playtype Selector</h3>
</div>
<div className="card-body">
This game has multiple playtypes. Please select one to view!
</div>
<div className="card-footer">
<div className="d-flex justify-content-center btn-group">
{gameConfig.validPlaytypes.map(pt => (
<Link
key={pt}
className="btn btn-outline-primary float-right"
to={`${base}/${pt}`}
>
{pt}
</Link>
))}
</div>
</div>
</div>
</div>
);
}
@@ -0,0 +1,348 @@
import { APIFetchV1, UnsuccessfulAPIFetchResponse } from "util/api";
import { CreateUserMap } from "util/data";
import { SelectRightChart } from "util/misc";
import { MillisToSince } from "util/time";
import useSetSubheader from "components/layout/header/useSetSubheader";
import Card from "components/layout/page/Card";
import LampCell from "components/tables/cells/LampCell";
import ScoreCell from "components/tables/cells/ScoreCell";
import MiniTable from "components/tables/components/MiniTable";
import PBTable from "components/tables/pbs/PBTable";
import ProfilePicture from "components/user/ProfilePicture";
import ApiError from "components/util/ApiError";
import Icon from "components/util/Icon";
import Loading from "components/util/Loading";
import Muted from "components/util/Muted";
import SelectButton from "components/util/SelectButton";
import { UserContext } from "context/UserContext";
import React, { useContext, useMemo, useState } from "react";
import { Col, Row } from "react-bootstrap";
import { useQuery } from "react-query";
import { Link, useParams } from "react-router-dom";
import {
ChartDocument,
FormatDifficulty,
GetGameConfig,
GetGamePTConfig,
integer,
PBScoreDocument,
PublicUserDocument,
SongDocument,
} from "tachi-common";
import { ChartPBLeaderboardReturn, UGPTChartLeaderboardAdjacent } from "types/api-returns";
import { GamePT, SetState } from "types/react";
import { PBDataset } from "types/tables";
// This component forms a wrapper around the Real GPT Chart Page
// which handles the case where activeChart == null.
export default function GPTChartPage({
chart,
setActiveChart,
game,
song,
playtype,
allCharts,
}: {
song: SongDocument;
chart: ChartDocument | null;
setActiveChart: SetState<ChartDocument | null>;
allCharts: ChartDocument[];
} & GamePT) {
const { difficulty } = useParams<{ difficulty: string }>();
const gptConfig = GetGamePTConfig(game, playtype);
const formatSongTitle = `${song.artist} - ${song.title}`;
const formatDiff = chart ? FormatDifficulty(chart, game) : "Loading...";
useSetSubheader(
["Games", GetGameConfig(game).name, playtype, "Songs", formatSongTitle, formatDiff],
[game, playtype, chart],
`${formatSongTitle} (${formatDiff})`
);
setActiveChart(SelectRightChart(gptConfig, difficulty, allCharts));
if (!chart) {
return <Loading />;
}
return <InternalGPTChartPage chart={chart} game={game} song={song} playtype={playtype} />;
}
interface ChartPBData {
leaderboard: ChartPBLeaderboardReturn;
adjacent?: UGPTChartLeaderboardAdjacent;
playcount: integer;
}
function InternalGPTChartPage({
chart,
game,
song,
playtype,
}: {
song: SongDocument;
chart: ChartDocument;
} & GamePT) {
const { user } = useContext(UserContext);
const [mode, setMode] = useState<"leaderboard" | "adjacent">("leaderboard");
const { data, isLoading, error } = useQuery<ChartPBData, UnsuccessfulAPIFetchResponse>(
["PBInfo", `${chart.chartID}`],
async () => {
const lRes = await APIFetchV1<ChartPBLeaderboardReturn>(
`/games/${game}/${playtype}/charts/${chart.chartID}/pbs`
);
if (!lRes.success) {
throw lRes;
}
const pRes = await APIFetchV1<{ count: integer }>(
`/games/${game}/${playtype}/charts/${chart.chartID}/playcount`
);
if (!pRes.success) {
throw pRes;
}
if (user) {
const nRes = await APIFetchV1<UGPTChartLeaderboardAdjacent>(
`/users/${user.id}/games/${game}/${playtype}/pbs/${chart.chartID}/leaderboard-adjacent`
);
if (!nRes.success) {
return { leaderboard: lRes.body, playcount: pRes.body.count };
}
return {
leaderboard: lRes.body,
adjacent: nRes.body,
playcount: pRes.body.count,
};
}
return { leaderboard: lRes.body, playcount: pRes.body.count };
}
);
if (error) {
return <ApiError error={error} />;
}
if (!data || isLoading) {
return <Loading />;
}
if (data.leaderboard.pbs.length === 0) {
return <div className="text-center my-8">Nobody has played this chart!</div>;
}
const userMap = CreateUserMap(data.leaderboard.users);
if (data.adjacent) {
for (const user of data.adjacent.users) {
userMap.set(user.id, user);
}
}
if (user) {
// Add current user, since there's no guarantee they are returned from either API.
userMap.set(user.id, user);
}
return (
<Row>
<Col xs={12} className="text-center mb-2">
<h4 className="display-4">Leaderboard</h4>
</Col>
<Col xs={12} className="d-flex justify-content-center mb-8">
<div className="btn-group">
<SelectButton value={mode} setValue={setMode} id="leaderboard">
<Icon type="trophy" />
Best 100
</SelectButton>
<SelectButton
value={mode}
setValue={setMode}
id="adjacent"
disabled={!data.adjacent}
>
<Icon type="user" />
Your Position
</SelectButton>
</div>
</Col>
<TopShowcase data={data} user={user} userMap={userMap} />
<Col xs={12} className="mt-4">
<ChartLeaderboardTable
{...{ data, game, playtype, user, userMap, chart, song, mode }}
/>
</Col>
</Row>
);
}
function ChartLeaderboardTable({
data,
userMap,
user,
game,
playtype,
mode,
chart,
song,
}: {
data: ChartPBData;
user: PublicUserDocument | null;
userMap: Map<integer, PublicUserDocument>;
mode: "leaderboard" | "adjacent";
chart: ChartDocument;
song: SongDocument;
} & GamePT) {
const dataset: PBDataset = useMemo(() => {
const ds: PBDataset = [];
let pbs = [];
if (mode === "leaderboard") {
pbs = data.leaderboard.pbs;
} else {
pbs = [
...data.adjacent!.adjacentAbove,
data.adjacent!.pb,
...data.adjacent!.adjacentBelow,
];
}
for (const pb of pbs) {
ds.push({
...pb,
__related: {
chart,
song,
index: pb.rankingData.rank - 1,
user: userMap.get(pb.userID)!,
},
});
}
return ds;
}, [data, mode, user]);
return (
<PBTable
key={mode}
dataset={dataset}
game={game}
playtype={playtype}
showChart={false}
showUser
/>
);
}
function TopShowcase({
data,
user,
userMap,
}: {
data: ChartPBData;
user: PublicUserDocument | null;
userMap: Map<integer, PublicUserDocument>;
}) {
// We have a couple of conditions.
// User is #1: col-12 #1,
// User has played: col-6 col-6,
// User has not played: col-12 #1,
const bestPlay = data.leaderboard.pbs[0];
const bestUser = userMap.get(bestPlay.userID)!;
// User hasn't played, or isn't logged in or something.
if (user?.id === bestPlay.userID) {
return (
<Col xs={12}>
<PlayCard name="Best Play" pb={bestPlay} user={bestUser} />
</Col>
);
}
if (!data.adjacent) {
return (
<>
<Col xs={12} lg={6}>
<PlayCard name="Best Play" pb={bestPlay} user={bestUser} />
</Col>
<Col xs={12} lg={6}>
<Card header="Your Score" className="text-center">
<Muted>You've not played this chart.</Muted>
</Card>
</Col>
</>
);
}
const thisUsersPlay = data.adjacent.pb;
return (
<>
<Col xs={12} lg={6}>
<PlayCard name="Best Play" pb={bestPlay} user={bestUser} />
</Col>
<Col xs={12} lg={6}>
<PlayCard name="Your Score" pb={thisUsersPlay} user={user!} />
</Col>
</>
);
}
function PlayCard({
pb,
user,
name,
}: {
pb: PBScoreDocument;
user: PublicUserDocument;
name: string;
}) {
return (
<Card header={name}>
<Row className="align-items-center">
<Col lg={3}>
<ProfilePicture user={user} />
</Col>
<Col lg={3}>
<h4>
<Link
className="gentle-link"
to={`/dashboard/users/${user.username}/games/${pb.game}/${pb.playtype}`}
>
{user.username}
</Link>
</h4>
<strong className="display-4">#{pb.rankingData.rank}</strong>
<span className="text-muted">/{pb.rankingData.outOf}</span>
</Col>
<Col lg={6}>
<MiniTable headers={["Score Info"]}>
<tr>
<ScoreCell score={pb} />
</tr>
<tr>
<LampCell score={pb} />
</tr>
</MiniTable>
<div className="text-center">
<Muted>
{pb.timeAchieved
? MillisToSince(pb.timeAchieved) ?? ""
: "No Timestamp Info"}
</Muted>
</div>
</Col>
</Row>
</Card>
);
}
@@ -0,0 +1,28 @@
import useSetSubheader from "components/layout/header/useSetSubheader";
import Card from "components/layout/page/Card";
import DebugContent from "components/util/DebugContent";
import React from "react";
import { FormatGame, GetGameConfig, GetGamePTConfig } from "tachi-common";
import { GamePT } from "types/react";
export default function GPTDevInfo({ game, playtype }: GamePT) {
useSetSubheader(
["Games", GetGameConfig(game).name, playtype, "Dev Info"],
[game, playtype],
`${FormatGame(game, playtype)} Dev Info`
);
const gameConfig = GetGameConfig(game);
const gptConfig = GetGamePTConfig(game, playtype);
return (
<>
<Card header="Game Configuration">
<DebugContent data={gameConfig} />
</Card>
<Card className="mt-4" header="GPT Configuration">
<DebugContent data={gptConfig} />
</Card>
</>
);
}
@@ -0,0 +1,245 @@
import { CreateChartMap, CreateSongMap, CreateUserMap } from "util/data";
import { UppercaseFirst } from "util/misc";
import { NumericSOV, StrSOV } from "util/sorts";
import ClassBadge from "components/game/ClassBadge";
import useSetSubheader from "components/layout/header/useSetSubheader";
import IndexCell from "components/tables/cells/IndexCell";
import UserCell from "components/tables/cells/UserCell";
import TachiTable, { Header } from "components/tables/components/TachiTable";
import PBTable from "components/tables/pbs/PBTable";
import ApiError from "components/util/ApiError";
import Divider from "components/util/Divider";
import Icon from "components/util/Icon";
import Loading from "components/util/Loading";
import Muted from "components/util/Muted";
import useApiQuery from "components/util/query/useApiQuery";
import SelectButton from "components/util/SelectButton";
import useScoreRatingAlg, { useProfileRatingAlg } from "components/util/useScoreRatingAlg";
import { UGPTSettingsContext } from "context/UGPTSettingsContext";
import React, { useContext, useState } from "react";
import { Col, Form, Row } from "react-bootstrap";
import { FormatGame, GetGameConfig, GetGamePTConfig } from "tachi-common";
import { ScoreLeaderboardReturns, UserLeaderboardReturns } from "types/api-returns";
import { GamePT } from "types/react";
import { PBDataset, UGSDataset } from "types/tables";
export default function GPTLeaderboardsPage({ game, playtype }: GamePT) {
useSetSubheader(
["Games", GetGameConfig(game).name, playtype, "Leaderboards"],
[game, playtype],
`${FormatGame(game, playtype)} Leaderboards`
);
const { settings } = useContext(UGPTSettingsContext);
const [mode, setMode] = useState<"profile" | "score">("profile");
return (
<Row>
<Col xs={12} className="d-flex justify-content-center">
<div className="btn-group">
<SelectButton id="profile" value={mode} setValue={setMode}>
<Icon type="user" />
User Leaderboards
</SelectButton>
<SelectButton id="score" value={mode} setValue={setMode}>
<Icon type="sort-numeric-up-alt" />
Score Leaderboards
</SelectButton>
</div>
</Col>
<Col xs={12}>
<Divider />
{mode === "profile" ? (
<ProfileLeaderboard game={game} playtype={playtype} />
) : (
<ScoreLeaderboard game={game} playtype={playtype} />
)}
</Col>
</Row>
);
}
function ProfileLeaderboard({ game, playtype }: GamePT) {
const gptConfig = GetGamePTConfig(game, playtype);
const defaultAlg = useProfileRatingAlg(game, playtype);
const [alg, setAlg] = useState(defaultAlg);
const SelectComponent =
gptConfig.profileRatingAlgs.length > 1 ? (
<Form.Control as="select" value={alg} onChange={e => setAlg(e.target.value as any)}>
{gptConfig.profileRatingAlgs.map(e => (
<option key={e} value={e}>
{UppercaseFirst(e)}
</option>
))}
</Form.Control>
) : null;
const { data, isLoading, error } = useApiQuery<UserLeaderboardReturns>(
`/games/${game}/${playtype}/leaderboard?alg=${alg}&limit=500`
);
if (error) {
return (
<>
{SelectComponent}
<ApiError error={error} />
</>
);
}
if (!data || isLoading) {
return (
<>
{SelectComponent}
<Loading />
</>
);
}
const userMap = CreateUserMap(data.users);
const userDataset: UGSDataset = [];
for (const [index, gs] of data.gameStats.entries()) {
userDataset.push({
...gs,
__related: {
user: userMap.get(gs.userID)!,
index,
},
});
}
return (
<>
{SelectComponent}
<Divider />
<TachiTable
dataset={userDataset}
entryName="Rankers"
headers={[
["Ranking", "Rank", NumericSOV(x => x.__related.index)],
["User", "User", StrSOV(x => x.__related.user.username)],
...gptConfig.profileRatingAlgs.map(
e =>
[
UppercaseFirst(e),
UppercaseFirst(e),
NumericSOV(x => x.ratings[e] ?? -Infinity),
] as Header<UGSDataset[0]>
),
["Classes", "Classes"],
]}
rowFunction={r => (
<tr>
<IndexCell index={r.__related.index} />
<UserCell game={game} playtype={playtype} user={r.__related.user} />
{gptConfig.profileRatingAlgs.map(e => (
<td key={e}>
{r.ratings[e]
? gptConfig.profileRatingAlgFormatters[e]
? gptConfig.profileRatingAlgFormatters[e]!(r.ratings[e]!)
: r.ratings[e]!.toFixed(2)
: "No Data."}
</td>
))}
<td>
{Object.keys(r.classes).length === 0 ? (
<Muted>None</Muted>
) : (
Object.entries(r.classes).map(([k, v]) => (
<ClassBadge
key={k}
classSet={k as keyof typeof r.classes}
game={game}
playtype={playtype}
classValue={v}
/>
))
)}
</td>
</tr>
)}
/>
</>
);
}
function ScoreLeaderboard({ game, playtype }: GamePT) {
const gptConfig = GetGamePTConfig(game, playtype);
const defaultAlg = useScoreRatingAlg(game, playtype);
const [alg, setAlg] = useState(defaultAlg);
const SelectComponent =
gptConfig.scoreRatingAlgs.length > 1 ? (
<Form.Control as="select" value={alg} onChange={e => setAlg(e.target.value as any)}>
{gptConfig.scoreRatingAlgs.map(e => (
<option key={e} value={e}>
{UppercaseFirst(e)}
</option>
))}
</Form.Control>
) : null;
const { data, isLoading, error } = useApiQuery<ScoreLeaderboardReturns>(
`/games/${game}/${playtype}/score-leaderboard?alg=${alg}`
);
if (error) {
return (
<>
{SelectComponent}
<ApiError error={error} />
</>
);
}
if (!data || isLoading) {
return (
<>
{SelectComponent}
<Loading />
</>
);
}
const songMap = CreateSongMap(data.songs);
const chartMap = CreateChartMap(data.charts);
const userMap = CreateUserMap(data.users);
const pbDataset: PBDataset = [];
for (const [index, pb] of data.pbs.entries()) {
pbDataset.push({
...pb,
__related: {
chart: chartMap.get(pb.chartID)!,
song: songMap.get(pb.songID)!,
index,
user: userMap.get(pb.userID)!,
},
});
}
return (
<>
{SelectComponent}
<Divider />
<PBTable
dataset={pbDataset}
game={game}
playtype={playtype}
showUser
showChart
indexCol
alg={alg}
/>
</>
);
}
@@ -0,0 +1,379 @@
import { APIFetchV1 } from "util/api";
import { DEFAULT_BAR_PROPS } from "util/charts";
import { CreateChartMap, CreateGoalMap, CreateSongMap, CreateUserMap } from "util/data";
import { UppercaseFirst } from "util/misc";
import { MillisToSince } from "util/time";
import { ResponsiveBar } from "@nivo/bar";
import { BarChartTooltip } from "components/charts/ChartTooltip";
import ClassBadge from "components/game/ClassBadge";
import useSetSubheader from "components/layout/header/useSetSubheader";
import Card from "components/layout/page/Card";
import GoalSubTable from "components/tables/goals/GoalSubTable";
import ScoreTable from "components/tables/scores/ScoreTable";
import ApiError from "components/util/ApiError";
import AsyncLoader from "components/util/AsyncLoader";
import Divider from "components/util/Divider";
import Icon from "components/util/Icon";
import Loading from "components/util/Loading";
import useApiQuery from "components/util/query/useApiQuery";
import { TachiConfig } from "lib/config";
import React, { useEffect, useState } from "react";
import { Col, Form, Row } from "react-bootstrap";
import { Link } from "react-router-dom";
import {
ChartDocument,
ClassAchievementDocument,
FormatGame,
gameClasses,
GamePTConfig,
GetGameConfig,
GetGamePTConfig,
GoalDocument,
integer,
PublicUserDocument,
ScoreDocument,
SongDocument,
GoalSubscriptionDocument,
} from "tachi-common";
import { RecentClassesReturn } from "types/api-returns";
import { GamePT } from "types/react";
import { ScoreDataset } from "types/tables";
export default function GPTMainPage({ game, playtype }: GamePT) {
useSetSubheader(
["Games", GetGameConfig(game).name, playtype],
[game, playtype],
FormatGame(game, playtype)
);
const gptConfig = GetGamePTConfig(game, playtype);
return (
<>
<Card header="Recent Highlighted Scores">
<RecentHighlightedScoresComponent game={game} playtype={playtype} />
</Card>
<Divider />
<Card header="Recent Achieved Goals">
<RecentAchievedGoalsComponent game={game} playtype={playtype} />
</Card>
{Object.keys(gptConfig.classHumanisedFormat).length !== 0 && (
<>
<Divider />
<Card header="Recently Achieved Classes">
<RecentClassesComponent game={game} playtype={playtype} />
</Card>
<Divider />
<Card header="Class Distributions">
<ClassDistributionComponent game={game} playtype={playtype} />
</Card>
</>
)}
</>
);
}
function RecentAchievedGoalsComponent({ game, playtype }: GamePT) {
const { data, isLoading, error } = useApiQuery<{
users: PublicUserDocument[];
goals: GoalDocument[];
goalSubs: GoalSubscriptionDocument[];
}>(`/games/${game}/${playtype}/goals/recently-achieved`);
if (error) {
return <ApiError error={error} />;
}
if (!data || isLoading) {
return <Loading />;
}
const dataset = [];
const userMap = CreateUserMap(data.users);
const goalMap = CreateGoalMap(data.goals);
for (const userGoal of data.goalSubs) {
const user = userMap.get(userGoal.userID);
const goal = goalMap.get(userGoal.goalID);
if (!user || !goal) {
console.warn(
`Couldn't find goal or user for ${userGoal.userID}, ${userGoal.goalID}? Skipping.`
);
continue;
}
dataset.push({
...userGoal,
__related: {
user,
goal,
},
});
}
return (
<Row>
<Col className="text-center" xs={12}>
Here's what goals players have recently achieved!
</Col>
<Col xs={12}>
<Divider />
</Col>
<Col xs={12}>
<GoalSubTable dataset={dataset} game={game} playtype={playtype} />
</Col>
</Row>
);
}
function RecentHighlightedScoresComponent({ game, playtype }: GamePT) {
const { data, isLoading, error } = useApiQuery<{
scores: ScoreDocument[];
users: PublicUserDocument[];
charts: ChartDocument[];
songs: SongDocument[];
}>(`/games/${game}/${playtype}/scores/highlighted`);
if (error) {
return <ApiError error={error} />;
}
if (!data || isLoading) {
return <Loading />;
}
const dataset: ScoreDataset = [];
const songMap = CreateSongMap(data.songs);
const chartMap = CreateChartMap(data.charts);
const userMap = CreateUserMap(data.users);
for (const [index, score] of data.scores.entries()) {
dataset.push({
...score,
__related: {
chart: chartMap.get(score.chartID)!,
song: songMap.get(score.songID)!,
index,
user: userMap.get(score.userID)!,
},
});
}
return (
<Row>
<Col className="text-center" xs={12}>
Here's what players have recently marked as highlighted scores!
</Col>
<Col xs={12}>
<Divider />
</Col>
<Col xs={12}>
<ScoreTable userCol dataset={dataset} game={game} playtype={playtype} />
</Col>
</Row>
);
}
function RecentClassesComponent({ game, playtype }: GamePT) {
const { data, isLoading, error } = useApiQuery<RecentClassesReturn>(
`/games/${game}/${playtype}/recent-classes`
);
if (error) {
return <ApiError error={error} />;
}
if (!data || isLoading) {
return <Loading />;
}
// if (data.classes.length === 0) {
// return <div className="text-center">Looks like nobodies got anything here... yet!</div>;
// }
const userMap = new Map<integer, PublicUserDocument>();
for (const user of data.users) {
userMap.set(user.id, user);
}
return (
<div>
{data.classes.length === 0 ? (
<span>Looks like nobodies ever got a class for this game. Ah well.</span>
) : (
data.classes.map((c, i) => {
const user = userMap.get(c.userID)!;
return <AchievedClassRow key={i} classInfo={c} user={user} />;
})
)}
</div>
);
}
function AchievedClassRow({
classInfo,
user,
}: {
classInfo: ClassAchievementDocument;
user: PublicUserDocument;
}) {
const game = classInfo.game;
const playtype = classInfo.playtype;
return (
<div className="py-2 timeline-element">
<Link to={`/dashboard/users/${user.username}/games/${game}/${playtype}`}>
{user.username}
</Link>{" "}
achieved{" "}
<ClassBadge
classSet={classInfo.classSet}
game={game}
playtype={playtype}
classValue={classInfo.classValue}
/>
{classInfo.classOldValue !== null && (
<>
{" "}
(Raised from{" "}
<ClassBadge
classSet={classInfo.classSet}
game={game}
playtype={playtype}
classValue={classInfo.classOldValue}
/>
)
</>
)}
<span className="float-right d-none d-lg-inline">
<Icon type="clock" />
<span className="pl-1">{MillisToSince(classInfo.timeAchieved)}</span>
</span>
</div>
);
}
function ClassDistributionComponent({ game, playtype }: GamePT) {
const gptConfig = GetGamePTConfig(game, playtype);
const [className, setClassName] = useState<gameClasses.AllClassSets>(
Object.keys(gptConfig.classHumanisedFormat)[0] as gameClasses.AllClassSets
);
useEffect(() => {
const gptConfig = GetGamePTConfig(game, playtype);
setClassName(Object.keys(gptConfig.classHumanisedFormat)[0] as gameClasses.AllClassSets);
}, [game, playtype]);
return (
<Row>
<Col xs={12}>
This will show the distribution of classes among all the players on{" "}
{TachiConfig.name}!
</Col>
<Col xs={12}>
<Divider />
</Col>
<Col xs={12} lg={4} className="offset-lg-4">
<Form.Control
as="select"
value={className}
onChange={e => setClassName(e.target.value as gameClasses.AllClassSets)}
>
{Object.keys(gptConfig.classHumanisedFormat).map(e => (
<option value={e} key={e}>
{UppercaseFirst(e)}
</option>
))}
</Form.Control>
</Col>
<Col xs={12}>
<Divider />
</Col>
<Col xs={12}>
<AsyncLoader
promiseFn={async () => {
const res = await APIFetchV1<Record<string, integer>>(
`/games/${game}/${playtype}/class-distribution?class=${className}`
);
if (!res.success) {
throw new Error(res.description);
}
return res.body;
}}
>
{d => <ClassDistChart data={d} gptConfig={gptConfig} className={className} />}
</AsyncLoader>
</Col>
</Row>
);
}
function ClassDistChart({
data,
gptConfig,
className,
}: {
data: Record<string, integer>;
gptConfig: GamePTConfig;
className: gameClasses.AllClassSets;
}) {
const dataset = [];
for (const key in gptConfig.classHumanisedFormat[className]) {
dataset.push({
id: key,
count: data[key] ?? 0,
});
}
if (dataset.length === 0) {
return (
<div style={{ height: 400 }}>
<div className="d-flex justify-content-center align-items-center h-100">
<span>Looks like nobody has any of these classes yet.</span>
</div>
</div>
);
}
return (
<div style={{ height: 200 + dataset.length * 10 }}>
<ResponsiveBar
data={dataset}
keys={["count"]}
indexBy="id"
layout="horizontal"
margin={{ left: 80, bottom: 40, top: 20, right: 20 }}
colors={v => "#cc527a"}
axisLeft={{
format: v => gptConfig.classHumanisedFormat[className][v]?.display,
}}
tooltip={d => (
<BarChartTooltip
point={d}
renderFn={d => (
<div className="w-100 text-center">
{
gptConfig.classHumanisedFormat[className][
d.indexValue as number
].display
}
: {(d.data as any).count}
</div>
)}
/>
)}
{...DEFAULT_BAR_PROPS}
/>
</div>
);
}
@@ -0,0 +1,125 @@
import { CreateSongMap } from "util/data";
import { NumericSOV, StrSOV } from "util/sorts";
import useSetSubheader from "components/layout/header/useSetSubheader";
import DifficultyCell from "components/tables/cells/DifficultyCell";
import TitleCell from "components/tables/cells/TitleCell";
import TachiTable from "components/tables/components/TachiTable";
import ApiError from "components/util/ApiError";
import DebounceSearch from "components/util/DebounceSearch";
import Divider from "components/util/Divider";
import Loading from "components/util/Loading";
import useApiQuery from "components/util/query/useApiQuery";
import React, { useState } from "react";
import { Col, Row } from "react-bootstrap";
import {
ChartDocument,
FormatGame,
GetGameConfig,
GetGamePTConfig,
integer,
SongDocument,
} from "tachi-common";
import { GamePT } from "types/react";
export default function GPTSongsPage({ game, playtype }: GamePT) {
useSetSubheader(
["Games", GetGameConfig(game).name, playtype, "Songs"],
[game, playtype],
`${FormatGame(game, playtype)} Songs`
);
const [search, setSearch] = useState("");
return (
<Row>
<Col xs={12}>
<DebounceSearch
className="form-control-lg"
setSearch={setSearch}
placeholder="Search songs and charts..."
/>
<Divider />
</Col>
<Col xs={12}>
<SearchSongsTable game={game} search={search} playtype={playtype} />
</Col>
</Row>
);
}
function SearchSongsTable({ game, playtype, search }: { search: string } & GamePT) {
const params = new URLSearchParams({ search });
const { data, isLoading, error } = useApiQuery<{
charts: (ChartDocument & { __playcount: integer })[];
songs: SongDocument[];
}>(`/games/${game}/${playtype}/charts${search !== "" ? `?${params.toString()}` : ""}`);
if (error) {
return <ApiError error={error} />;
}
if (!data || isLoading) {
return <Loading />;
}
const songMap = CreateSongMap(data.songs);
const dataset = [];
for (const chart of data.charts) {
dataset.push({
...chart,
__related: {
song: songMap.get(chart.songID)!,
},
});
}
const gptConfig = GetGamePTConfig(game, playtype);
return (
<>
{search === "" && (
<div className="w-100 text-center">
Displaying the most played charts for {FormatGame(game, playtype)}
</div>
)}
<TachiTable
entryName="Charts"
dataset={dataset}
headers={[
[
"Chart",
"Chart",
NumericSOV(chart => {
for (const tierlist of gptConfig.tierlists) {
if (chart.tierlistInfo[tierlist]) {
return chart.tierlistInfo[tierlist]!.value;
}
}
return chart.levelNum;
}),
],
["Song Title", "Song", StrSOV(x => x.__related.song.title)],
["Playcount", "Playcount", NumericSOV(x => x.__playcount)],
]}
searchFunctions={{
title: x => x.__related.song.title,
artist: x => x.__related.song.artist,
playcount: x => x.__playcount,
difficulty: x => x.difficulty,
level: x => x.levelNum,
}}
rowFunction={d => (
<tr>
<DifficultyCell game={game} chart={d} />
<TitleCell chart={d} game={game} song={d.__related.song} />
<td>{d.__playcount}</td>
</tr>
)}
/>
</>
);
}
@@ -0,0 +1,48 @@
import useSetSubheader from "components/layout/header/useSetSubheader";
import Divider from "components/util/Divider";
import ExternalLink from "components/util/ExternalLink";
import { TachiConfig } from "lib/config";
import React from "react";
import { Alert } from "react-bootstrap";
export default function BarbatosPage() {
useSetSubheader(["Import Scores", "Barbatos"]);
return (
<div>
<h1 className="text-center mb-4">What Is Barbatos?</h1>
<div>
Barbatos is a <code>.dll</code> file that hooks into SDVX and automatically sends
the scores to a server. {TachiConfig.name} is compatible with what Barbatos sends,
so you can use it to submit scores!
</div>
<Alert variant="warning">Barbatos does not support EXCEED GEAR yet.</Alert>
<Divider />
<h1 className="text-center my-4">Setup Instructions</h1>
<ol className="instructions-list">
<li>
Download <code>barbatos.dll</code> from{" "}
<ExternalLink href="http://f.wcal.xyz/2W9K7kXl">here</ExternalLink>.
</li>
<li>
Download your config file{" "}
<ExternalLink href="/client-file-flow/CXBarbatos">here</ExternalLink>. <br />
<b>This file contains an API Key, which is meant to be kept secret!</b>
</li>
<li>
Place <code>barbatos.dll</code> and the above config file inside your SDVX
folder, next to your <code>.bat</code> file.
</li>
<ul className="instructions-list">
<li>
BemaniTools: Add <code>-K barbatos.dll</code> to your <code>.bat</code>{" "}
file.
</li>
<li>
SpiceTools: Add <code>-k barbatos.dll</code> to your <code>.bat</code> file.
</li>
</ul>
</ol>
</div>
);
}
@@ -0,0 +1,45 @@
import ImportFileInfo from "components/imports/ImportFileInfo";
import useSetSubheader from "components/layout/header/useSetSubheader";
import { TachiConfig } from "lib/config";
import React, { useState } from "react";
import { BatchManual, FormatGame, GetGameConfig, GetGamePTConfig } from "tachi-common";
import { ImportStates, NotStartedState } from "types/import";
export default function BatchManualPage() {
useSetSubheader(["Dashboard", "Import Scores", "Batch Manual"]);
return (
<ImportFileInfo
acceptMime="application/json"
name="Batch Manual"
importType="file/batch-manual"
parseFunction={(text: string) => {
const data: BatchManual = JSON.parse(text);
const gameConfig = GetGameConfig(data.meta.game);
if (!gameConfig) {
throw new Error(
`Invalid game ${data.meta.game}. Expected any of ${TachiConfig.games}.`
);
}
const gptConfig = GetGamePTConfig(data.meta.game, data.meta.playtype);
if (!gptConfig) {
throw new Error(
`Invalid Playtype ${data.meta.playtype}. Expected any of ${gameConfig.validPlaytypes}.`
);
}
return {
valid: true,
info: {
Game: FormatGame(data.meta.game, data.meta.playtype),
Scores: data.scores.length,
},
};
}}
/>
);
}
@@ -0,0 +1,29 @@
import TISInfo from "components/imports/TISInfo";
import useSetSubheader from "components/layout/header/useSetSubheader";
import Divider from "components/util/Divider";
import Muted from "components/util/Muted";
import { TachiConfig } from "lib/config";
import React from "react";
import { Link } from "react-router-dom";
export default function BeatorajaDBPage() {
useSetSubheader(["Import Scores", "Beatoraja Database File"]);
return (
<>
<TISInfo name="Beatoraja Database" />
<Divider />
<Muted>
This method is intended for syncing up with existing scores. For new scores, you
should set up the <Link to="/dashboard/import/beatoraja-ir">Beatoraja IR</Link>, for
automatic score uploading.
</Muted>
<br />
<Muted>
Note: If you submit a score on a chart that {TachiConfig.name} doesn't recognise,
you'll need to wait until atleast 2 other players submit scores for that chart
before it'll show up. This is to combat accidental IR spam.
</Muted>
</>
);
}
@@ -0,0 +1,123 @@
import useSetSubheader from "components/layout/header/useSetSubheader";
import Divider from "components/util/Divider";
import ExternalLink from "components/util/ExternalLink";
import Muted from "components/util/Muted";
import { TachiConfig } from "lib/config";
import React from "react";
const WIN_BAT = `
REM *** Set system-wide "_JAVA_OPTIONS" environment variable to use OpenGL pipeline (improved performance of > 30% potentially. Also use anti-aliasing for non-LR2 fonts, and finally allow Swing framework to utilize AA and GTKLookAndFeel for config window. ***
set _JAVA_OPTIONS='-Dsun.java2d.opengl=true -Dawt.useSystemAAFontSettings=on -Dswing.aatext=true -Dswing.defaultlaf=com.sun.java.swing.plaf.gtk.GTKLookAndFeel' -Dfile.encoding="UTF-8"
pushd %~dp0
java -Xms1g -Xmx4g -cp beatoraja.jar;ir/* bms.player.beatoraja.MainLoader
popd
`;
const LINUX_SH = `
#!/bin/bash
export _JAVA_OPTIONS='-Dsun.java2d.opengl=true -Dawt.useSystemAAFontSettings=on -Dswing.aatext=true -Dswing.defaultlaf=com.sun.java.swing.plaf.gtk.GTKLookAndFeel'
cd "$(dirname "$0")"
exec java -Xms1g -Xmx4g -cp beatoraja.jar:ir/* bms.player.beatoraja.MainLoader
`;
export default function BeatorajaIRPage({ game }: { game: "bms" | "pms" }) {
useSetSubheader(["Import Scores", "Beatoraja IR"]);
return (
<div>
<h2 className="text-center mb-4">Beatoraja IR Setup Instructions</h2>
<ol className="instructions-list">
<li>
Download the latest version of the Beatoraja IR{" "}
<ExternalLink href="https://github.com/TNG-dev/tachi-beatoraja-ir/releases">
here
</ExternalLink>
.
</li>
<li>
{game === "bms" ? (
<>
Make sure you're running <b>LR2oraja</b>. {TachiConfig.name} does{" "}
<b>NOT</b> support BMS scores achieved on beatoraja, and will not accept
scores from the client.
<br />
<ExternalLink href="https://github.com/wcko87/lr2oraja/releases">
LR2oraja
</ExternalLink>{" "}
is a one-file change for beatoraja that changes the settings to match
LR2, which is the standard for players.
<br />
Beatoraja has significantly different gauge implementations, which means
cross-comparing scores is unfair.
</>
) : (
<>
Make sure you're running <b>beatoraja</b>. {TachiConfig.name} does{" "}
<b>NOT</b> support PMS scores achieved on LR2oraja, and will not accept
scores from the client.
</>
)}
</li>
<li>
Place the IR <code>.jar</code> file in the <code>ir/</code> folder.
</li>
<li>Open the game options, and navigate to the IR section.</li>
<li>
Select {TachiConfig.name} IR.
<br />
<span className="text-warning">
If the IR isn't showing up, make sure you're launching the game with the{" "}
<code>beatoraja-config.bat</code> file. Otherwise, IRs will never load.
<br />
If this is still not working, you may not have a{" "}
<code>beatoraja-config.bat</code> file that supports custom IRs. Here are
working ones.
<ul>
<li>
<a
download="beatoraja-config.bat"
href={`data:text/plain;base64,${window.btoa(WIN_BAT)}`}
>
Windows
</a>
</li>
<li>
<a
download="beatoraja-config.sh"
href={`data:text/plain;base64,${window.btoa(LINUX_SH)}`}
>
Linux
</a>
</li>
</ul>
</span>
</li>
<li>
Get an API token for the IR by clicking{" "}
<ExternalLink href="/client-file-flow/CXBeatorajaIR">this link</ExternalLink>.
</li>
<li>
Place the API token in the password field. Put your username in as well! The IR
wont load if you don't have a username set.
<br />
<span className="text-warning">
<b>DO NOT PUT YOUR PASSWORD IN THE PASSWORD FIELD!</b>
<br />
For security reasons, you must put the API Key in that field, instead.
</span>
</li>
<li>
That's it! Launch the game and start playing, your scores will automatically
submit to the server.
</li>
</ol>
<Divider />
<Muted>
Note: If you submit a score on a chart that {TachiConfig.name} doesn't recognise,
you'll need to wait until atleast 2 other players submit scores for that chart
before it'll show up. This is to combat accidental IR spam.
</Muted>
</div>
);
}
@@ -0,0 +1,44 @@
import useSetSubheader from "components/layout/header/useSetSubheader";
import Divider from "components/util/Divider";
import ExternalLink from "components/util/ExternalLink";
import { TachiConfig } from "lib/config";
import React from "react";
export default function ChunitachiPage() {
useSetSubheader(["Import Scores", "Chunitachi"]);
return (
<div>
<h1 className="text-center mb-4">What Is Chunitachi?</h1>
<div>
Chunitachi is a <code>.dll</code> file that hooks into CHUNITHM and automatically
sends the scores to a server. {TachiConfig.name} is compatible with what Chunitachi
sends, so you can use it to submit scores!
</div>
<Divider />
<h1 className="text-center my-4">Setup Instructions</h1>
<ol className="instructions-list">
<li>
Download <code>chunitachi.dll</code> from{" "}
<ExternalLink href="https://github.com/tomatosoupcan/ChunItachi/releases">
Here
</ExternalLink>
.
</li>
<li>
Download your <code>ChunItachi.ini</code> config file{" "}
<ExternalLink href="/client-file-flow/CXChunitachi">here</ExternalLink>. <br />
<b>This config file contains an API Key, which should be kept secret!</b>.
</li>
<li>
Follow the remaining install instructions on{" "}
<ExternalLink href="https://github.com/tomatosoupcan/ChunItachi/blob/main/README.md">
GitHub
</ExternalLink>
.
</li>
<li>That's it! Your scores should now automatically submit to the server.</li>
</ol>
</div>
);
}
@@ -0,0 +1,57 @@
import useSetSubheader from "components/layout/header/useSetSubheader";
import Divider from "components/util/Divider";
import ExternalLink from "components/util/ExternalLink";
import { TachiConfig } from "lib/config";
import React from "react";
export default function FervidexPage() {
useSetSubheader(["Import Scores", "Fervidex"]);
return (
<div>
<h1 className="text-center mb-4">What Is Fervidex?</h1>
<div>
Fervidex is a <code>.dll</code> file that hooks into IIDX and automatically sends
the scores to a server. {TachiConfig.name} is compatible with what Fervidex sends,
so you can use it to submit scores!
</div>
<Divider />
<h1 className="text-center my-4">Setup Instructions</h1>
<ol className="instructions-list">
<li>
Download <code>fervidex.dll</code> from{" "}
<ExternalLink href="https://client.fervidex.net/latest.zip">Here</ExternalLink>.
</li>
<li>
Download your <code>fervidex.json</code> config file{" "}
<ExternalLink href="/client-file-flow/CXFervidex">here</ExternalLink>. <br />
<b>This config file contains an API Key, which should be kept secret!</b>.
</li>
<li>
Drop both files into the same folder as your <code>bm2dx.dll</code>.
</li>
<h3 className="my-2">For Infinitas Players</h3>
<ul className="instructions-list">
<li>
You'll also need to download{" "}
<ExternalLink href="https://client.fervidex.net/latest-chainloader.zip">
this
</ExternalLink>
, and extract it to the same folder.
</li>
</ul>
<h3 className="my-2">For Not-Infinitas Players</h3>
<ul className="instructions-list">
<li>
BemaniTools: Add <code>-K fervidex.dll</code> to your <code>.bat</code>{" "}
file.
</li>
<li>
SpiceTools: Add <code>-k fervidex.dll</code> to your <code>.bat</code> file.
</li>
</ul>
<li>That's it! Your scores should now automatically submit to the server.</li>
</ol>
</div>
);
}
@@ -0,0 +1,115 @@
import ImportFileInfo, { MoreDataForm } from "components/imports/ImportFileInfo";
import useSetSubheader from "components/layout/header/useSetSubheader";
import React, { useEffect, useState } from "react";
import { Form } from "react-bootstrap";
import { FileUploadImportTypes } from "tachi-common";
export default function IIDXEamCSVPage({
name,
importType,
}: {
name: string;
importType: FileUploadImportTypes;
}) {
useSetSubheader(["Import Scores", name]);
return (
<ImportFileInfo
acceptMime="text/csv"
importType={importType}
name={name}
parseFunction={ParseFunction}
MoreDataForm={MoreDataForm}
/>
);
}
const MoreDataForm: MoreDataForm = ({ setFulfilled, setInfo }) => {
const [state, setState] = useState<Record<string, string>>({});
useEffect(() => {
setInfo(state);
}, [state]);
return (
<>
<div className="alert alert-danger">
Selecting your playtype here is <b>SUPER</b> important.
<br />
There is <b>ABSOLUTELY NO WAY</b> to know whether an e-am CSV is for SP or DP from
the file alone.
<br />
If you input the wrong playtype here, you will corrupt your account. I'm serious.
Don't make that mistake.
</div>
<select
className="form-control"
onChange={e => {
const playtype = e.target.value;
if (playtype === "") {
setFulfilled(false);
} else {
setState({ ...state, playtype });
setFulfilled(true);
}
}}
>
<option value="">Please select a playtype.</option>
<option value="SP">SP</option>
<option value="DP">DP</option>
</select>
<Form.Group className="mt-4">
<Form.Check
type="checkbox"
checked={!!state.assertPlaytypeCorrect}
onChange={e => {
setState({
...state,
assertPlaytypeCorrect: e.target.checked ? "true" : "",
});
}}
label="Disable Filename Checks"
/>
<Form.Text>
<span className="text-warning">
This will disable a safety feature of checking the uploaded filename for
"SP" or "DP", and prevent you from accidentally selecting the wrong
playtype.
<br />
Use this only if you get errors normally.
</span>
</Form.Text>
</Form.Group>
</>
);
};
const PRE_HV_HEADER_COUNT = 27;
const HV_HEADER_COUNT = 41;
function ParseFunction(data: string) {
const lines: string[] = data.split("\n");
const headers = lines[0].split(",");
if (headers.length !== HV_HEADER_COUNT && headers.length !== PRE_HV_HEADER_COUNT) {
throw new Error(
`Unexpected amount of headers in the file -- Expected ${HV_HEADER_COUNT} or ${PRE_HV_HEADER_COUNT}, received ${headers.length}. Is this an eamusement CSV?`
);
}
if (lines.length === 1) {
throw new Error(`This CSV has no scores? Only found headers.`);
}
const linesWithScores = lines.filter(e => e !== "");
return {
valid: true,
info: {
Scores: linesWithScores.length - 1,
"Game Version": linesWithScores[linesWithScores.length - 1].split(",")[0],
},
};
}
@@ -0,0 +1,429 @@
import useSetSubheader from "components/layout/header/useSetSubheader";
import Card from "components/layout/page/Card";
import ApiError from "components/util/ApiError";
import DiscordLink from "components/util/DiscordLink";
import Divider from "components/util/Divider";
import ExternalLink from "components/util/ExternalLink";
import LinkButton from "components/util/LinkButton";
import Loading from "components/util/Loading";
import useApiQuery from "components/util/query/useApiQuery";
import { UserContext } from "context/UserContext";
import { TachiConfig } from "lib/config";
import React, { useContext, useState } from "react";
import {
APIImportTypes,
FileUploadImportTypes,
Game,
GetGameConfig,
ImportTypes,
integer,
PublicUserDocument,
} from "tachi-common";
export default function ImportPage() {
useSetSubheader(["Import Scores"]);
const [game, setGame] = useState<Game | null>(null);
return (
<div>
<div>
<h4>
Here, you can import score files, Synchronise with existing services, or set up
in-game automatic score uploading!
</h4>
Don't see what you want here? Make a <a>Feature Request</a>, or ask around on the{" "}
<DiscordLink>Discord</DiscordLink>.
<br />
Know how to program, and want to write a script yourself? Check out{" "}
<ExternalLink href="https://tachi.readthedocs.io/en/latest/tachi-server/batch-manual/overview/">
Batch Manual
</ExternalLink>
.
</div>
<Divider />
<select
className="form-control"
onChange={e => setGame(e.target.value === "" ? null : (e.target.value as Game))}
>
<option value="">Please select a game.</option>
{TachiConfig.games.map(e => (
<option value={e} key={e}>
{GetGameConfig(e).name}
</option>
))}
</select>
<Divider />
{game ? <ImportInfoDisplayer game={game} /> : <ShowRecentImports />}
</div>
);
}
function ShowRecentImports() {
const { user } = useContext(UserContext);
if (!user) {
return <>You're not signed in.</>;
}
return <InnerShowRecentImports user={user} />;
}
function InnerShowRecentImports({ user }: { user: PublicUserDocument }) {
const { data, error, isLoading } = useApiQuery<{ importType: ImportTypes; count: integer }[]>(
`/users/${user.id}/recent-imports`
);
if (error) {
return <ApiError error={error} />;
}
if (isLoading || !data) {
return <Loading />;
}
if (data.length === 0) {
return null;
}
return (
<>
<h4>Recently Used Import Methods</h4>
<Divider />
{data
.filter(e => e.importType.startsWith("file/") || e.importType.startsWith("api/"))
.map(e => (
<ImportTypeInfoCard
key={e.importType}
importType={e.importType as FileUploadImportTypes | APIImportTypes}
/>
))}
</>
);
}
function ImportInfoDisplayer({ game }: { game: Game }) {
const gameConfig = GetGameConfig(game);
const Content = [<ImportTypeInfoCard key="file/batch-manual" importType="file/batch-manual" />];
if (game === "iidx") {
Content.unshift(
<ImportInfoCard
name="Fervidex"
href="fervidex"
desc="Automatically import scores from INFINITAS and other clients, whenever they are achieved!"
moreInfo="This is the recommended way to import scores, as it provides quality data in real-time."
key="Fervidex"
/>,
<ImportTypeInfoCard
key="file/eamusement-iidx-csv"
importType="file/eamusement-iidx-csv"
/>,
<ImportTypeInfoCard key="api/flo-iidx" importType="api/flo-iidx" />,
<ImportTypeInfoCard key="api/eag-iidx" importType="api/eag-iidx" />,
<ImportTypeInfoCard key="api/arc-iidx" importType="api/arc-iidx" />,
// <ImportTypeInfoCard key="file/mer-iidx" importType="file/mer-iidx" />,
<ImportTypeInfoCard key="file/solid-state-squad" importType="file/solid-state-squad" />,
<ImportTypeInfoCard key="file/pli-iidx-csv" importType="file/pli-iidx-csv" />
);
} else if (game === "sdvx") {
Content.unshift(
<ImportInfoCard
name="Barbatos"
href="barbatos"
desc="Automatically import scores, whenever you get them!"
moreInfo="This is the recommended way to import SDVX scores, as it provides high quality data in real-time. ONLY SUPPORTS UP TO VIVID WAVE CURRENTLY!"
key="Barbatos"
/>,
<ImportInfoCard
name="Konaste Hook"
href="ks-hook"
desc="Automatically import scores from SDVX 6 Konaste!"
moreInfo="Yep, it's that simple."
key="Konaste Hook"
/>,
<ImportTypeInfoCard
key="file/eamusement-sdvx-csv"
importType="file/eamusement-sdvx-csv"
/>,
<ImportTypeInfoCard key="api/flo-sdvx" importType="api/flo-sdvx" />,
<ImportTypeInfoCard key="api/eag-sdvx" importType="api/eag-sdvx" />,
<ImportTypeInfoCard key="api/min-sdvx" importType="api/min-sdvx" />,
<ImportTypeInfoCard key="api/arc-sdvx" importType="api/arc-sdvx" />
);
} else if (game === "chunithm") {
Content.unshift(
<ImportInfoCard
name="Chunitachi"
href="chunitachi"
desc="Automatically import scores, whenever you get them!"
moreInfo="This is the recommended way to import CHUNITHM scores, as it provides high quality data in real-time."
key="Chunitachi"
/>
);
} else if (game === "bms") {
Content.unshift(
<ImportInfoCard
name="LR2oraja IR"
href="beatoraja-ir"
desc="Automatically import scores, whenever you get them!"
moreInfo="This is the recommended way to import BMS scores, as it provides high quality data in real-time."
key="LR2oraja IR"
/>,
<ImportInfoCard
name="LR2 Hook"
href="lr2hook"
desc="Automatically import scores, whenever you get them (in LR2)!"
moreInfo="This is the recommended way to import LR2 BMS scores, as it provides high quality data in real-time."
key="LR2 IR"
/>,
<ImportInfoCard
name="Beatoraja Database Import"
href="beatoraja-db"
desc="Import scores from a beatoraja score database file."
moreInfo="This should be done once initially to sync scores up, but not all the time, as it provides worse quality data."
key="Beatoraja Database Import"
/>,
<ImportInfoCard
name="LR2 Database Import"
href="lr2-db"
desc="Import scores from a LR2 score database file."
moreInfo="This should be done once initially to sync scores up, but not all the time, as it provides worse quality data."
key="LR2 Database Import"
/>
);
} else if (game === "usc") {
Content.unshift(
<ImportInfoCard
name="USC IR"
href="usc-ir"
desc="Automatically import scores, whenever you get them!"
moreInfo="This is the recommended way to import USC scores, as it provides high quality data in real-time."
key="Beatoraja IR"
/>,
<ImportInfoCard
name="USC Database Import"
href="usc-db"
desc="Import scores from a USC score database file."
moreInfo="This should be done once initially to sync scores up, but not all the time, as it provides worse quality data."
key="USC Database Import"
/>
);
} else if (game === "popn") {
Content.unshift(
<ImportInfoCard
name="Silent Hook"
href="silent-hook"
desc={`Automatically upload Pop'n scores to ${TachiConfig.name}!`}
moreInfo="Yep, it's that simple."
key="Silent Hook"
/>
);
} else if (game === "pms") {
Content.unshift(
<ImportInfoCard
name="Beatoraja IR"
href="beatoraja-ir-pms"
desc="Automatically import scores, whenever you get them!"
moreInfo="This is the recommended way to import PMS scores, as it provides high quality data in real-time."
key="Beatoraja IR"
/>
);
} else if (game === "wacca") {
Content.unshift(
<ImportInfoCard
name="WACCA Site Importer"
href="wacca-site"
desc="Scrape your scores from the WACCA website."
key="WACCA Site Importer"
/>
);
}
return (
<>
<div className="text-center mb-4">
<h1>{gameConfig.name}</h1>
</div>
<div className="row justify-content-center">{Content}</div>
</>
);
}
function ImportTypeInfoCard({
importType,
}: {
importType: FileUploadImportTypes | APIImportTypes;
}) {
switch (importType) {
case "api/arc-sdvx":
return (
<ImportInfoCard
name="ARC Integration"
href="sdvx-arc"
desc="Pull your SDVX scores from the ARC Network."
moreInfo="Note: All networks are reduced to their first three letters for anonymity reasons. ARC has a serious problem where it only stores one score per chart. This results in broken timestamps, and technically false score imports. I highly recommend using Barbatos instead, and only importing this once to synchronise things up."
key="ARC Integration"
/>
);
case "api/arc-iidx":
return (
<ImportInfoCard
name="ARC Integration"
href="iidx-arc"
desc="Pull your IIDX scores from the ARC Network."
moreInfo="Note: All networks are reduced to their first three letters for anonymity reasons. ARC has a serious problem where it only stores one score per chart. This results in broken timestamps, and technically false score imports. I highly recommend using Fervidex instead, and only importing this once to synchronise things up."
key="ARC Integration"
/>
);
case "api/eag-iidx":
return (
<ImportInfoCard
name="EAG Integration"
href="iidx-eag"
desc="Pull your IIDX scores from the EAG Network."
moreInfo="Note: All networks are reduced to their first three letters for anonymity reasons."
key="EAG Integration"
/>
);
case "api/flo-iidx":
return (
<ImportInfoCard
name="FLO Integration"
href="iidx-flo"
desc="Pull your IIDX scores from the FLO Network."
moreInfo="Note: All networks are reduced to their first three letters for anonymity reasons."
key="FLO Integration"
/>
);
case "api/flo-sdvx":
return (
<ImportInfoCard
name="FLO Integration"
href="sdvx-flo"
desc="Pull your SDVX scores from the FLO Network."
moreInfo="Note: All networks are reduced to their first three letters for anonymity reasons."
key="FLO Integration"
/>
);
case "api/eag-sdvx":
return (
<ImportInfoCard
name="EAG Integration"
href="sdvx-eag"
desc="Pull your SDVX scores from the EAG Network."
moreInfo="Note: All networks are reduced to their first three letters for anonymity reasons."
key="EAG Integration"
/>
);
case "api/min-sdvx":
return (
<ImportInfoCard
name="MIN Integration"
href="sdvx-min"
desc="Pull your SDVX scores from the MIN Network."
moreInfo="Note: All networks are reduced to their first three letters for anonymity reasons."
key="MIN Integration"
/>
);
case "file/eamusement-iidx-csv":
return (
<ImportInfoCard
name="E-Amusement CSV"
href="iidx-eam-csv"
desc="Use the official E-Amusement CSV to import scores."
moreInfo="Note: This format has issues with timestamps, since it stores only one timestamp per song. Playing the same song on different difficulties in a session will result in broken timestamps on your account."
key="E-Amusement CSV"
/>
);
case "file/solid-state-squad":
return (
<ImportInfoCard
name="SOLID STATE SQUAD .xml"
href="sss-xml"
desc="Use a SOLID STATE SQUAD XML file to import scores. This service is rather old, and was originally for manually tracking CS scores. However, it still exports data, and we still support it!"
moreInfo="Also, these guys provide pretty good quality data, especially for the time."
key="SOLID STATE SQUAD .xml"
/>
);
case "file/eamusement-sdvx-csv":
return (
<ImportInfoCard
name="E-Amusement CSV"
href="sdvx-eam-csv"
desc="Use the official E-Amusement CSV to import scores."
moreInfo="Note: This format doesn't support timestamps, which means sessions cannot be generated from it."
key="SDVX E-Amusement CSV"
/>
);
case "file/batch-manual":
return (
<ImportInfoCard
name="Batch Manual"
href="batch-manual"
desc={`A JSON format ${TachiConfig.name} recognises and can import scores from. This is for programmers to create their own import scripts.`}
moreInfo={
<>
Check{" "}
<ExternalLink href="https://tachi.readthedocs.io/en/latest/tachi-server/batch-manual/overview/">
the documentation
</ExternalLink>
.
</>
}
key="Batch Manual"
/>
);
case "file/mer-iidx":
return (
<ImportInfoCard
name="MER .json"
href="iidx-mer"
desc="Use a MER-exported .json file to synchronise scores."
moreInfo="Note: Scores that do not match an existing song on the site will be orphaned, and anyone can see your orphaned scores. Don't be an idiot. I highly recommend using Fervidex instead."
key="MER .json"
/>
);
case "file/pli-iidx-csv":
return (
<ImportInfoCard
name="PLI .csv"
href="iidx-pli-csv"
desc="Use a PLI .csv file to import scores."
moreInfo="Note: This network is currently not being developed on. I highly recommend switching to anything else. I highly recommend using Fervidex instead, and just using this once to sync things up."
key="PLI .csv"
/>
);
}
}
function ImportInfoCard({
name,
href,
desc,
moreInfo,
}: {
name: string;
href: string;
desc: string;
moreInfo?: React.ReactChild;
}) {
return (
<div className="col-12 col-lg-6 mb-4">
<Card
header={name}
footer={<LinkButton to={`/dashboard/import/${href}`}>Use this!</LinkButton>}
>
<div style={{ fontSize: "1.5rem" }}>{desc}</div>
{moreInfo && (
<>
<Divider />
<div>{moreInfo}</div>
</>
)}
</Card>
</div>
);
}
@@ -0,0 +1,47 @@
import useSetSubheader from "components/layout/header/useSetSubheader";
import Divider from "components/util/Divider";
import ExternalLink from "components/util/ExternalLink";
import { TachiConfig } from "lib/config";
import React from "react";
export default function KsHookPage() {
useSetSubheader(["Import Scores", "Konaste Hook"]);
return (
<div>
<h1 className="text-center mb-4">What Is Konaste Hook?</h1>
<div>
Konaste Hook is a <code>.dll</code> file that hooks into SDVX and automatically
sends the scores to a server. {TachiConfig.name} is compatible with what Konaste
Hook sends, so you can use it to submit scores!
</div>
<Divider />
<h1 className="text-center my-4">Setup Instructions</h1>
<ol className="instructions-list">
<li>
Download the latest version of <code>kshook.dll</code> from{" "}
<ExternalLink href="https://djtrackers.com/kshook/latest.zip">
here
</ExternalLink>
.
</li>
<li>
Download your config file{" "}
<ExternalLink href="/client-file-flow/CXKsHook">here</ExternalLink>. <br />
<b>This file contains an API Key, which is meant to be kept secret!</b>
</li>
<li>Place all of these files inside your SDVX6 (EAC) folder.</li>
<li>
Use <b>kshook.exe</b> to launch the game. It will open the usual login page, but
when the game is launched the hook will be loaded.
<br />
It is recommended you replace your existing shortcuts with <b>
kshook.exe
</b>{" "}
instead.
</li>
<li>Your scores now automatically upload to {TachiConfig.name}!</li>
</ol>
</div>
);
}
@@ -0,0 +1,27 @@
import TISInfo from "components/imports/TISInfo";
import useSetSubheader from "components/layout/header/useSetSubheader";
import Divider from "components/util/Divider";
import Muted from "components/util/Muted";
import { TachiConfig } from "lib/config";
import React from "react";
export default function LR2DBPage() {
useSetSubheader(["Import Scores", "LR2 Database File"]);
return (
<>
<TISInfo name="LR2 Database" />
<Divider />
<Muted>
This is currently the only way to import scores from LR2, this sucks, because I'd
love for scores to automatically import! There is an open bounty on an LR2 automatic
score uploader.
</Muted>
<br />
<Muted>
Note: If you submit a score on a chart that {TachiConfig.name} doesn't recognise,
you'll need to wait until atleast 2 other players submit scores for that chart
before it'll show up. This is to combat accidental IR spam.
</Muted>
</>
);
}
@@ -0,0 +1,45 @@
import useSetSubheader from "components/layout/header/useSetSubheader";
import Divider from "components/util/Divider";
import ExternalLink from "components/util/ExternalLink";
import Muted from "components/util/Muted";
import { TachiConfig } from "lib/config";
import React from "react";
export default function LR2HookPage() {
useSetSubheader(["Import Scores", "LR2 Hook"]);
return (
<div>
<h2 className="text-center mb-4">LR2 Hook Setup Instructions</h2>
<ol className="instructions-list">
<li>
Download the latest version of the LR2Hook{" "}
<ExternalLink href="https://github.com/MatVeiQaaa/BokutachiHook/releases">
here
</ExternalLink>
.
</li>
<li>
Place all of those files in the same folder as <b>LR2Body.exe</b>.
</li>
<li>
Get your <code>BokutachiAuth.json</code> file by clicking{" "}
<ExternalLink href="/client-file-flow/CXLR2Hook">this link</ExternalLink>.
</li>
<li>
Place this file in the same folder as <b>LR2Body.exe</b>.
</li>
<li>
That's it! Launch the game with <code>BokutachiLauncher.exe</code> and start
playing, your scores will automatically submit to the server.
</li>
</ol>
<Divider />
<Muted>
Note: If you submit a score on a chart that {TachiConfig.name} doesn't recognise,
you'll need to wait until atleast 2 other players submit scores for that chart
before it'll show up. This is to combat accidental IR spam.
</Muted>
</div>
);
}
@@ -0,0 +1,31 @@
import ImportFileInfo from "components/imports/ImportFileInfo";
import useSetSubheader from "components/layout/header/useSetSubheader";
import React from "react";
export default function MerJSONPage() {
useSetSubheader(["Import Scores", "MER .json"]);
return (
<ImportFileInfo
acceptMime="application/json"
importType="file/mer-iidx"
name="MER .json"
parseFunction={ParseFunction}
/>
);
}
function ParseFunction(d: string) {
const json = JSON.parse(d);
if (!Array.isArray(json)) {
throw new Error(`Expected a JSON array. Is this valid MER?`);
}
return {
valid: true,
info: {
Scores: json.length,
},
};
}
@@ -0,0 +1,48 @@
import ImportFileInfo from "components/imports/ImportFileInfo";
import useSetSubheader from "components/layout/header/useSetSubheader";
import React from "react";
import { FileUploadImportTypes } from "tachi-common";
export default function SDVXEamCSVPage({
name,
importType,
}: {
name: string;
importType: FileUploadImportTypes;
}) {
useSetSubheader(["Import Scores", name]);
return (
<ImportFileInfo
acceptMime="text/csv"
importType={importType}
name={name}
parseFunction={ParseFunction}
/>
);
}
function ParseFunction(data: string) {
const lines: string[] = data.split("\n");
const headers = lines[0].split(",");
if (headers.length !== 11) {
throw new Error(
`Unexpected amount of headers in the file -- Expected 11. Is this an eamusement CSV?`
);
}
if (lines.length === 1) {
throw new Error(`This CSV has no scores? Only found headers.`);
}
const linesWithScores = lines.filter(e => e !== "");
return {
valid: true,
info: {
Scores: linesWithScores.length - 1,
},
};
}
@@ -0,0 +1,38 @@
import ImportFileInfo from "components/imports/ImportFileInfo";
import useSetSubheader from "components/layout/header/useSetSubheader";
import React from "react";
export default function SSSXMLPage() {
useSetSubheader(["Import Scores", "SSS .xml"]);
return (
<ImportFileInfo
acceptMime="text/xml"
importType="file/solid-state-squad"
name="SSS .xml"
parseFunction={(d: string) => {
const parser = new DOMParser();
const xmlDoc = parser.parseFromString(d, "text/xml");
const main = xmlDoc.querySelector("s3data");
if (!main) {
throw new Error("Couldn't find s3data, is this valid SSS XML?");
}
const body = main.querySelector("scoredata");
if (!body) {
throw new Error("Couldn't find scoredata, is this valid SSS XML?");
}
return {
valid: true,
info: {
Scores: body.childElementCount,
},
};
}}
/>
);
}
@@ -0,0 +1,47 @@
import useSetSubheader from "components/layout/header/useSetSubheader";
import Divider from "components/util/Divider";
import ExternalLink from "components/util/ExternalLink";
import { TachiConfig } from "lib/config";
import React from "react";
export default function SilentHookPage() {
useSetSubheader(["Import Scores", "Silent Hook"]);
return (
<div>
<h1 className="text-center mb-4">What Is Silent Hook?</h1>
<div>
Silent Hook is a <code>.dll</code> file that hooks into Pop'n and automatically
sends the scores to a server. {TachiConfig.name} is compatible with what the Silent
Hook sends, so you can use it to submit scores!
</div>
<Divider />
<h1 className="text-center my-4">Setup Instructions</h1>
<ol className="instructions-list">
<li>
Download <code>silent.dll</code> from{" "}
<ExternalLink href="https://cdn.discordapp.com/attachments/917508472625573888/938094517226192906/silent.dll">
Here
</ExternalLink>
.
</li>
<li>
Download <code>silent</code>'s dependencies from
<ExternalLink href="https://cdn.discordapp.com/attachments/683093664192266282/975761045568233513/aksjdfhg.7z">
Here.
</ExternalLink>
<br />
You must put these files into <code>contents/libssl-1_1.dll</code> and{" "}
<code>libcrypto-1_1.dll</code>.
</li>
<li>
Download your config file{" "}
<ExternalLink href="/client-file-flow/CXSilentHook">here</ExternalLink>. <br />
<b>This file contains an API Key, which is meant to be kept secret!</b>
</li>
<li>Place both of these files inside your pop'n folder.</li>
<li>Your scores now automatically upload to {TachiConfig.name}!</li>
</ol>
</div>
);
}
@@ -0,0 +1,28 @@
import TISInfo from "components/imports/TISInfo";
import useSetSubheader from "components/layout/header/useSetSubheader";
import Divider from "components/util/Divider";
import Muted from "components/util/Muted";
import { TachiConfig } from "lib/config";
import React from "react";
import { Link } from "react-router-dom";
export default function USCDBPage() {
useSetSubheader(["Import Scores", "USC Database File"]);
return (
<>
<TISInfo name="USC Database" />
<Divider />
<Muted>
This method is intended for syncing up with existing scores. For new scores, you
should set up the <Link to="/dashboard/import/usc-ir">USC IR</Link>, for automatic
score uploading.
</Muted>
<br />
<Muted>
Note: If you submit a score on a chart that {TachiConfig.name} doesn't recognise,
you'll need to wait until atleast 2 other players submit scores for that chart
before it'll show up. This is to combat accidental IR spam.
</Muted>
</>
);
}
@@ -0,0 +1,54 @@
import { ToServerURL } from "util/api";
import useSetSubheader from "components/layout/header/useSetSubheader";
import Divider from "components/util/Divider";
import ExternalLink from "components/util/ExternalLink";
import Muted from "components/util/Muted";
import { TachiConfig } from "lib/config";
import React from "react";
import { Alert } from "react-bootstrap";
export default function USCIRPage() {
useSetSubheader(["Import Scores", "USC IR"]);
return (
<div>
<h2 className="text-center mb-4">USC IR Setup Instructions</h2>
<ol className="instructions-list">
<li>Open USC.</li>
<li>Open Settings, and go to Online.</li>
<Divider />
<h3>
<b>If you play on an Arcade Size Controller</b>
</h3>
<li>
Fill out IR Base URL with <code>{ToServerURL("/ir/usc/Controller")}</code>.
</li>
<h3 className="mt-4">
<b>If you play on Keyboard or Pocket Voltex</b>
</h3>
<li>
Fill out IR Base URL with <code>{ToServerURL("/ir/usc/Keyboard")}</code>.
</li>
<Alert variant="danger" className="mt-4">
You <b>MUST</b> select the right IR! These leaderboards are separated because it
is unfair to compare the two input devices.
<br />
<b>DELIBERATELY SELECTING THE WRONG IR WILL RESULT IN A PERMANENT BAN!</b>
</Alert>
<Divider />
<li>
Grab an API Token from{" "}
<ExternalLink href="/client-file-flow/CXUSCIR">here</ExternalLink>, and place it
in the API Token field.
</li>
<li>That's it! Your scores will now automatically upload to the server.</li>
</ol>
<Divider />
<Muted>
Note: If you submit a score on a chart that {TachiConfig.name} doesn't recognise,
you'll need to wait until atleast 2 other players submit scores for that chart
before it'll show up. This is to combat accidental IR spam.
</Muted>
</div>
);
}
@@ -0,0 +1,26 @@
import useSetSubheader from "components/layout/header/useSetSubheader";
import Divider from "components/util/Divider";
import ExternalLink from "components/util/ExternalLink";
import { TachiConfig } from "lib/config";
import React from "react";
export default function WACCASiteImportPage() {
useSetSubheader(["Import Scores", "WACCA Site Importer"]);
return (
<div>
<h1 className="text-center mb-4">What Is The WACCA Site Importer?</h1>
<div>
The WACCA Site Importer is a script that will scrape your profile on the WACCA
website and imports it to {TachiConfig.name}.
</div>
<Divider />
<h1 className="text-center my-4">Setup Instructions</h1>
Instructions are available on{" "}
<ExternalLink href="https://github.com/cg505/kt-wacca-site-importer">
the GitHub repository
</ExternalLink>
.
</div>
);
}
@@ -0,0 +1,258 @@
import { Contributors } from "util/constants/contributors";
import { RFA } from "util/misc";
import useSetSubheader from "components/layout/header/useSetSubheader";
import Divider from "components/util/Divider";
import ExternalLink from "components/util/ExternalLink";
import { UserContext } from "context/UserContext";
import { TachiConfig } from "lib/config";
import React, { useContext, useState } from "react";
import { Link } from "react-router-dom";
export default function CreditsPage() {
useSetSubheader("Credits");
const [alt, setAlt] = useState(false);
const [whatTheHell, setWhatTheHell] = useState(0);
const { user } = useContext(UserContext);
function a(normal: JSX.Element | string, ...altStr: (JSX.Element | string)[]) {
if (!alt) {
return normal;
} else if (whatTheHell < 0) {
return normal;
}
return RFA(altStr);
}
return (
<div style={{ fontSize: "1.15rem" }}>
<p>
{a(
<>
{TachiConfig.name} has been the work of many people, and many more
contributors. None of this would be possible without these people.
</>,
"Funding for this program was made possible by by by by by-",
`${TachiConfig.name} actually just appeared here one day, and we're not sure how.`,
`Actually, we just ran npm install ${TachiConfig.name}.`,
"To be honest, we had a surplus of monkeys and typewriters."
)}
</p>
<Divider />
<p>
If you want to support {TachiConfig.name} development, see{" "}
<Link to="/dashboard/support">Support</Link>.
</p>
<Divider />
<div className="mt-4">
<h1>
De
<span
className="text-white credits-easter-egg"
onClick={() => {
if (alt) {
// Force a different RFA
// by incrementing a random
// variable
setWhatTheHell(whatTheHell + 1);
} else {
setAlt(true);
}
}}
>
v
</span>{" "}
Team
</h1>
<ul>
<li>
{a(
"Lead Dev",
"Rules Of Hooks Violator",
"PNPM Evangelist",
"ABBA Enthusiast",
"Nutcase",
"IIDX 'player'"
)}
: <strong>zkldi</strong>
</li>
<li>
{a(
"Infrastructure & Dev/Ops",
"Professional YAML Editor",
"Professional nginx.conf Editor",
"'What do you mean you rm -rf'd that folder?'",
"WintOr",
"Frog Lover"
)}
: <strong>winter</strong>
<br />
<small>Also the reason I even started learning how to program.</small>
</li>
<li>
{a(
"Graphic Design",
"Adobe Suite Purchaser",
"Squiggly Line Drawer",
"Straight Line Drawer",
"Ratboy Genius",
"Large Headded Individual",
"The Ace Of Craigs"
)}
: <strong>Craig</strong>
</li>
<li>
{a(
"Dev, Maths Consultation",
"Functional Programming Evangelist",
"Born On The Cob",
"Crunchy Nut Enjoyer",
"Has An Afro"
)}
: <strong>Percyqaz</strong>
</li>
<li>
{a(
"Tachi Discord Bot Work",
"oooOoooo you want to use redux oooo",
"Java Skirt Boy",
"Urbit Enjoyer"
)}
: <strong>pfych</strong>
</li>
</ul>
</div>
<div className="mt-6">
<h1>Significant Contributors</h1>
<p>
These people have contributed a significant sub-project to {TachiConfig.name}.
</p>
<ul>
<li>
{a("Fervidex", "IIDX Black Magic")}: <strong>aixxe</strong>
</li>
<li>
{a("Barbatos", "SDVX Black Magic")}: <strong>Arm1stice</strong>
</li>
<li>
{a(
"Database Management, Various features, generally just a hell of a lot of work",
"This guy put emojis in my codebase",
"Emacs Evangelist"
)}
: <strong>cg505</strong>
</li>
<li>
{a("KsHook", "Konaste Black Magic")}: <strong>Emma</strong>
</li>
<li>
{a("APIs, Databasing", "All Black Magic")}: <strong>Felix</strong>
</li>
<li>
{a("APIs, Massive Dev Support", "Have you tried Elixir?")}:{" "}
<strong>haste</strong>
</li>
<li>
{a("Chunitachi", "CHUNITHM Black Magic")}: <strong>tomatosoup</strong>
</li>
<li>
{a("Server", "Ethereum Mining Solutions")}: <strong>viddy</strong>
<br />
<small>Yes, the entire server, networking costs, everything.</small>
</li>
</ul>
</div>
<div className="mt-6">
<h1>Contributors</h1>
<p>These people have contributed to {TachiConfig.name}.</p>
<ul>
{Contributors.slice(0)
.sort((a, b) => a.localeCompare(b))
.map(e => (
<li key={e}>{e}</li>
))}
</ul>
</div>
<div className="mt-6">
<h1>External Contributors</h1>
<p>
These people (or teams) have created programs that have been massively
beneficial.
<br />
Note: I'm leaving out huge teams here (like MongoDB and TypeScript), and
focusing more on the smaller guys.
</p>
<ul>
<li>
<ExternalLink href="https://pnpm.io">PNPM</ExternalLink>
<br />
<small>
PNPM is a monumental improvement over NPM. I cannot recommend it highly
enough.
</small>
</li>
<li>
<ExternalLink href="https://mkdocs.org">MkDocs</ExternalLink>
<br />
<small>
MkDocs is a simple Markdown-based documentation generator. It is
lightweight, easy to use, and less hassle than things like Sphinx.
</small>
</li>
<li>
<ExternalLink href="https://squidfunk.github.io/mkdocs-material">
MkDocs Material
</ExternalLink>
<br />
<small>MkDocs Material is a beautiful theme for MkDocs.</small>
</li>
<li>
<ExternalLink href="https://node-tap.org">Node TAP</ExternalLink>
<br />
<small>Node TAP is a testing framework that is.not.a("mess")</small>
</li>
<li>
<ExternalLink href="https://automattic.github.io/monk/docs/GETTING_STARTED.html">
Monk
</ExternalLink>
<br />
<small>
Monk is a no-nonsense driver wrapper for MongoDB wrote and maintained by
one guy.
</small>
</li>
<li>
<ExternalLink href="https://nivo.rocks">Nivo</ExternalLink>
<br />
<small>Nivo is the best graphing library ever made. That's it.</small>
</li>
</ul>
</div>
<div className="mt-6">
<h1>And...</h1>
<p>
Everyone who was part of the beta.
{user?.badges.some(x => x === "beta" || x === "alpha") ? " (That's you!)" : ""}
</p>
<p>
Everyone in the <code>#dev</code> chat for the constant advice and comedy gold.
</p>
</div>
<span className="text-muted" style={{ fontSize: "0.2rem" }}>
Click the V in Dev Team.
</span>
</div>
);
}
@@ -0,0 +1,22 @@
import useSetSubheader from "components/layout/header/useSetSubheader";
import ExternalLink from "components/util/ExternalLink";
import { TachiConfig } from "lib/config";
import React from "react";
import { Link } from "react-router-dom";
export default function SupportMePage() {
useSetSubheader("Support / Patreon");
return (
<div style={{ fontSize: "1.15rem" }}>
<p>
{TachiConfig.name} is a passion project, and developed by{" "}
<Link to="/dashboard/credits">people like you</Link>.
</p>
<p>
If you want to support {TachiConfig.name} development, you can donate to my{" "}
<ExternalLink href="https://ko-fi.com/zkldi">Ko-Fi</ExternalLink>.
</p>
</div>
);
}
@@ -0,0 +1,133 @@
import { APIFetchV1 } from "util/api";
import ApiError from "components/util/ApiError";
import Divider from "components/util/Divider";
import Loading from "components/util/Loading";
import Muted from "components/util/Muted";
import useApiQuery from "components/util/query/useApiQuery";
import React, { useEffect, useState } from "react";
import { Button, Col, Form } from "react-bootstrap";
import { Link } from "react-router-dom";
import { ARCSavedProfileDocument, PublicUserDocument } from "tachi-common";
import { SetState } from "types/react";
export default function ARCIntegrationPage({ reqUser }: { reqUser: PublicUserDocument }) {
const [iidxID, setIIDXID] = useState("");
const [sdvxID, setSDVXID] = useState("");
const { data, isLoading, error } = useApiQuery<{
iidx: ARCSavedProfileDocument | null;
sdvx: ARCSavedProfileDocument | null;
}>(`/users/${reqUser.id}/integrations/arc`, undefined, true);
useEffect(() => {
if (data) {
setIIDXID(data.iidx?.accountID ?? "");
setSDVXID(data.sdvx?.accountID ?? "");
}
}, [data]);
if (error) {
return <ApiError error={error} />;
}
if (!data || isLoading) {
return <Loading />;
}
return (
<>
<Col xs={12}>
<h4>ARC Integration</h4>
<span>To import scores from ARC, just put your AccountIDs in the tabs below.</span>
<br />
<Muted>
Note: Only the latest versions of IIDX and SDVX are supported for imports.
</Muted>
<Divider />
<h5>How to get your AccountID</h5>
<ol className="text-left">
<li>Open the ARC website.</li>
<li>Go to the game you care about, and select My Bests.</li>
<li>
In the URL, after <code>/profiles</code>, there'll be a string that looks
like <code>H4JxPV3vQH_</code>. Copy that into the text fields below!
</li>
<li>To unset an integration, remove the account ID and hit save.</li>
</ol>
</Col>
<Col xs={12} className="mt-4">
<IntegrationStatus name="IIDX" value={iidxID} setValue={setIIDXID} />
<span className="text-warning">
This profile <b>MUST</b> be for BISTROVER! You need to have atleast one play on
BISTROVER for your scores to carry over.
</span>
<br />
{iidxID && iidxID.length !== 11 && (
<span className="text-danger">
Invalid IIDX ID, it should be 11 characters long.
</span>
)}
<IntegrationStatus name="SDVX" value={sdvxID} setValue={setSDVXID} />
<span className="text-warning">
This profile <b>MUST</b> be for VIVID WAVE! You need to have atleast one play on
VIVID WAVE for your scores to carry over.
</span>
<br />
{sdvxID && sdvxID.length !== 11 && (
<span className="text-danger">
Invalid SDVX ID, it should be 11 characters long.
</span>
)}
<Divider />
<Button
disabled={
!!(iidxID && iidxID.length !== 11) || !!(sdvxID && sdvxID.length !== 11)
}
onClick={() => {
APIFetchV1(
`/users/${reqUser.id}/integrations/arc`,
{
method: "PATCH",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
iidx: iidxID || null,
sdvx: sdvxID || null,
}),
},
true,
true
);
}}
>
Submit Settings
</Button>
<Divider />
After setting this, go to <Link to="/dashboard/import">Import Scores</Link>!
</Col>
</>
);
}
function IntegrationStatus({
name,
value,
setValue,
}: {
name: string;
value: string;
setValue: SetState<string>;
}) {
return (
<Form.Group className="mt-4">
<Form.Label>{`${name} Account ID`}</Form.Label>
<Form.Control
type="text"
value={value}
placeholder="H4JxPV3vQH_"
onChange={e => setValue(e.target.value)}
/>
</Form.Group>
);
}
@@ -0,0 +1,183 @@
import { APIFetchV1 } from "util/api";
import ApiError from "components/util/ApiError";
import Divider from "components/util/Divider";
import Loading from "components/util/Loading";
import useApiQuery from "components/util/query/useApiQuery";
import React, { useState } from "react";
import { Button, Col, Form } from "react-bootstrap";
import { Link } from "react-router-dom";
import { FervidexSettingsDocument, PublicUserDocument } from "tachi-common";
export default function FervidexIntegrationPage({ reqUser }: { reqUser: PublicUserDocument }) {
const { data: settings, isLoading, error } = useApiQuery<FervidexSettingsDocument | null>(
`/users/${reqUser.id}/integrations/fervidex/settings`
);
if (error) {
return <ApiError error={error} />;
}
if (isLoading || settings === undefined) {
return <Loading />;
}
return (
<>
<Col xs={12}>
<h4>Fervidex Integration</h4>
<span>
Fervidex is a Score-Importing Hook for IIDX. Configuring it is as simple as
dropping a <code>.dll</code> and config file into your game folder.
</span>
</Col>
<Col xs={12} className="mt-4">
Instructions on how to setup fervidex can be found{" "}
<Link to="/dashboard/import/fervidex">here</Link>.
<Divider />
</Col>
<Col xs={12} className="mt-4">
<h4 className="mb-4">Advanced Settings</h4>
<FervidexForm {...{ reqUser, settings }} />
</Col>
</>
);
}
function FervidexForm({
reqUser,
settings,
}: {
reqUser: PublicUserDocument;
settings: FervidexSettingsDocument | null;
}) {
const [formSettings, setFormSettings] = useState<Omit<FervidexSettingsDocument, "userID">>(
settings
? { forceStaticImport: settings.forceStaticImport, cards: settings.cards }
: {
forceStaticImport: false,
cards: null,
}
);
return (
<form
className="text-left"
onSubmit={async e => {
e.preventDefault();
if (formSettings.cards?.length === 0) {
setFormSettings({ ...formSettings, cards: null });
}
await APIFetchV1(
`/users/${reqUser.id}/integrations/fervidex/settings`,
{
method: "PATCH",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(formSettings),
},
true,
true
);
}}
>
<Form.Group>
<Form.Check
type="checkbox"
checked={formSettings.forceStaticImport}
onChange={e => {
setFormSettings({ ...formSettings, forceStaticImport: e.target.checked });
}}
label="Force Static Imports"
/>
<Form.Text>
Import scores on game load for non-INFINITAS IIDX versions.
<br />
<span className="text-warning">
Warning: You should always import from your network first. Statically
imported scores have the bare minimum data (Lamp + EX Score + BP), but due
to score de-duplication rules, future network imports cannot append more
data (Timestamps, Graphs, etc.) to these scores.
</span>
</Form.Text>
</Form.Group>
{formSettings.cards === null ? (
<Button
variant="info"
onClick={() => setFormSettings({ ...formSettings, cards: [] })}
>
Enable Card Filters
</Button>
) : (
<>
<Button
variant="danger"
onClick={() => setFormSettings({ ...formSettings, cards: null })}
>
Disable Card Filters
</Button>
<Form.Group>
<Form.Label>
Your Card ID. You can specify multiple by separating them with spaces.
<br />
<span className="text-warning">
Warning: CardIDs are the ones that <b>DON'T</b> start with E004.
</span>
</Form.Label>
<Form.Control
value={formSettings.cards.join(" ")}
onChange={e => {
setFormSettings({
...formSettings,
cards: e.target.value.split(" ").map(e => e.toUpperCase()),
});
}}
/>
{formSettings.cards.some(c => !isCardIDValid(c)) && (
<div className="text-danger">
CardIDs should be 16 characters, or a <code>C</code> followed by 12
characters!
</div>
)}
{formSettings.cards.some(x => x.startsWith("E004")) && (
<div className="text-danger">
CardIDs are <b>NOT</b> the ones that start with E004!
</div>
)}
</Form.Group>
</>
)}
<Form.Text>
This will restrict score uploads from your API key to only those from this set of
cards. You should only use this if multiple people will play on your setup.
<br />
<span className="text-warning">
Warning: If you play on INFINITAS and other versions, you will need to set both
cardIDs here!
</span>
</Form.Text>
<div className="d-flex justify-content-center">
<Button variant="primary" className="mt-4" type="submit">
Submit Settings
</Button>
</div>
</form>
);
}
function isCardIDValid(cardID: string) {
if (cardID.startsWith("E004")) {
return false;
}
if (cardID[0] === "C") {
return cardID.length === 13;
}
return cardID.length === 16;
}
@@ -0,0 +1,107 @@
import { APIFetchV1 } from "util/api";
import { GetSortedGPTs } from "util/site";
import useSetSubheader from "components/layout/header/useSetSubheader";
import Card from "components/layout/page/Card";
import RankingData from "components/user/UGPTRankingData";
import UGPTRatingsTable from "components/user/UGPTStatsOverview";
import AsyncLoader from "components/util/AsyncLoader";
import LinkButton from "components/util/LinkButton";
import Muted from "components/util/Muted";
import ReferToUser from "components/util/ReferToUser";
import React from "react";
import { FormatGame, PublicUserDocument, UserGameStats } from "tachi-common";
import { UGSWithRankingData } from "types/api-returns";
interface Props {
reqUser: PublicUserDocument;
}
export default function UserGamesPage({ reqUser }: Props) {
useSetSubheader(
["Users", reqUser.username, "Games"],
[reqUser],
`${reqUser.username}'s Game Profiles`
);
return (
<div className="row">
<AsyncLoader
promiseFn={async () => {
const res = await APIFetchV1<UGSWithRankingData[]>(
`/users/${reqUser.id}/game-stats`
);
if (!res.success) {
throw new Error(res.description);
}
return res.body;
}}
>
{ugs =>
ugs.length ? (
<GamesInfo ugs={ugs} reqUser={reqUser} />
) : (
<div className="col-12 text-center">
<Muted>
<ReferToUser reqUser={reqUser} /> not played anything.
</Muted>
</div>
)
}
</AsyncLoader>
</div>
);
}
function GamesInfo({ ugs, reqUser }: { ugs: UserGameStats[]; reqUser: PublicUserDocument }) {
const gpts = GetSortedGPTs();
const ugsMap = new Map();
for (const u of ugs) {
ugsMap.set(`${u.game}:${u.playtype}`, u);
}
return (
<>
{gpts.map(({ game, playtype }, i) => {
const e = ugsMap.get(`${game}:${playtype}`);
if (!e) {
return <></>;
}
return <GameStatContainer key={`${game}:${playtype}`} ugs={e} reqUser={reqUser} />;
})}
</>
);
}
export function GameStatContainer({ ugs, reqUser }: { ugs: UGSWithRankingData } & Props) {
return (
<div className="col-12 col-lg-6">
<Card
className="mb-4"
footer={
<div className="d-flex justify-content-end">
<LinkButton
to={`/dashboard/users/${reqUser.username}/games/${ugs.game}/${ugs.playtype}`}
>
View Game Profile
</LinkButton>
</div>
}
header={FormatGame(ugs.game, ugs.playtype)}
>
<UGPTRatingsTable ugs={ugs} />
<RankingData
game={ugs.game}
playtype={ugs.playtype}
rankingData={ugs.__rankingData}
userID={ugs.userID}
/>
</Card>
</div>
);
}
@@ -0,0 +1,917 @@
import { APIFetchV1 } from "util/api";
import { allPermissions, DelayedPageReload } from "util/misc";
import useSetSubheader from "components/layout/header/useSetSubheader";
import Card from "components/layout/page/Card";
import ApiError from "components/util/ApiError";
import Divider from "components/util/Divider";
import ExternalLink from "components/util/ExternalLink";
import FormInput from "components/util/FormInput";
import Icon from "components/util/Icon";
import Loading from "components/util/Loading";
import Muted from "components/util/Muted";
import useApiQuery from "components/util/query/useApiQuery";
import SelectButton from "components/util/SelectButton";
import { mode, TachiConfig } from "lib/config";
import React, { useEffect, useState } from "react";
import { Alert, Button, Col, Form, Modal, Row } from "react-bootstrap";
import { Link } from "react-router-dom";
import {
APIPermissions,
APITokenDocument,
integer,
PublicUserDocument,
TachiAPIClientDocument,
} from "tachi-common";
import { SetState } from "types/react";
import ARCIntegrationPage from "./ARCIntegrationPage";
import FervidexIntegrationPage from "./FervidexIntegrationPage";
export default function UserIntegrationsPage({ reqUser }: { reqUser: PublicUserDocument }) {
const [page, setPage] = useState<"services" | "api-keys" | "oauth-clients">("api-keys");
useSetSubheader(
["Users", reqUser.username, "Integrations"],
[reqUser],
`${reqUser.username}'s Integrations`
);
return (
<Card header="Integrations" className="col-12 offset-lg-2 col-lg-8">
<Row>
<Col xs={12}>
<div className="btn-group d-flex justify-content-center">
{mode !== "btchi" && (
<SelectButton value={page} setValue={setPage} id="services">
<Icon type="network-wired" />
Service Configuration
</SelectButton>
)}
<SelectButton value={page} setValue={setPage} id="api-keys">
<Icon type="key" />
API Keys
</SelectButton>
<SelectButton value={page} setValue={setPage} id="oauth-clients">
<Icon type="robot" />
My API Clients
</SelectButton>
</div>
<Divider />
</Col>
<Col xs={12}>
{page === "services" ? (
<ServicesPage reqUser={reqUser} />
) : page === "api-keys" ? (
<APIKeysPage reqUser={reqUser} />
) : (
<OAuthClientPage />
)}
</Col>
</Row>
</Card>
);
}
function OAuthClientPage() {
return (
<Row className="text-center justify-content-center">
<Col xs={12}>
<h3>API Clients</h3>
<Alert variant="info" style={{ color: "black" }}>
This page is for programmers who want to make their own things that interface
with {TachiConfig.name}.
<br />
You can read the documentation{" "}
<ExternalLink
style={{ color: "white" }}
href="https://tachi.readthedocs.io/en/latest/tachi-server/infrastructure/api-clients/"
>
here
</ExternalLink>
!
</Alert>
<Muted>Register your own clients for integrating with {TachiConfig.name}.</Muted>
</Col>
<Col xs={12}>
<OAuthClientInfo />
</Col>
</Row>
);
}
function OAuthClientInfo() {
const { data, isLoading, error } = useApiQuery<TachiAPIClientDocument[]>("/clients");
const [clients, setClients] = useState<TachiAPIClientDocument[]>([]);
useEffect(() => {
setClients(data ?? []);
}, [data]);
if (error) {
return <ApiError error={error} />;
}
if (isLoading || !data) {
return <Loading />;
}
if (clients.length === 0) {
return (
<>
<Muted>You don't have any API Clients.</Muted>
<Divider />
<CreateNewOAuthClient setClients={setClients} />
</>
);
}
return (
<>
{clients.map(e => (
<OAuthClientRow
setClients={setClients}
clients={clients}
key={e.clientID}
client={e}
/>
))}
<Divider />
<CreateNewOAuthClient setClients={setClients} />
</>
);
}
function CreateNewOAuthClient({ setClients }: { setClients: SetState<TachiAPIClientDocument[]> }) {
const [show, setShow] = useState(false);
const [name, setName] = useState("");
const [redirectUri, setRedirectUri] = useState("");
const [webhookUri, setWebhookUri] = useState("");
const [apiKeyFilename, setApiKeyFilename] = useState("");
const [apiKeyTemplate, setApiKeyTemplate] = useState("");
const [permissions, setPermissions] = useState<APIPermissions[]>([]);
return (
<>
<Button onClick={() => setShow(true)} variant="success">
Create New Client
</Button>
<Modal show={show} onHide={() => setShow(false)}>
<Modal.Header closeButton>
<Modal.Title>Create New Client</Modal.Title>
</Modal.Header>
<Modal.Body>
<Form
onSubmit={async e => {
e.preventDefault();
const res = await APIFetchV1(
"/clients/create",
{
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
name,
redirectUri: redirectUri || null,
permissions,
apiKeyTemplate: apiKeyTemplate || null,
apiKeyFilename: apiKeyFilename || null,
webhookUri: webhookUri || null,
}),
},
true,
true
);
if (res.success) {
DelayedPageReload();
}
}}
>
<div className="input-group">
<div className="input-group-append">
<span className="input-group-text">Name</span>
</div>
<input
value={name}
className="form-control"
onChange={e => setName(e.target.value)}
placeholder="My API Client"
/>
</div>
<Muted>
Give your Service a name. This will be shown when users use follow OAuth
flow.
</Muted>
<Divider />
<div className="input-group">
<div className="input-group-append">
<span className="input-group-text">Redirect URI</span>
</div>
<input
value={redirectUri}
className="form-control"
onChange={e => setRedirectUri(e.target.value)}
placeholder="https://example.com/callback"
/>
</div>
<Muted>
This is the URL {TachiConfig.name} will redirect to as part of the OAuth
flow.
</Muted>
<Divider />
<FormInput
fieldName="Webhook Uri"
value={webhookUri}
setValue={setWebhookUri}
placeholder="https://example.com/webhook"
/>
<Muted>
This is the URL {TachiConfig.name} will send webhook info to. Leave this
blank to not recieve webhook events.
</Muted>
<Divider />
<FormInput
as="textarea"
fieldName="File Template"
value={apiKeyTemplate}
setValue={setApiKeyTemplate}
placeholder={JSON.stringify({ token: "%%TACHI_KEY%%" }, null, "\t")}
/>
<Muted>
In what format should a generated API Key be shown to the user? This
only applies to Client File Flow. <code>%%TACHI_KEY%%</code> will be
replaced with the generated key. Read more about client file flow{" "}
<ExternalLink href="https://tachi.readthedocs.io/en/latest/tachi-server/infrastructure/file-flow/">
here
</ExternalLink>
.
<br />
Leave this empty to spit the key out directly.
</Muted>
{apiKeyTemplate !== "" && !apiKeyTemplate.includes("%%TACHI_KEY%%") && (
<>
<br />
<span className="text-danger">
No %%TACHI_KEY%% detected in file template. Please add one!
</span>
</>
)}
<Divider />
<FormInput
fieldName="File Template"
value={apiKeyFilename}
setValue={setApiKeyFilename}
placeholder="my-service-config.json"
/>
<Muted>
If this is not empty, Client File Flow will result in a file of this
name being downloaded (in the above format).
</Muted>
<Divider />
<h4>Permissions</h4>
<div className="px-4">
{allPermissions.map(permission => (
<>
<input
key={permission}
className="form-check-input"
type="checkbox"
onChange={e => {
if (e.target.checked) {
setPermissions([...permissions, permission]);
} else {
setPermissions(
permissions.filter(e => e !== permission)
);
}
}}
/>
<label className="form-check-label">{permission}</label>
<br />
</>
))}
</div>
<Divider />
<button type="submit" className="btn btn-success">
Create Client
</button>
</Form>
</Modal.Body>
</Modal>
</>
);
}
interface OAuthClientProps {
client: TachiAPIClientDocument;
clients: TachiAPIClientDocument[];
setClients: SetState<TachiAPIClientDocument[]>;
}
function OAuthClientRow({ client, clients, setClients }: OAuthClientProps) {
const [hasWarned, setHasWarned] = useState(false);
const [showDangerousStuff, setShowDangerousStuff] = useState(false);
const [deleteModalShow, setDeleteModalShow] = useState(false);
const [editModalShow, setEditModalShow] = useState(false);
return (
<div key={client.clientID} className="col-12">
<Divider />
<h2 className="mb-4">{client.name}</h2>
<div className="text-left">
<h5>
Client ID: <code>{client.clientID}</code>
</h5>
<h5>
Client Secret:{" "}
<code onClick={() => setHasWarned(true)}>
{hasWarned ? client.clientSecret : "SENSITIVE: CLICK TO REVEAL"}
</code>
</h5>
<h5>
Permissions: <code>{client.requestedPermissions.join(", ")}</code>
</h5>
<h5>
Redirect Uri: <code>{client.redirectUri ?? "No Redirect URI"}</code>
</h5>
<h5>
Webhook Uri: <code>{client.webhookUri ?? "No Webhook URI"}</code>
</h5>
<h5>
Download Filename: <code>{client.apiKeyFilename ?? "No Filename"}</code>
</h5>
<h5>
File Format:{" "}
<textarea
readOnly
className="w-100 mt-2 text-monospace"
value={client.apiKeyTemplate ?? "%%TACHI_KEY%%"}
/>
</h5>
<h6>
Client File Flow Link: <br />
<code>
{window.location.origin}/client-file-flow/{client.clientID}
</code>
</h6>
<h6>
OAuth Flow Link: <br />
<code>
{window.location.origin}/oauth/request-auth?clientID={client.clientID}
</code>
</h6>
</div>
<Divider />
<div className="d-flex" style={{ justifyContent: "space-around" }}>
<Button variant="info" onClick={() => setEditModalShow(!editModalShow)}>
Edit Client
</Button>
<Button
onClick={() => {
setShowDangerousStuff(!showDangerousStuff);
}}
variant="danger"
>
{showDangerousStuff ? "Hide Dangerous Stuff" : "Show Dangerous Stuff"}
</Button>
</div>
{showDangerousStuff && (
<div className="mt-8">
<Button
onClick={async () => {
const res = await APIFetchV1<TachiAPIClientDocument>(
`/clients/${client.clientID}/reset-secret`,
{
method: "POST",
},
true,
true
);
if (res.success) {
setClients(
clients.map(e => {
if (e.clientID === client.clientID) {
return res.body;
}
return e;
})
);
}
}}
variant="warning"
>
Reset Client Secret
</Button>
<br />
<Muted>
You can reset your client secret incase you accidentally exposed it.
</Muted>
<br />
<Button
className="mt-4"
onClick={() => setDeleteModalShow(true)}
variant="danger"
>
Destroy Client
</Button>
<br />
<Muted>
This will destroy your client and all API Keys associated with it.
</Muted>
</div>
)}
<EditClientModal
{...{ client, setClients, clients, show: editModalShow, setShow: setEditModalShow }}
/>
<Modal show={deleteModalShow} onHide={() => setDeleteModalShow(false)}>
<Modal.Header closeButton>
<Modal.Title>Seriously, are you really sure?</Modal.Title>
</Modal.Header>
<Modal.Body>
All keys ever created for this API Client will be deleted.
<br />
<strong className="text-danger">
ALL USERS USING THIS APPLICATION WILL NO LONGER BE ABLE TO USE THE
ASSOCIATED KEYS!
</strong>
<Divider />
<div className="w-100 d-flex justify-content-center">
<Button
variant="danger"
onClick={async () => {
const res = await APIFetchV1(
`/clients/${client.clientID}`,
{
method: "DELETE",
},
true,
true
);
if (res.success) {
setClients(clients.filter(e => e.clientID !== client.clientID));
}
}}
>
I'm sure.
</Button>
</div>
</Modal.Body>
</Modal>
</div>
);
}
function EditClientModal({
client,
clients,
setClients,
show,
setShow,
}: OAuthClientProps & {
show: boolean;
setShow: SetState<boolean>;
}) {
const [name, setName] = useState(client.name);
const [redirectUri, setRedirectUri] = useState(client.redirectUri ?? "");
const [webhookUri, setWebhookUri] = useState(client.webhookUri ?? "");
const [apiKeyFilename, setApiKeyFilename] = useState(client.apiKeyFilename ?? "");
const [apiKeyTemplate, setApiKeyTemplate] = useState(client.apiKeyTemplate ?? "");
return (
<Modal show={show} onHide={() => setShow(false)}>
<Modal.Header closeButton>
<Modal.Title>Edit {client.name}</Modal.Title>
</Modal.Header>
<Modal.Body>
<Form
onSubmit={async e => {
e.preventDefault();
const res = await APIFetchV1<TachiAPIClientDocument>(
`/clients/${client.clientID}`,
{
method: "PATCH",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
name,
redirectUri,
webhookUri: webhookUri === "" ? null : webhookUri,
apiKeyFilename: apiKeyFilename === "" ? null : apiKeyFilename,
apiKeyTemplate: apiKeyTemplate === "" ? null : apiKeyTemplate,
}),
},
true,
true
);
if (res.success) {
setClients(
clients.map(e => {
if (e.clientID === client.clientID) {
return res.body;
}
return e;
})
);
}
}}
>
<FormInput
fieldName="Client Name"
value={name}
setValue={setName}
placeholder="My API Client"
/>
<Divider />
<FormInput
fieldName="Redirect URI"
value={redirectUri}
setValue={setRedirectUri}
placeholder="https://example.com/callback"
/>
<Muted>
Where a user will be redirected to after completing the OAuth flow.
</Muted>
<Divider />
<FormInput
fieldName="Webhook URI"
value={webhookUri}
setValue={setWebhookUri}
placeholder="https://example.com/webhook"
/>
<Muted>
Where to send webhook events to. Please read the{" "}
<ExternalLink href="https://tachi.readthedocs.io/en/latest/api/webhooks/main/">
Webhook Documentation
</ExternalLink>{" "}
before using this, as there are necessary security precautions.
</Muted>
<Divider />
<FormInput
as="textarea"
fieldName="File Template"
value={apiKeyTemplate}
setValue={setApiKeyTemplate}
placeholder={JSON.stringify({ token: "%%TACHI_KEY%%" }, null, "\t")}
/>
<Muted>
In what format should a generated API Key be shown to the user? This only
applies to Client File Flow. <code>%%TACHI_KEY%%</code> will be replaced
with the generated key. Read more about client file flow{" "}
<ExternalLink href="https://tachi.readthedocs.io/en/latest/tachi-server/infrastructure/file-flow/">
here
</ExternalLink>
.
<br />
Leave this empty to spit the key out directly.
</Muted>
{apiKeyTemplate !== "" && !apiKeyTemplate.includes("%%TACHI_KEY%%") && (
<>
<br />
<span className="text-danger">
No %%TACHI_KEY%% detected in file template. Please add one!
</span>
</>
)}
<Divider />
<FormInput
fieldName="File Template"
value={apiKeyFilename}
setValue={setApiKeyFilename}
placeholder="my-service-config.json"
/>
<Muted>
If this is not empty, Client File Flow will result in a file of this name
being downloaded (in the above format).
</Muted>
<Divider />
<button type="submit" className="btn btn-success">
Update Client
</button>
</Form>
</Modal.Body>
</Modal>
);
}
function ServicesPage({ reqUser }: { reqUser: PublicUserDocument }) {
if (mode === "btchi") {
return (
<Row className="text-center">
Looks like there's no services available for integration.
</Row>
);
}
const [page, setPage] = useState<"fervidex" | "arc" | "flo" | "eag" | "min">("fervidex");
return (
<Row className="text-center justify-content-center">
<Col xs={12}>
<h3>Service Configuration</h3>
<span>
This is for <b>Configuring Integrations!</b>
</span>
<br />
<Muted>
Note: Some services have had their names truncated to their first three
characters for privacy reasons.
</Muted>
<Divider />
</Col>
<Col xs={12}>
<div className="btn-group">
<SelectButton value={page} setValue={setPage} id="fervidex">
Fervidex
</SelectButton>
<SelectButton value={page} setValue={setPage} id="arc">
ARC
</SelectButton>
<SelectButton value={page} setValue={setPage} id="flo">
FLO
</SelectButton>
<SelectButton value={page} setValue={setPage} id="eag">
EAG
</SelectButton>
<SelectButton value={page} setValue={setPage} id="min">
MIN
</SelectButton>
</div>
<Divider />
</Col>
{page === "fervidex" ? (
<FervidexIntegrationPage reqUser={reqUser} />
) : page === "arc" ? (
<ARCIntegrationPage reqUser={reqUser} />
) : page === "flo" ? (
<KAIIntegrationStatus userID={reqUser.id} kaiType="flo" />
) : page === "eag" ? (
<KAIIntegrationStatus userID={reqUser.id} kaiType="eag" />
) : (
<KAIIntegrationStatus userID={reqUser.id} kaiType="min" />
)}
</Row>
);
}
function KAIIntegrationStatus({
kaiType,
userID,
}: {
kaiType: "flo" | "eag" | "min";
userID: integer;
}) {
const { data, isLoading, error } = useApiQuery<{ authStatus: boolean }>(
`/users/${userID}/integrations/kai/${kaiType}`
);
if (error) {
return <ApiError error={error} />;
}
if (isLoading || !data) {
return <Loading />;
}
return (
<div className="row">
<div className="col-12">
<h3>
{data.authStatus
? `You are authenticated with ${kaiType.toUpperCase()}!`
: `You are not authenticated with ${kaiType.toUpperCase()}`}
</h3>
{data.authStatus ? (
<h4>There's no configuration to do here. You're all good!</h4>
) : (
<h4>
You should authenticate yourself by going to{" "}
<Link to="/dashboard/import">Import Scores</Link> for the thing you want to
import for!
</h4>
)}
</div>
</div>
);
}
function APIKeysPage({ reqUser }: { reqUser: PublicUserDocument }) {
const [apiKeys, setApiKeys] = useState<APITokenDocument[]>([]);
const [showModal, setShowModal] = useState(false);
const { data, isLoading, error } = useApiQuery<APITokenDocument[]>(
`/users/${reqUser.id}/api-tokens`
);
useEffect(() => {
if (data) {
setApiKeys(data);
}
}, [data]);
if (error) {
return <ApiError error={error} />;
}
if (isLoading || !data) {
return <Loading />;
}
return (
<>
<Alert variant="danger" style={{ color: "black" }}>
API Keys allow other programs to interact with {TachiConfig.name} on your behalf.
They have limited permissions, so they can't just change your password!
<br />
<br />
In contrast to Integrations, API Keys let other programs interact with{" "}
{TachiConfig.name}, rather than the other way around.
<br />
<br />
Still, the stuff on this page is sensitive information! Be careful who you give
these keys to.
</Alert>
<div className="row">
{apiKeys.length === 0 ? (
<div className="text-center">You have no API Keys.</div>
) : (
apiKeys.map(e => (
<APIKeyRow
apiKeys={apiKeys}
setApiKeys={setApiKeys}
key={e.token}
apiKey={e}
/>
))
)}
</div>
<Divider />
<div className="row justify-content-center">
<button className="btn btn-primary" onClick={() => setShowModal(true)}>
Create new API Key
</button>
</div>
<CreateAPIKeyModal {...{ showModal, setShowModal, reqUser, setApiKeys, apiKeys }} />
</>
);
}
function CreateAPIKeyModal({
showModal,
setShowModal,
reqUser,
apiKeys,
setApiKeys,
}: {
showModal: boolean;
setShowModal: SetState<boolean>;
reqUser: PublicUserDocument;
apiKeys: APITokenDocument[];
setApiKeys: SetState<APITokenDocument[]>;
}) {
const [identifier, setIdentifier] = useState("My API Key");
const [permissions, setPermissions] = useState<APIPermissions[]>([]);
return (
<Modal show={showModal} onHide={() => setShowModal(false)}>
<Modal.Header>Create API Key</Modal.Header>
<Modal.Body>
<form
onSubmit={async e => {
e.preventDefault();
const res = await APIFetchV1<APITokenDocument>(
`/users/${reqUser.id}/api-tokens/create`,
{
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
permissions,
identifier,
}),
},
true,
true
);
if (res.success) {
setApiKeys([...apiKeys, res.body]);
setShowModal(false);
}
}}
>
<div className="input-group">
<div className="input-group-append">
<span className="input-group-text">Identifier</span>
</div>
<input
value={identifier}
className="form-control"
onChange={e => setIdentifier(e.target.value)}
/>
</div>
<Muted>Give your API Key a name, so you dont forget what it's for!</Muted>
<Divider />
<h4>Permissions</h4>
<div className="px-4">
{allPermissions.map(permission => (
<>
<input
key={permission}
className="form-check-input"
type="checkbox"
onChange={e => {
if (e.target.checked) {
setPermissions([...permissions, permission]);
} else {
setPermissions(
permissions.filter(e => e !== permission)
);
}
}}
/>
<label className="form-check-label">{permission}</label>
<br />
</>
))}
</div>
<Divider />
<button type="submit" className="btn btn-success">
Create Key
</button>
</form>
</Modal.Body>
</Modal>
);
}
function APIKeyRow({
apiKey,
setApiKeys,
apiKeys,
}: {
apiKey: APITokenDocument;
apiKeys: APITokenDocument[];
setApiKeys: SetState<APITokenDocument[]>;
}) {
const [show, setShow] = useState(false);
const [sure, setSure] = useState(false);
return (
<div className="col-12">
<Divider />
<h4>{apiKey.identifier}</h4>
{show ? (
<code style={{ fontSize: "2rem" }}>{apiKey.token}</code>
) : (
<code style={{ fontSize: "2rem" }} onClick={() => setShow(true)}>
Sensitive Information. Click to reveal.
</code>
)}
<h5>Permissions: {Object.keys(apiKey.permissions).join(", ")}</h5>
<Button
onClick={async () => {
if (!sure) {
setSure(true);
} else {
await APIFetchV1(
`/users/${apiKey.userID}/api-tokens/${apiKey.token}`,
{
method: "DELETE",
},
true,
true
);
setApiKeys(apiKeys.filter(e => e.token !== apiKey.token));
}
}}
className="float-right"
variant="danger"
>
{sure ? "Are you really sure?" : "Delete Key"}
</Button>
</div>
);
}
@@ -0,0 +1,140 @@
import { APIFetchV1 } from "util/api";
import { DelayedPageReload } from "util/misc";
import { FormatTime } from "util/time";
import useSetSubheader from "components/layout/header/useSetSubheader";
import Card from "components/layout/page/Card";
import ApiError from "components/util/ApiError";
import Divider from "components/util/Divider";
import Loading from "components/util/Loading";
import Muted from "components/util/Muted";
import useApiQuery from "components/util/query/useApiQuery";
import React from "react";
import { Button, Col, Row } from "react-bootstrap";
import { Link } from "react-router-dom";
import { integer, InviteCodeDocument, PublicUserDocument } from "tachi-common";
export default function UserInvitesPage({ reqUser }: { reqUser: PublicUserDocument }) {
useSetSubheader(
["Users", reqUser.username, "Invites"],
[reqUser],
`${reqUser.username}'s Invites`
);
const { data, isLoading, error } = useApiQuery<{ invites: integer; limit: integer }>(
`/users/${reqUser.id}/invites/limit`
);
if (error) {
return <ApiError error={error} />;
}
if (!data || isLoading) {
return <Loading />;
}
const newInvites = data.limit - data.invites;
return (
<Card header="Invites" className="col-12 offset-lg-2 col-lg-8">
<Row>
<Col xs={12} className="text-center">
<h4 className="display-4">
You can make <b>{newInvites}</b> invite code{newInvites !== 1 ? "s" : ""}.
</h4>
<h4>You get new invite codes every month.</h4>
<span className="text-danger">
By inviting a user, you take partial responsibility for their actions. If
they cause trouble, you may also be in trouble.
<br />
Only invite people you trust!
</span>
<Divider />
</Col>
{newInvites !== 0 && (
<Col xs={12}>
<div className="d-flex justify-content-center">
<Button
onClick={async () => {
const r = await APIFetchV1(
`/users/${reqUser.id}/invites/create`,
{
method: "POST",
},
true,
true
);
if (r.success) {
DelayedPageReload();
}
}}
className="btn btn-primary"
>
Create new Invite
</Button>
</div>
<Divider />
</Col>
)}
<Col xs={12}>
<h4 className="text-center mb-4">Your Invites</h4>
<InviteList reqUser={reqUser} />
</Col>
</Row>
</Card>
);
}
function InviteList({ reqUser }: { reqUser: PublicUserDocument }) {
const { data, isLoading, error } = useApiQuery<{
invites: InviteCodeDocument[];
consumers: PublicUserDocument[];
}>(`/users/${reqUser.id}/invites`);
if (error) {
return <ApiError error={error} />;
}
if (isLoading || !data) {
return <Loading />;
}
const unconsumed = data.invites.filter(e => !e.consumed);
const consumed = data.invites.filter(e => e.consumed);
return (
<div style={{ fontSize: "1.2rem" }}>
<h3>Unused</h3>
<Muted>
These codes have not been used by anyone yet! You can send them to anyone to let
them onto the site.
</Muted>
{unconsumed.length ? (
unconsumed.map(e => (
<div key={e.code}>
<code>{e.code}</code>
</div>
))
) : (
<div>No Unconsumed Invites!</div>
)}
<h3 className="mt-4">Users you have Invited</h3>
<div style={{ fontSize: "1.1rem" }}>
{consumed.length ? (
consumed.map(e => (
<div key={e.code}>
Invited{" "}
<Link to={`/dashboard/users/${e.consumedBy}`}>
{data.consumers.find(u => u.id === e.consumedBy)?.username ??
"Unknown"}
</Link>{" "}
at {FormatTime(e.consumedAt || 0)}
</div>
))
) : (
<div>No-one, yet!</div>
)}
</div>
</div>
);
}
@@ -0,0 +1,116 @@
import { APIFetchV1 } from "util/api";
import useSetSubheader from "components/layout/header/useSetSubheader";
import Card from "components/layout/page/Card";
import Divider from "components/util/Divider";
import ExternalLink from "components/util/ExternalLink";
import Muted from "components/util/Muted";
import { UserContext } from "context/UserContext";
import React, { useContext, useEffect, useState } from "react";
import { Button } from "react-bootstrap";
import ReactMarkdown from "react-markdown";
import { PublicUserDocument } from "tachi-common";
interface Props {
reqUser: PublicUserDocument;
}
export default function UserPage({ reqUser }: Props) {
useSetSubheader(["Users", reqUser.username], [reqUser], `${reqUser.username}'s Profile`);
return <AboutMeCard reqUser={reqUser} />;
}
function AboutMeCard({ reqUser }: Props) {
const { user } = useContext(UserContext);
const [editMode, setEditMode] = useState(false);
const [content, setContent] = useState(reqUser.about);
async function SubmitNewAboutMe() {
const res = await APIFetchV1(
`/users/${user!.id}`,
{
method: "PATCH",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ about: content }),
},
true,
true
);
if (res.success) {
setEditMode(false);
}
}
useEffect(() => {
if (
editMode &&
(content === "<script>alert(1)</script>" || content === "<script>alert(1);</script>")
) {
alert(`1\n\n\n...Just kidding, this is an easter egg :)`);
}
}, [content]);
let footer = <></>;
if (user?.id === reqUser.id) {
if (!editMode) {
footer = (
<div className="d-flex justify-content-end">
<Button onClick={() => setEditMode(true)} variant="info">
Edit
</Button>
</div>
);
} else {
footer = (
<div className="d-flex">
<Button
className="mr-auto"
onClick={() => {
setEditMode(false);
setContent(reqUser.about);
}}
variant="secondary"
>
Cancel
</Button>
<Button
className="ml-auto"
onClick={() => SubmitNewAboutMe()}
variant="success"
>
Submit
</Button>
</div>
);
}
}
return (
<Card header="About Me" footer={footer}>
{editMode && (
<>
<textarea
style={{ height: 200 }}
className="w-100"
value={content}
onChange={e => setContent(e.target.value)}
/>
<Muted>
You can use{" "}
<ExternalLink href="https://www.markdownguide.org/basic-syntax/">
markdown
</ExternalLink>{" "}
here!
</Muted>
<Divider className="mt-4 mb-4" />
</>
)}
<ReactMarkdown>{content}</ReactMarkdown>
</Card>
);
}
@@ -0,0 +1,484 @@
import { APIFetchV1, ToAPIURL } from "util/api";
import { DelayedPageReload, FetchJSONBody, UppercaseFirst } from "util/misc";
import useSetSubheader from "components/layout/header/useSetSubheader";
import Card from "components/layout/page/Card";
import ProfilePicture from "components/user/ProfilePicture";
import Divider from "components/util/Divider";
import Icon from "components/util/Icon";
import Muted from "components/util/Muted";
import SelectButton from "components/util/SelectButton";
import { UserSettingsContext } from "context/UserSettingsContext";
import { useFormik } from "formik";
import { TachiConfig } from "lib/config";
import React, { useContext, useState } from "react";
import { Alert, Button, Form } from "react-bootstrap";
import { PublicUserDocument, UserSettings } from "tachi-common";
interface Props {
reqUser: PublicUserDocument;
}
export default function UserSettingsPage({ reqUser }: Props) {
useSetSubheader(
["Users", reqUser.username, "Settings"],
[reqUser],
`${reqUser.username}'s Settings`
);
const [page, setPage] = useState<"image" | "socialMedia" | "preferences" | "account">("image");
return (
<Card header="Settings" className="col-12 offset-lg-2 col-lg-8">
<div className="row">
<div className="col-12">
<div className="btn-group d-flex justify-content-center">
<SelectButton value={page} setValue={setPage} id="image">
<Icon type="image" />
Pictures
</SelectButton>
<SelectButton value={page} setValue={setPage} id="socialMedia">
<Icon type="twitter" brand />
Social Media
</SelectButton>
<SelectButton value={page} setValue={setPage} id="preferences">
<Icon type="cogs" />
UI Preferences
</SelectButton>
<SelectButton value={page} setValue={setPage} id="account">
<Icon type="lock" />
Change Password
</SelectButton>
</div>
</div>
<div className="col-12">
<Divider className="mt-4 mb-4" />
{page === "image" ? (
<ImageForm reqUser={reqUser} />
) : page === "socialMedia" ? (
<SocialMediaForm reqUser={reqUser} />
) : page === "account" ? (
<AccountSettings reqUser={reqUser} />
) : (
<PreferencesForm reqUser={reqUser} />
)}
</div>
<div className="col-12">
<Divider />
<Muted>
Looking to change settings for a specific game? Go to your game profile, and
select those settings!
</Muted>
</div>
</div>
</Card>
);
}
function AccountSettings({ reqUser }: { reqUser: PublicUserDocument }) {
const formik = useFormik({
initialValues: {
"!oldPassword": "",
"!password": "",
confPass: "",
},
onSubmit: async values => {
const r = await APIFetchV1<UserSettings>(
`/users/${reqUser.id}/change-password`,
{
method: "POST",
...FetchJSONBody({
"!oldPassword": values["!oldPassword"],
"!password": values["!password"],
}),
},
true,
true
);
if (r.success) {
formik.setValues({
"!oldPassword": "",
"!password": "",
confPass: "",
});
}
},
});
return (
<Form onSubmit={formik.handleSubmit}>
<Form.Group>
<Form.Label>Old Password</Form.Label>
<Form.Control
type="password"
id="!oldPassword"
value={formik.values["!oldPassword"]}
placeholder="Your Current Password"
onChange={formik.handleChange}
/>
</Form.Group>
<Form.Group>
<Form.Label>New Password</Form.Label>
<Form.Control
type="password"
id="!password"
value={formik.values["!password"]}
placeholder="New Password"
onChange={formik.handleChange}
/>
{formik.values["!password"].length < 8 && (
<Form.Text className="text-warning">
Passwords have to be atleast 8 characters long.
</Form.Text>
)}
</Form.Group>
<Form.Group>
<Form.Label>Confirm New Password</Form.Label>
<Form.Control
type="password"
id="confPass"
value={formik.values.confPass}
placeholder="New Password"
onChange={formik.handleChange}
/>
</Form.Group>
{!(formik.values["!password"] === formik.values.confPass) && (
<Form.Text className="text-danger">Passwords don't match!</Form.Text>
)}
<Button
className="mt-8"
variant="danger"
type="submit"
disabled={
!(
formik.values["!password"] === formik.values.confPass &&
formik.values.confPass.length >= 8
)
}
>
Change Password
</Button>
</Form>
);
}
function PreferencesForm({ reqUser }: { reqUser: PublicUserDocument }) {
const { settings, setSettings } = useContext(UserSettingsContext);
const formik = useFormik({
initialValues: {
developerMode: settings?.preferences.developerMode ?? false,
invisible: settings?.preferences.invisible ?? false,
contentiousContent: settings?.preferences.contentiousContent ?? false,
advancedMode: settings?.preferences.advancedMode ?? false,
deletableScores: settings?.preferences.deletableScores ?? false,
},
onSubmit: async values => {
const res = await APIFetchV1<UserSettings>(
`/users/${reqUser.id}/settings`,
{
method: "PATCH",
...FetchJSONBody(values),
},
true,
true
);
if (res.success) {
setSettings(res.body);
}
},
});
return (
<Form onSubmit={formik.handleSubmit}>
<Form.Group>
<Form.Check
type="checkbox"
id="developerMode"
checked={formik.values.developerMode}
onChange={formik.handleChange}
label="Developer Mode"
/>
<Form.Text>Enable debug information and other useful debugging buttons.</Form.Text>
</Form.Group>
<Form.Group>
<Form.Check
type="checkbox"
id="advancedMode"
checked={formik.values.advancedMode}
onChange={formik.handleChange}
label="Advanced Mode"
/>
<Form.Text>
Enable advanced stuff, like being able to copy a tables contents into a CSV.
</Form.Text>
</Form.Group>
{/* <Form.Group>
<Form.Check
type="checkbox"
id="invisible"
checked={formik.values.invisible}
onChange={formik.handleChange}
label="Invisible Mode"
/>
<Form.Text>Hide your last seen status.</Form.Text>
</Form.Group> */}
<Form.Group>
<Form.Check
type="checkbox"
id="deletableScores"
checked={formik.values.deletableScores}
onChange={formik.handleChange}
label="Deletable Scores"
/>
<Form.Text>
Enables the option to delete scores. Turn this off if the thought of deleting a
score gives you anxiety.
</Form.Text>
</Form.Group>
<Form.Group>
<Form.Check
type="checkbox"
id="contentiousContent"
checked={formik.values.contentiousContent}
onChange={formik.handleChange}
label="Family Unfriendly Mode"
/>
<Form.Text>
Show slightly less appropriate splash texts in certain places.
</Form.Text>
</Form.Group>
<Button type="submit">Update Settings</Button>
</Form>
);
}
function ImageForm({ reqUser }: { reqUser: PublicUserDocument }) {
const [pfp, setPfp] = useState<File | undefined>();
const [banner, setBanner] = useState<File | undefined>();
return (
<div>
<Alert variant="danger">
Remember Rule 6! Do not set inappropriate stuff as your avatar/banner. If you have
to ask, the answer is probably no.
</Alert>
<Form.Group>
<Form.Label>Profile Picture</Form.Label>
<input
className="form-control"
accept="image/png,image/jpeg,image/gif"
tabIndex={1}
type="file"
id="pfp"
multiple={false}
onChange={e => setPfp(e.target.files![0])}
/>
<div className="d-flex justify-content-center mt-4">
<ProfilePicture
user={reqUser}
src={pfp ? URL.createObjectURL(pfp) : ToAPIURL(`/users/${reqUser.id}/pfp`)}
/>
</div>
<FileUploadController file={pfp} reqUser={reqUser} type="pfp" />
</Form.Group>
<Form.Group>
<Form.Label>Profile Banner</Form.Label>
<input
className="form-control"
accept="image/png,image/jpeg,image/gif"
tabIndex={2}
type="file"
id="banner"
multiple={false}
onChange={e => setBanner(e.target.files![0])}
/>
<div
className="d-flex justify-content-center mt-4 rounded"
style={{
height: "200px",
boxShadow: "0px 0px 10px 0px #000000",
backgroundRepeat: "no-repeat",
backgroundSize: "cover",
backgroundPosition: "center",
backgroundImage: `url(${
banner
? URL.createObjectURL(banner)
: ToAPIURL(`/users/${reqUser.id}/banner`)
})`,
}}
/>
<FileUploadController file={banner} reqUser={reqUser} type="banner" />
</Form.Group>
</div>
);
}
function SocialMediaForm({ reqUser }: { reqUser: PublicUserDocument }) {
const placeholders = {
discord: "Example#0000",
twitter: "Twitter Handle",
twitch: "Twitch Username",
github: "Github Username",
youtube: "Channel Name",
steam: "Steam Community ID",
};
const formik = useFormik({
initialValues: {
discord: reqUser.socialMedia.discord ?? "",
twitter: reqUser.socialMedia.twitter ?? "",
twitch: reqUser.socialMedia.twitch ?? "",
github: reqUser.socialMedia.github ?? "",
steam: reqUser.socialMedia.steam ?? "",
youtube: reqUser.socialMedia.youtube ?? "",
},
onSubmit: async values => {
const valuesClone: Record<string, string | null> = {};
for (const v in values) {
const vx = v as keyof typeof values;
valuesClone[vx] = values[vx] || null;
}
const rj = await APIFetchV1<PublicUserDocument>(
"/users/me",
{
method: "PATCH",
body: JSON.stringify(valuesClone),
headers: {
"Content-Type": "application/json",
},
},
true,
true
);
if (rj.success) {
DelayedPageReload();
}
},
});
return (
<Form onSubmit={formik.handleSubmit}>
{(["discord", "twitter", "github", "steam", "youtube"] as const).map((e, i) => (
<Form.Group key={e}>
<Form.Label>{UppercaseFirst(e)}</Form.Label>
<Form.Control
tabIndex={i + 1}
type="text"
id={e}
value={formik.values[e]}
placeholder={placeholders[e]}
onChange={formik.handleChange}
/>
</Form.Group>
))}
<Form.Group>
<Form.Label>Twitch</Form.Label>
<Form.Control
tabIndex={6}
type="text"
id="twitch"
value={formik.values.twitch}
placeholder={placeholders.twitch}
onChange={formik.handleChange}
/>
<Form.Text className="text-muted">
If you are streaming a game supported by {TachiConfig.name}, your stream may
appear on that games page!
</Form.Text>
</Form.Group>
<div className="row justify-content-center">
<Button type="submit" variant="success">
Submit
</Button>
</div>
</Form>
);
}
function SizeWarner({ bytes, cap }: { bytes: number; cap: number }) {
const kb = bytes / 1_000;
let className = "text-success";
if (kb > cap) {
className = "text-danger";
} else if (cap * 0.95 < kb) {
className = "text-warning";
}
return (
<span className={className}>
{kb}kb/{cap}kb
</span>
);
}
function FileUploadController({
file,
type,
reqUser,
}: {
file?: File;
type: "pfp" | "banner";
reqUser: PublicUserDocument;
}) {
return (
<div className="d-flex mt-8">
<Button
onClick={async () => {
const res = await APIFetchV1(
`/users/me/${type}`,
{ method: "DELETE" },
true,
true
);
if (res.success) {
DelayedPageReload();
}
}}
disabled={!reqUser.customPfpLocation}
className="mr-auto"
variant="secondary"
>
Unset
</Button>
{file && (
<div className="mx-auto">
<SizeWarner bytes={file.size} cap={1024} />
</div>
)}
{file && (
<Button
onClick={async () => {
const formData = new FormData();
formData.append(type, file);
const res = await APIFetchV1(
`/users/me/${type}`,
{
method: "PUT",
body: formData,
},
true,
true
);
if (res.success) {
window.location.reload();
}
}}
className="ml-auto"
variant="success"
disabled={file.size > 1024 * 1000}
>
Submit
</Button>
)}
</div>
);
}
@@ -0,0 +1,199 @@
import { APIFetchV1, UnsuccessfulAPIFetchResponse } from "util/api";
import { ChangeOpacity } from "util/color-opacity";
import { UppercaseFirst } from "util/misc";
import ClassBadge from "components/game/ClassBadge";
import useSetSubheader from "components/layout/header/useSetSubheader";
import Card from "components/layout/page/Card";
import MiniTable from "components/tables/components/MiniTable";
import GentleLink from "components/util/GentleLink";
import LoadingWrapper from "components/util/LoadingWrapper";
import { useProfileRatingAlg } from "components/util/useScoreRatingAlg";
import React, { useState } from "react";
import { useQuery } from "react-query";
import { Link } from "react-router-dom";
import {
COLOUR_SET,
FormatGame,
GetGameConfig,
GetGamePTConfig,
IDStrings,
integer,
PublicUserDocument,
UGSRatingsLookup,
UserGameStats,
} from "tachi-common";
import { GameClassSets } from "tachi-common/js/game-classes";
import { GPTLeaderboard, UGPTLeaderboardAdjacent } from "types/api-returns";
import { GamePT, SetState } from "types/react";
interface LeaderboardsData {
stats: UGPTLeaderboardAdjacent;
leaderboard: GPTLeaderboard;
}
export default function LeaderboardsPage({
reqUser,
game,
playtype,
}: { reqUser: PublicUserDocument } & GamePT) {
const gameConfig = GetGameConfig(game);
useSetSubheader(
["Users", reqUser.username, "Games", gameConfig.name, playtype, "Leaderboard"],
[reqUser, game, playtype],
`${reqUser.username}'s ${FormatGame(game, playtype)} Leaderboard`
);
const defaultRating = useProfileRatingAlg(game, playtype);
const [alg, setAlg] = useState(defaultRating);
const url = `/users/${reqUser.id}/games/${game}/${playtype}/leaderboard-adjacent?alg=${alg}`;
const { isLoading, error, data } = useQuery<LeaderboardsData, UnsuccessfulAPIFetchResponse>(
url,
async () => {
const res = await APIFetchV1<UGPTLeaderboardAdjacent>(url);
if (!res.success) {
throw res;
}
const lRes = await APIFetchV1<GPTLeaderboard>(
`/games/${game}/${playtype}/leaderboard?limit=3&alg=${alg}`
);
if (!lRes.success) {
throw lRes;
}
return {
stats: res.body,
leaderboard: lRes.body,
};
}
);
return (
<LoadingWrapper {...{ dataset: data, isLoading, error }}>
<LeaderboardsPageContent {...{ reqUser, game, playtype, data: data!, alg, setAlg }} />
</LoadingWrapper>
);
}
function LeaderboardsPageContent({
reqUser,
game,
playtype,
data,
alg,
}: {
reqUser: PublicUserDocument;
data: LeaderboardsData;
alg: UGSRatingsLookup[IDStrings];
setAlg: SetState<UGSRatingsLookup[IDStrings]>;
} & GamePT) {
const { stats, leaderboard } = data;
const gptConfig = GetGamePTConfig(game, playtype);
const userMap = new Map<integer, PublicUserDocument>();
for (const u of stats.users) {
userMap.set(u.id, u);
}
for (const u of leaderboard.users) {
userMap.set(u.id, u);
}
// hack - we aren't returned from this api call for some reason.
userMap.set(reqUser.id, reqUser);
const bestNearbyUser = stats.thisUsersRanking.ranking - stats.above.length - 1;
function LeaderboardRow({ s, i }: { s: UserGameStats; i: integer }) {
return (
<tr
style={{
backgroundColor:
reqUser.id === s.userID ? ChangeOpacity(COLOUR_SET.gold, 0.15) : undefined,
height: reqUser.id === s.userID ? "50px" : undefined,
}}
>
<td>
<strong>#{i}</strong>
{reqUser.id === s.userID && (
<small className="text-muted">/{stats.thisUsersRanking.outOf}</small>
)}
</td>
<td>
<GentleLink
to={`/dashboard/users/${
userMap.get(s.userID)!.username
}/games/${game}/${playtype}`}
>
{userMap.get(s.userID)?.username}
</GentleLink>
</td>
<td>
{s.ratings[alg]
? gptConfig.profileRatingAlgFormatters[alg]
? gptConfig.profileRatingAlgFormatters[alg]!(s.ratings[alg]!)
: s.ratings[alg]!.toFixed(2)
: "No Data."}
</td>
{/* temp */}
<td>
{Object.entries(s.classes).length
? Object.entries(s.classes).map(([k, v]) => (
<ClassBadge
key={`${k}:${v}`}
classValue={v}
classSet={k as GameClassSets[IDStrings]}
game={game}
playtype={playtype}
/>
))
: "No Classes"}
</td>
</tr>
);
}
return (
<Card
header={"Leaderboard"}
footer={
<Link
to={`/dashboard/games/${game}/${playtype}/leaderboards`}
className="float-right"
>
View Global Leaderboards
</Link>
}
>
<MiniTable
className="text-center"
headers={["Position", "User", UppercaseFirst(alg), "Classes"]}
>
<>
{bestNearbyUser >= 1 &&
leaderboard.gameStats
.slice(0, bestNearbyUser)
.map((s, i) => <LeaderboardRow key={s.userID} s={s} i={i + 1} />)}
{bestNearbyUser > 4 && (
<tr style={{ lineHeight: "0.5rem" }}>
<td colSpan={4}>...</td>
</tr>
)}
{[...stats.above, stats.thisUsersStats, ...stats.below].map((s, i) => (
<LeaderboardRow
key={s.userID}
s={s}
i={stats.thisUsersRanking.ranking - stats.above.length + i}
/>
))}
</>
</MiniTable>
</Card>
);
}
@@ -0,0 +1,500 @@
import { APIFetchV1 } from "util/api";
import { ONE_HOUR, ONE_MINUTE } from "util/constants/time";
import { CreateChartMap, CreateSongMap, GetPBs } from "util/data";
import { UppercaseFirst } from "util/misc";
import { NumericSOV } from "util/sorts";
import { FormatDate, FormatDuration, FormatTime, MillisToSince } from "util/time";
import TimelineChart from "components/charts/TimelineChart";
import useSetSubheader from "components/layout/header/useSetSubheader";
import Card from "components/layout/page/Card";
import MiniTable from "components/tables/components/MiniTable";
import ScoreTable from "components/tables/scores/ScoreTable";
import UGPTStatShowcase from "components/user/UGPTStatShowcase";
import AsyncLoader from "components/util/AsyncLoader";
import Divider from "components/util/Divider";
import Icon from "components/util/Icon";
import Muted from "components/util/Muted";
import SelectButton from "components/util/SelectButton";
import { useProfileRatingAlg } from "components/util/useScoreRatingAlg";
import { DateTime } from "luxon";
import React, { useMemo, useState } from "react";
import { Badge } from "react-bootstrap";
import {
FormatGame,
GetGameConfig,
GetGamePTConfig,
PublicUserDocument,
ScoreDocument,
SessionDocument,
UserGameStats,
} from "tachi-common";
import { SessionReturns, UGPTHistory } from "types/api-returns";
import { GamePT } from "types/react";
import { ScoreDataset } from "types/tables";
export default function OverviewPage({
reqUser,
game,
playtype,
}: { reqUser: PublicUserDocument } & GamePT) {
const gameConfig = GetGameConfig(game);
useSetSubheader(
["Users", reqUser.username, "Games", gameConfig.name, playtype],
[reqUser, game, playtype],
`${reqUser.username}'s ${FormatGame(game, playtype)} Overview`
);
return (
<React.Fragment key={`${game}:${playtype}`}>
<UGPTStatShowcase reqUser={reqUser} game={game} playtype={playtype} />
<RankingInfo reqUser={reqUser} game={game} playtype={playtype} />
<LastSession reqUser={reqUser} game={game} playtype={playtype} />
</React.Fragment>
);
}
function LastSession({ reqUser, game, playtype }: { reqUser: PublicUserDocument } & GamePT) {
return (
<AsyncLoader
promiseFn={async () => {
const res = await APIFetchV1<SessionDocument>(
`/users/${reqUser.id}/games/${game}/${playtype}/sessions/last`
);
if (res.statusCode === 404) {
return null;
}
if (!res.success) {
throw new Error(res.description);
}
const sessionDataRes = await APIFetchV1<SessionReturns>(
`/sessions/${res.body!.sessionID}`
);
if (!sessionDataRes.success) {
throw new Error(res.description);
}
return { session: res.body, sessionData: sessionDataRes.body };
}}
>
{data =>
data && (
<Card className="mt-4" header="Most Recent Session">
<div className="row d-flex justify-content-center">
{
<>
<div className="col-12 text-center">
<h4 className="display-4">{data.session.name}</h4>
<span className="text-muted">{data.session.desc}</span>
<Divider className="mt-4 mb-8" />
</div>
<div className="col-12 col-lg-3 text-center">
<MiniTable
className="mt-4"
headers={["Information"]}
colSpan={2}
>
<tr>
<td>Started</td>
<td>{FormatTime(data.session.timeStarted)}</td>
</tr>
<tr>
{/* If session ended less than 2 hours ago, it can still be appended to. */}
{/* Kinda - time is a bit nonlinear wrt. importing scores, as scores don't have to have */}
{/* happened at the same time they were imported. */}
{/* that doesn't matter though - this is just a UI thing */}
<td>
{Date.now() - data.session.timeEnded <
ONE_HOUR * 2
? "Last Score"
: "Ended At"}
</td>
<td>
{FormatTime(data.session.timeEnded)}
{/* if the last score was less than 10 mins ago, this session is probably still being populated */}
{Date.now() - data.session.timeEnded <
ONE_MINUTE * 10 && (
<>
<br />
<Badge
variant="primary"
className="mt-2"
>
Ongoing!
</Badge>
</>
)}
</td>
</tr>
<tr>
<td>Duration</td>
<td>
{FormatDuration(
data.session.timeEnded -
data.session.timeStarted
)}
</td>
</tr>
<tr>
<td>Scores</td>
<td>{data.session.scoreInfo.length}</td>
</tr>
<tr>
<td>PBs</td>
<td>{GetPBs(data.session.scoreInfo).length}</td>
</tr>
</MiniTable>
</div>
<div className="col-12 col-lg-9">
<RecentSessionScoreInfo
{...data}
game={game}
playtype={playtype}
reqUser={reqUser}
/>
</div>
</>
}
</div>
</Card>
)
}
</AsyncLoader>
);
}
function RecentSessionScoreInfo({
session,
sessionData,
game,
playtype,
reqUser,
}: {
session: SessionDocument;
sessionData: SessionReturns;
reqUser: PublicUserDocument;
} & GamePT) {
const highlightedScores = sessionData.scores.filter(e => e.highlight);
const [mode, setMode] = useState<"highlight" | "best" | "recent">(
highlightedScores.length > 0 ? "highlight" : "best"
);
const gptConfig = GetGamePTConfig(game, playtype);
const songMap = CreateSongMap(sessionData.songs);
const chartMap = CreateChartMap(sessionData.charts);
const dataset = useMemo(() => {
let scoreSet = sessionData.scores;
if (mode === "highlight") {
scoreSet = highlightedScores;
} else if (mode === "best") {
scoreSet.sort((a, b) =>
NumericSOV<ScoreDocument>(
d => d.calculatedData[gptConfig.defaultScoreRatingAlg] ?? 0
)(b, a)
);
} else if (mode === "recent") {
// sneaky hack to sort in reverse
scoreSet.sort((a, b) => NumericSOV<ScoreDocument>(d => d.timeAchieved ?? 0)(b, a));
}
const scoreDataset: ScoreDataset = [];
for (const score of scoreSet) {
const chart = chartMap.get(score.chartID)!;
scoreDataset.push({
...score,
__related: {
chart,
song: songMap.get(chart.songID)!,
index: 0,
user: reqUser,
},
});
}
return scoreDataset;
}, [mode]);
return (
<div className="row">
<div className="col-12 d-flex justify-content-center">
<div className="btn-group">
<SelectButton id="highlight" value={mode} setValue={setMode}>
<Icon type="star" />
Highlights
</SelectButton>
<SelectButton id="best" value={mode} setValue={setMode}>
<Icon type="sort-amount-up" />
Best
</SelectButton>
<SelectButton id="recent" value={mode} setValue={setMode}>
<Icon type="history" />
Recent
</SelectButton>
</div>
</div>
<div className="col-12 mt-4">
<ScoreTable
dataset={dataset as any}
pageLen={5}
game={game}
playtype={playtype as any}
/>
</div>
</div>
);
}
function RankingInfo({ reqUser, game, playtype }: { reqUser: PublicUserDocument } & GamePT) {
return (
<AsyncLoader
promiseFn={async () => {
const res = await APIFetchV1<UGPTHistory>(
`/users/${reqUser.id}/games/${game}/${playtype}/history`
);
if (!res.success) {
throw new Error(res.description);
}
return res.body;
}}
>
{data => <UserHistory {...{ data, game, playtype, reqUser }} />}
</AsyncLoader>
);
}
function UserHistory({
data,
reqUser,
game,
playtype,
}: { data: UGPTHistory; reqUser: PublicUserDocument } & GamePT) {
const gptConfig = GetGamePTConfig(game, playtype);
const [mode, setMode] = useState<"ranking" | "playcount" | "rating">("ranking");
const preferredRating = useProfileRatingAlg(game, playtype);
const [rating, setRating] = useState<keyof UserGameStats["ratings"]>(preferredRating);
const propName = useMemo(() => {
if (mode === "rating" && rating) {
return UppercaseFirst(rating);
} else if (mode === "ranking") {
return `${UppercaseFirst(rating)} Ranking`;
}
return UppercaseFirst(mode);
}, [mode, rating]);
const currentPropValue = useMemo(() => {
if (mode === "rating" && rating) {
return data[0].ratings[rating] ? data[0].ratings[rating]!.toFixed(2) : "N/A";
} else if (mode === "ranking") {
return (
<>
#{data[0].rankings[rating]?.ranking ?? "ERR!"}
<Muted>/{data[0].rankings[rating]?.outOf ?? "ERR!"}</Muted>
</>
);
}
return data[0].playcount;
}, [mode, rating]);
return (
<Card className="mt-4" header={`${reqUser.username}'s History`}>
<div className="row d-flex justify-content-center align-items-center mb-4">
{/* <div className="d-none d-md-block col-md-3 text-center">
<div className="mb-4">Something</div>
<div>
<span className="display-4">Todo</span>
</div>
</div> */}
<div className="col-12 col-md-6 offset-md-3 d-flex justify-content-center">
<div className="btn-group">
<SelectButton id="ranking" value={mode} setValue={setMode}>
<Icon type="trophy" />
Ranking
</SelectButton>
<SelectButton id="playcount" value={mode} setValue={setMode}>
<Icon type="gamepad" />
Playcount
</SelectButton>
<SelectButton id="rating" value={mode} setValue={setMode}>
<Icon type="chart-line" />
Ratings
</SelectButton>
</div>
</div>
<div className="col-12 d-block d-md-none mb-4"></div>
<div className="col-12 col-md-3 text-center">
<div className="mb-4">Current {propName}</div>
<div>
<span className="display-4">{currentPropValue}</span>
</div>
</div>
</div>
<Divider className="mt-6 mb-2" />
{mode === "ranking" ? (
<>
<div className="col-12 offset-md-4 col-md-4 mt-4">
<select
className="form-control"
value={rating}
onChange={e =>
setRating(e.target.value as keyof UserGameStats["ratings"])
}
>
{gptConfig.profileRatingAlgs.map(e => (
<option key={e} value={e}>
{UppercaseFirst(e)}
</option>
))}
</select>
</div>
<RankingTimeline data={data} rating={rating} />
</>
) : mode === "playcount" ? (
<TimelineChart
height="30rem"
mobileHeight="20rem"
data={[
{
id: "playcount",
data: data.map(d => ({ x: d.timestamp, y: d.playcount })),
},
]}
axisBottom={{
format: x => DateTime.fromJSDate(x).toLocaleString(DateTime.DATE_FULL),
tickValues: 3,
}}
axisLeft={{
tickSize: 5,
tickPadding: 5,
tickRotation: 0,
format: y => (Number.isInteger(y) ? y : ""),
}}
tooltipRenderFn={p => (
<div>
{p.data.yFormatted} Play{p.data.yFormatted !== "1" && "s"}
<br />
<small className="text-muted">
{MillisToSince(+p.data.xFormatted)}
</small>
</div>
)}
curve={"stepBefore"}
enableArea={true}
areaBaselineValue={Math.min(...data.map(e => e.playcount))}
/>
) : (
<>
<div className="col-12 offset-md-4 col-md-4 mt-4">
<select
className="form-control"
value={rating}
onChange={e =>
setRating(e.target.value as keyof UserGameStats["ratings"])
}
>
{gptConfig.profileRatingAlgs.map(e => (
<option key={e} value={e}>
{UppercaseFirst(e)}
</option>
))}
</select>
</div>
<RatingTimeline {...{ data, rating }} />
</>
)}
</Card>
);
}
function RatingTimeline({
data,
rating,
}: {
data: UGPTHistory;
rating: keyof UserGameStats["ratings"];
}) {
const ratingDataset = [
{ id: rating, data: data.map(e => ({ x: e.timestamp, y: e.ratings[rating] })) },
];
return (
<TimelineChart
height="30rem"
mobileHeight="20rem"
data={ratingDataset}
axisBottom={{
format: x => DateTime.fromJSDate(x).toLocaleString(DateTime.DATE_FULL),
tickValues: 3, // temp
}}
axisLeft={{
tickSize: 5,
tickPadding: 5,
tickRotation: 0,
format: y => (y ? y.toFixed(2) : "N/A"),
}}
tooltipRenderFn={p => (
<div>
{p.data.y ? (p.data.y as number).toFixed(2) : "N/A"} {UppercaseFirst(rating)}
<br />
<small className="text-muted">{MillisToSince(+p.data.xFormatted)}</small>
</div>
)}
/>
);
}
function RankingTimeline({
data,
rating,
}: {
data: UGPTHistory;
rating: keyof UserGameStats["ratings"];
}) {
return (
<TimelineChart
height="30rem"
mobileHeight="20rem"
data={[
{
id: "ranking",
data: data.map(d => ({ x: d.timestamp, y: d.rankings[rating].ranking })),
},
]}
axisBottom={{
format: x => DateTime.fromJSDate(x).toLocaleString(DateTime.DATE_FULL),
tickValues: 3, // temp
}}
axisLeft={{
tickSize: 5,
tickPadding: 5,
tickRotation: 0,
format: y => (Number.isInteger(y) ? `#${y}` : ""),
}}
reverse={true}
tooltipRenderFn={p => (
<div>
{MillisToSince(+p.data.xFormatted)}: #{p.data.yFormatted}
<br />
<small className="text-muted">({FormatDate(+p.data.xFormatted)})</small>
</div>
)}
/>
);
}
@@ -0,0 +1,337 @@
import { APIFetchV1 } from "util/api";
import useSetSubheader from "components/layout/header/useSetSubheader";
import PBTable from "components/tables/pbs/PBTable";
import ScoreTable from "components/tables/scores/ScoreTable";
import DebounceSearch from "components/util/DebounceSearch";
import Icon from "components/util/Icon";
import LoadingWrapper from "components/util/LoadingWrapper";
import SelectLinkButton from "components/util/SelectLinkButton";
import useScoreRatingAlg from "components/util/useScoreRatingAlg";
import useUGPTBase from "components/util/useUGPTBase";
import React, { useState } from "react";
import { useQuery } from "react-query";
import { Route, Switch } from "react-router-dom";
import {
ChartDocument,
FormatGame,
Game,
GamePTConfig,
GetGameConfig,
GetGamePTConfig,
IDStrings,
PBScoreDocument,
PublicUserDocument,
ScoreCalculatedDataLookup,
ScoreDocument,
SongDocument,
UnsuccessfulAPIResponse,
} from "tachi-common";
import { GamePT, SetState } from "types/react";
export default function ScoresPage({
reqUser,
game,
playtype,
}: {
reqUser: PublicUserDocument;
} & GamePT) {
const gameConfig = GetGameConfig(game);
const gptConfig = GetGamePTConfig(game, playtype);
const defaultRating = useScoreRatingAlg(game, playtype);
const [alg, setAlg] = useState(defaultRating);
useSetSubheader(
["Users", reqUser.username, "Games", gameConfig.name, playtype, "Scores"],
[reqUser],
`${reqUser.username}'s ${FormatGame(game, playtype)} Scores`
);
const base = useUGPTBase({ reqUser, game, playtype });
return (
<div className="row">
<div className="col-12 text-center">
<div className="btn-group mb-4">
<SelectLinkButton to={`${base}/scores`}>
<Icon type="trophy" />
Best 100 PBs
</SelectLinkButton>
<SelectLinkButton to={`${base}/scores/history`}>
<Icon type="history" />
Recent 100 Scores
</SelectLinkButton>
<SelectLinkButton to={`${base}/scores/most-played`}>
<Icon type="mortar-pestle" />
Most Played
</SelectLinkButton>
<SelectLinkButton to={`${base}/scores/all`}>
<Icon type="database" />
All PBs
</SelectLinkButton>
</div>
</div>
<div className="col-12 mt-4">
<Switch>
<Route exact path="/dashboard/users/:userID/games/:game/:playtype/scores">
<>
{gptConfig.scoreRatingAlgs.length > 1 && (
<AlgSelector {...{ alg, setAlg, gptConfig }} />
)}
<PBsOverview
url={`/users/${reqUser.id}/games/${game}/${playtype}/pbs/best?alg=${alg}`}
{...{ reqUser, game, playtype, alg }}
/>
</>
</Route>
<Route path="/dashboard/users/:userID/games/:game/:playtype/scores/history">
<ScoresOverview {...{ reqUser, game, playtype }} />
</Route>
<Route path="/dashboard/users/:userID/games/:game/:playtype/scores/all">
<PBsOverview
url={`/users/${reqUser.id}/games/${game}/${playtype}/pbs/all`}
reqUser={reqUser}
game={game}
playtype={playtype}
indexCol={false}
/>
</Route>
<Route path="/dashboard/users/:userID/games/:game/:playtype/scores/most-played">
<PBsOverview
url={`/users/${reqUser.id}/games/${game}/${playtype}/most-played`}
reqUser={reqUser}
game={game}
playtype={playtype}
indexCol
showPlaycount
/>
</Route>
</Switch>
</div>
</div>
);
}
function AlgSelector({
gptConfig,
alg,
setAlg,
}: {
gptConfig: GamePTConfig;
alg: ScoreCalculatedDataLookup[IDStrings];
setAlg: SetState<ScoreCalculatedDataLookup[IDStrings]>;
}) {
return (
<div className="row justify-content-center mb-4">
<div className="form-group">
<span className="form-group-prefix">Best 100 PBs according to </span>
<select
className="form-control"
value={alg}
onChange={e => setAlg(e.target.value as any)}
>
{gptConfig.scoreRatingAlgs.map(e => (
<option key={e}>{e}</option>
))}
</select>
</div>
</div>
);
}
function useFetchPBs(url: string, reqUser: PublicUserDocument) {
const { isLoading, error, data } = useQuery(url, async () => {
const res = await APIFetchV1<{
pbs: PBScoreDocument<"iidx:SP">[];
charts: ChartDocument<"iidx:SP">[];
songs: SongDocument<"iidx">[];
}>(url);
if (!res.success) {
throw res;
}
return FormatData(res.body.pbs, res.body.songs, res.body.charts, reqUser);
});
return { isLoading, error: error as UnsuccessfulAPIResponse, data };
}
function PBsOverview({
reqUser,
game,
playtype,
indexCol = true,
showPlaycount = false,
url,
alg,
}: {
reqUser: PublicUserDocument;
url: string;
indexCol?: boolean;
showPlaycount?: boolean;
alg?: ScoreCalculatedDataLookup[IDStrings];
} & GamePT) {
const [search, setSearch] = useState("");
const { isLoading, error, data } = useFetchPBs(url, reqUser);
return (
<div className="row">
<div className="col-12">
<DebounceSearch
className="form-control-lg"
setSearch={setSearch}
placeholder="Search all PBs..."
/>
</div>
<div className="col-12 mt-8">
{search === "" ? (
<LoadingWrapper
style={{ height: 500 }}
{...{ isLoading, error, dataset: data }}
>
<PBTable
dataset={data!}
game={game}
showPlaycount={showPlaycount}
indexCol={indexCol}
alg={alg}
playtype={playtype as "SP" | "DP"}
/>
</LoadingWrapper>
) : (
<PBsSearch {...{ reqUser, game, playtype, search }} />
)}
</div>
</div>
);
}
function FormatData<
D extends PBScoreDocument | ScoreDocument,
I extends IDStrings = IDStrings,
G extends Game = Game
>(d: D[], songs: SongDocument<G>[], charts: ChartDocument<I>[], reqUser: PublicUserDocument) {
const songMap = new Map();
const chartMap = new Map();
for (const song of songs) {
songMap.set(song.id, song);
}
for (const chart of charts) {
chartMap.set(chart.chartID, chart);
}
const data = d.map((e, i) => ({
...e,
__related: {
song: songMap.get(e.songID),
chart: chartMap.get(e.chartID),
index: i,
user: reqUser,
},
}));
return data;
}
function useFetchScores(url: string, reqUser: PublicUserDocument) {
const { isLoading, error, data } = useQuery(url, async () => {
const res = await APIFetchV1<{
scores: ScoreDocument[];
charts: ChartDocument[];
songs: SongDocument[];
}>(url);
if (!res.success) {
throw res;
}
return FormatData(res.body.scores, res.body.songs, res.body.charts, reqUser);
});
return { isLoading, error: error as UnsuccessfulAPIResponse, data };
}
function PBsSearch({
reqUser,
game,
playtype,
search,
alg,
}: {
reqUser: PublicUserDocument;
search: string;
alg?: ScoreCalculatedDataLookup[IDStrings];
} & GamePT) {
const { isLoading, error, data } = useFetchPBs(
`/users/${reqUser.id}/games/${game}/${playtype}/pbs?search=${search}`,
reqUser
);
return (
<LoadingWrapper style={{ height: 500 }} {...{ isLoading, error, dataset: data }}>
<PBTable
indexCol={false}
dataset={data!}
game={game}
alg={alg}
playtype={playtype as "SP" | "DP"}
/>
</LoadingWrapper>
);
}
function ScoresOverview({ reqUser, game, playtype }: { reqUser: PublicUserDocument } & GamePT) {
const [search, setSearch] = useState("");
const { isLoading, error, data } = useFetchScores(
`/users/${reqUser.id}/games/${game}/${playtype}/scores/recent`,
reqUser
);
return (
<div className="row">
<div className="col-12">
<DebounceSearch
className="form-control-lg"
setSearch={setSearch}
placeholder="Search all individual scores..."
/>
</div>
<div className="col-12 mt-8">
{search === "" ? (
<LoadingWrapper
style={{ height: 500 }}
{...{ isLoading, dataset: data, error }}
>
<ScoreTable dataset={data!} game={game} playtype={playtype as any} />
</LoadingWrapper>
) : (
<ScoresSearch {...{ reqUser, game, playtype, search }} />
)}
</div>
</div>
);
}
function ScoresSearch({
reqUser,
game,
playtype,
search,
}: { reqUser: PublicUserDocument; search: string } & GamePT) {
const { isLoading, error, data } = useFetchScores(
`/users/${reqUser.id}/games/${game}/${playtype}/scores?search=${search}`,
reqUser
);
return (
<LoadingWrapper style={{ height: 500 }} {...{ isLoading, error, dataset: data }}>
<ScoreTable dataset={data!} game={game} playtype={playtype as any} />
</LoadingWrapper>
);
}
@@ -0,0 +1,148 @@
import { APIFetchV1 } from "util/api";
import { NumericSOV } from "util/sorts";
import useSetSubheader from "components/layout/header/useSetSubheader";
import GenericSessionTable, {
SessionDataset,
} from "components/tables/sessions/GenericSessionTable";
import DebounceSearch from "components/util/DebounceSearch";
import Icon from "components/util/Icon";
import LoadingWrapper from "components/util/LoadingWrapper";
import SelectButton from "components/util/SelectButton";
import { useSessionRatingAlg } from "components/util/useScoreRatingAlg";
import React, { useState } from "react";
import { useQuery } from "react-query";
import {
FormatGame,
GetGameConfig,
PublicUserDocument,
SessionDocument,
UnsuccessfulAPIResponse,
} from "tachi-common";
import { GamePT } from "types/react";
export default function SessionsPage({
reqUser,
game,
playtype,
}: {
reqUser: PublicUserDocument;
} & GamePT) {
const [sessionSet, setSessionSet] = useState<"recent" | "best" | "highlighted">("best");
const [search, setSearch] = useState("");
const gameConfig = GetGameConfig(game);
useSetSubheader(
["Users", reqUser.username, "Games", gameConfig.name, playtype, "Sessions"],
[reqUser],
`${reqUser.username}'s ${FormatGame(game, playtype)} Sessions`
);
const baseUrl = `/users/${reqUser.id}/games/${game}/${playtype}/sessions`;
const rating = useSessionRatingAlg(game, playtype);
const { isLoading, error, data } = useQuery<SessionDataset, UnsuccessfulAPIResponse>(
`${baseUrl}/${sessionSet}`,
async () => {
const res = await APIFetchV1<SessionDocument[]>(`${baseUrl}/${sessionSet}`);
if (!res.success) {
throw res;
}
return res.body
.sort(
sessionSet === "best"
? NumericSOV(x => x.calculatedData[rating] ?? 0, true)
: NumericSOV(x => x.timeEnded ?? 0, true)
)
.map((e, i) => ({
...e,
__related: {
index: i,
},
}));
}
);
return (
<div className="row">
<div className="col-12 text-center">
<div className="btn-group mb-4">
<SelectButton id="best" setValue={setSessionSet} value={sessionSet}>
<Icon type="trophy" />
Best Sessions
</SelectButton>
<SelectButton id="recent" setValue={setSessionSet} value={sessionSet}>
<Icon type="history" />
Recent Sessions
</SelectButton>
<SelectButton id="highlighted" setValue={setSessionSet} value={sessionSet}>
<Icon type="star" />
Highlighted Sessions
</SelectButton>
</div>
</div>
<div className="col-12 mt-4">
<DebounceSearch
placeholder="Search all sessions..."
className="form-control-lg"
setSearch={setSearch}
/>
</div>
<div className="col-12 mt-4">
{search === "" ? (
<LoadingWrapper {...{ isLoading, error, dataset: data }}>
<GenericSessionTable
indexCol={sessionSet === "best"}
dataset={data!}
reqUser={reqUser}
game={game}
playtype={playtype}
/>
</LoadingWrapper>
) : (
<SearchSessionsTable {...{ game, playtype, reqUser, baseUrl, search }} />
)}
</div>
</div>
);
}
function SearchSessionsTable({
search,
game,
playtype,
reqUser,
baseUrl,
}: { search: string; baseUrl: string; reqUser: PublicUserDocument } & GamePT) {
const { isLoading, error, data } = useQuery<SessionDataset, UnsuccessfulAPIResponse>(
`${baseUrl}?search=${search}`,
async () => {
const res = await APIFetchV1<SessionDocument[]>(`${baseUrl}?search=${search}`);
if (!res.success) {
throw res;
}
return res.body.map((e, i) => ({
...e,
__related: {
index: i,
},
}));
}
);
return (
<LoadingWrapper {...{ isLoading, error, dataset: data }}>
<GenericSessionTable
reqUser={reqUser}
dataset={data!}
game={game}
playtype={playtype}
/>
</LoadingWrapper>
);
}
@@ -0,0 +1,298 @@
import { APIFetchV1 } from "util/api";
import { CreateChartMap, CreateScoreIDMap, CreateSongMap } from "util/data";
import { DelayedPageReload } from "util/misc";
import useSetSubheader from "components/layout/header/useSetSubheader";
import SessionOverview from "components/sessions/SessionOverview";
import SessionRaiseBreakdown from "components/sessions/SessionRaiseBreakdown";
import ScoreTable from "components/tables/scores/ScoreTable";
import ApiError from "components/util/ApiError";
import Divider from "components/util/Divider";
import FormInput from "components/util/FormInput";
import Icon from "components/util/Icon";
import Loading from "components/util/Loading";
import useApiQuery from "components/util/query/useApiQuery";
import SelectButton from "components/util/SelectButton";
import { UserContext } from "context/UserContext";
import React, { useContext, useMemo, useState } from "react";
import { Badge, Button, Col, Form, Modal, Row } from "react-bootstrap";
import { Redirect, useParams } from "react-router-dom";
import { GetGameConfig, PublicUserDocument, SessionDocument } from "tachi-common";
import { SessionReturns } from "types/api-returns";
import { GamePT, SetState } from "types/react";
type Props = { reqUser: PublicUserDocument } & GamePT;
export default function SpecificSessionPage({ reqUser, game, playtype }: Props) {
const { sessionID } = useParams<{ sessionID: string }>();
const { data, isLoading, error } = useApiQuery<SessionReturns>(`/sessions/${sessionID}`);
if (error) {
return <ApiError error={error} />;
}
if (isLoading || !data) {
return <Loading />;
}
if (
data.user.id !== reqUser.id ||
game !== data.session.game ||
playtype !== data.session.playtype
) {
return (
<Redirect
to={`/dashboard/users/${data.user.username}/games/${data.session.game}/${data.session.playtype}/sessions/${sessionID}`}
/>
);
}
return <SessionPage {...{ data, game, playtype, reqUser }} />;
}
function SessionPage({ data, game, playtype }: Props & { data: SessionReturns }) {
const { session, user, charts, scores, songs } = data;
const { user: loggedInUser } = useContext(UserContext);
useSetSubheader(
[
"Users",
user.username,
"Games",
GetGameConfig(game).name,
playtype,
"Sessions",
session.name,
],
[session.name, game, playtype, user],
`${user.username}: ${session.name}`
);
const [view, setView] = useState<"raises" | "overview" | "scores">("overview");
const songMap = CreateSongMap(songs);
const chartMap = CreateChartMap(charts);
const scoreMap = CreateScoreIDMap(scores);
const scoreDataset = useMemo(() => {
const d = [];
for (const scoreInfo of session.scoreInfo) {
const score = scoreMap.get(scoreInfo.scoreID);
if (!score) {
console.error(`No score for scoreID ${scoreInfo.scoreID}, but one was in session?`);
continue;
}
const chart = chartMap.get(score.chartID);
const song = songMap.get(score.songID);
if (!chart || !song) {
console.error(`No chart for ${score.chartID} (${score.songID})?`);
continue;
}
d.push({
...score,
__related: {
chart,
song,
index: 0,
user,
},
});
}
return d;
}, [data]);
const [highlight, setHighlight] = useState(session.highlight);
const [showEditModal, setShowEditModal] = useState(false);
return (
<Row className="justify-content-center">
<Col xs={12} className="text-center">
<h1>{session.name}</h1>
<h4 className="text-muted mb-4">{session.desc}</h4>
<Badge variant="secondary">
<span
style={{
lineHeight: "15px",
verticalAlign: "middle",
}}
>
<Icon type="eye" style={{ verticalAlign: "middle" }} /> Views:{" "}
{session.views}
</span>
</Badge>
{session.highlight && (
<Badge
style={{
lineHeight: "15px",
}}
variant="warning"
className="ml-2"
>
Highlight!
</Badge>
)}
<Divider className="mt-8 mb-4" />
{user.id === loggedInUser?.id && (
<>
<Button
variant="outline-info"
onClick={() => setShowEditModal(true)}
className="mr-4"
>
Edit Session
</Button>
{highlight ? (
<Button
variant="outline-danger"
onClick={async () => {
setHighlight(false);
session.highlight = false;
await APIFetchV1(
`/sessions/${session.sessionID}`,
{
method: "PATCH",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
highlight: false,
}),
},
true,
true
);
}}
>
Un-Highlight Session
</Button>
) : (
<Button
variant="outline-warning"
onClick={async () => {
setHighlight(true);
session.highlight = true;
await APIFetchV1(
`/sessions/${session.sessionID}`,
{
method: "PATCH",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
highlight: true,
}),
},
true,
true
);
}}
>
Highlight Session
</Button>
)}
<Divider />
</>
)}
<div className="btn-group">
<SelectButton value={view} setValue={setView} id="raises">
<Icon type="receipt" />
Raises
</SelectButton>
<SelectButton value={view} setValue={setView} id="overview">
<Icon type="chart-area" />
Overview
</SelectButton>
<SelectButton value={view} setValue={setView} id="scores">
<Icon type="table" />
All Scores
</SelectButton>
</div>
<Divider />
</Col>
{view === "raises" ? (
<SessionRaiseBreakdown sessionData={data} />
) : view === "overview" ? (
<SessionOverview scoreDataset={scoreDataset} sessionData={data} />
) : (
<Col xs={12}>
<ScoreTable
dataset={scoreDataset}
game={session.game}
playtype={session.playtype}
/>
</Col>
)}
{showEditModal && (
<EditModal session={session} show={showEditModal} setShow={setShowEditModal} />
)}
</Row>
);
}
function EditModal({
session,
show,
setShow,
}: {
session: SessionDocument;
show: boolean;
setShow: SetState<boolean>;
}) {
const [name, setName] = useState(session.name);
const [desc, setDesc] = useState(session.desc ?? "No Description...");
return (
<Modal show={show} onHide={() => setShow(false)}>
<Modal.Header closeButton>
<Modal.Title>Edit Comment</Modal.Title>
</Modal.Header>
<Modal.Body>
<Form
onSubmit={async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
const r = await APIFetchV1(
`/sessions/${session.sessionID}`,
{
method: "PATCH",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
name,
desc,
}),
},
true,
true
);
if (r.success) {
DelayedPageReload();
}
}}
>
<FormInput fieldName="Session Name" value={name} setValue={setName} />
<Divider />
<FormInput
fieldName="Session Description"
value={desc ?? ""}
setValue={setDesc as SetState<string>}
/>
<Divider />
<Button type="submit">Submit</Button>
</Form>
</Modal.Body>
</Modal>
);
}
@@ -0,0 +1,463 @@
import { APIFetchV1 } from "util/api";
import ClassBadge from "components/game/ClassBadge";
import useSetSubheader from "components/layout/header/useSetSubheader";
import Card from "components/layout/page/Card";
import UGPTStatContainer from "components/user/UGPTStatContainer";
import UGPTStatCreator from "components/user/UGPTStatCreator";
import ApiError from "components/util/ApiError";
import Divider from "components/util/Divider";
import Icon from "components/util/Icon";
import Loading from "components/util/Loading";
import Muted from "components/util/Muted";
import useApiQuery from "components/util/query/useApiQuery";
import SelectButton from "components/util/SelectButton";
import useQueryString from "components/util/useQueryString";
import { UGPTSettingsContext } from "context/UGPTSettingsContext";
import deepmerge from "deepmerge";
import { useFormik } from "formik";
import { TachiConfig } from "lib/config";
import React, { useContext, useEffect, useState } from "react";
import { Button, Col, Form, Row } from "react-bootstrap";
import {
FormatGame,
GetGameConfig,
GetGamePTConfig,
PublicUserDocument,
ShowcaseStatDetails,
TableDocument,
UGPTSettings,
} from "tachi-common";
import { GamePT, SetState } from "types/react";
type Props = { reqUser: PublicUserDocument } & GamePT;
export default function UGPTSettingsPage({ reqUser, game, playtype }: Props) {
const query = useQueryString();
const [page, setPage] = useState<"preferences" | "showcase">(
query.get("showcase") ? "showcase" : "preferences"
);
const gameConfig = GetGameConfig(game);
useSetSubheader(
["Users", reqUser.username, "Games", gameConfig.name, playtype, "Settings"],
[reqUser],
`${reqUser.username}'s ${FormatGame(game, playtype)} Settings`
);
const props = { reqUser, game, playtype };
return (
<Card header="Settings" className="col-12 offset-lg-2 col-lg-8">
<div className="row">
<div className="col-12 d-flex justify-content-center">
<div className="btn-group">
<SelectButton value={page} setValue={setPage} id="preferences">
<Icon type="cogs" />
Preferences
</SelectButton>
<SelectButton value={page} setValue={setPage} id="showcase">
<Icon type="bars" />
Showcase Stats
</SelectButton>
</div>
</div>
<div className="col-12">
<Divider className="mt-4 mb-4" />
{page === "preferences" ? (
<PreferencesForm {...props} />
) : (
<ShowcaseForm {...props} />
)}
</div>
</div>
</Card>
);
}
function PreferencesForm({ reqUser, game, playtype }: Props) {
const { settings, setSettings } = useContext(UGPTSettingsContext);
const gptConfig = GetGamePTConfig(game, playtype);
const formik = useFormik({
initialValues: {
preferredScoreAlg:
settings!.preferences.preferredScoreAlg || gptConfig.defaultScoreRatingAlg,
preferredProfileAlg:
settings!.preferences.preferredProfileAlg || gptConfig.defaultProfileRatingAlg,
preferredSessionAlg:
settings!.preferences.preferredSessionAlg || gptConfig.defaultSessionRatingAlg,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
gameSpecific: settings!.preferences.gameSpecific as any,
defaultTable: settings!.preferences.defaultTable,
scoreBucket: settings!.preferences.scoreBucket ?? gptConfig.scoreBucket,
},
onSubmit: async values => {
const rj = await APIFetchV1<PublicUserDocument>(
`/users/${reqUser.id}/games/${game}/${playtype}/settings`,
{
method: "PATCH",
body: JSON.stringify(values),
headers: {
"Content-Type": "application/json",
},
},
true,
true
);
if (rj.success) {
setSettings(deepmerge(settings as UGPTSettings, { preferences: values }));
}
},
});
const { data: tables, isLoading, error } = useApiQuery<TableDocument[]>(
`/games/${game}/${playtype}/tables?showInactive=true`
);
if (error) {
return <ApiError error={error} />;
}
if (isLoading || !tables) {
return <Loading />;
}
const displayableTables = tables.filter(
e => !e.inactive || settings?.preferences.defaultTable === e.tableID
);
return (
<Form onSubmit={formik.handleSubmit}>
<Form.Group>
<Form.Label>Preferred Score Algorithm</Form.Label>
<Form.Control
as="select"
id="preferredScoreAlg"
value={formik.values.preferredScoreAlg}
onChange={formik.handleChange}
>
{gptConfig.scoreRatingAlgs.map(e => (
<option key={e}>{e}</option>
))}
</Form.Control>
<Form.Text className="text-muted">
This configures the default rating algorithm to display for scores. This is used
for things like score tables and PB tables.
</Form.Text>
</Form.Group>
<Form.Group>
<Form.Label>Preferred Session Algorithm</Form.Label>
<Form.Control
as="select"
id="preferredSessionAlg"
value={formik.values.preferredSessionAlg}
onChange={formik.handleChange}
>
{gptConfig.sessionRatingAlgs.map(e => (
<option key={e}>{e}</option>
))}
</Form.Control>
<Form.Text className="text-muted">
This configures the default rating algorithm to display for sessions. This is
used for things like session tables.
</Form.Text>
</Form.Group>
<Form.Group>
<Form.Label>Preferred Profile Algorithm</Form.Label>
<Form.Control
as="select"
id="preferredProfileAlg"
value={formik.values.preferredProfileAlg}
onChange={formik.handleChange}
>
{gptConfig.profileRatingAlgs.map(e => (
<option key={e}>{e}</option>
))}
</Form.Control>
<Form.Text className="text-muted">
This configures the default rating algorithm to display for profiles. This is
used for things like leaderboards.
</Form.Text>
</Form.Group>
<Form.Group>
<Form.Label>Preferred Score Info</Form.Label>
<Form.Control
as="select"
id="scoreBucket"
value={formik.values.scoreBucket}
onChange={formik.handleChange}
>
<option value="grade">Score</option>
<option value="lamp">Lamp</option>
</Form.Control>
<Form.Text className="text-muted">
What should {TachiConfig.name} prefer to show you about scores?
<br />
Note: This will only affect defaults, such as what graph is shown in the folder
breakdown. You can still view all the same stats!
</Form.Text>
</Form.Group>
<Form.Group>
<Form.Label>Preferred Table</Form.Label>
<Form.Control
as="select"
id="defaultTable"
value={
formik.values.defaultTable ??
tables.find(x => x.default)?.tableID ??
displayableTables[0].tableID
}
onChange={formik.handleChange}
>
{displayableTables.map(table => (
<option key={table.tableID} value={table.tableID}>
{table.title}
</option>
))}
</Form.Control>
<Form.Text className="text-muted">
What folders would you like to see when you go to the folders page by default?
</Form.Text>
</Form.Group>
{game === "iidx" && (
<>
<Form.Group>
<Form.Check
type="checkbox"
id="gameSpecific.display2DXTra"
name="gameSpecific.display2DXTra"
checked={formik.values.gameSpecific.display2DXTra}
onChange={formik.handleChange}
label="Display 2DX-tra Charts"
/>
<Form.Text className="text-muted">
Display 2DX-tra charts on the song page.
</Form.Text>
</Form.Group>
<Form.Group>
<Form.Label>BPI Target</Form.Label>
<Form.Control
type="number"
id="gameSpecific.bpiTarget"
name="gameSpecific.bpiTarget"
value={formik.values.gameSpecific.bpiTarget}
min={0}
max={100}
step={5}
onChange={formik.handleChange}
/>
<Form.Text className="text-muted">
Set yourself a BPI target. {TachiConfig.name} will show how far away you
are from it in the UI!
</Form.Text>
</Form.Group>
</>
)}
{(game === "sdvx" || game === "usc") && (
<Form.Group>
<Form.Label>VF6 Target</Form.Label>
<Row>
<Col xs={12} lg={9}>
<Form.Control
type="number"
id="gameSpecific.vf6Target"
name="gameSpecific.vf6Target"
value={formik.values.gameSpecific.vf6Target}
min={0}
max={0.5}
step={0.001}
onChange={formik.handleChange}
/>
</Col>
<Col xs={12} lg={3} className="my-auto">
Expected Profile VF6{" "}
{((formik.values.gameSpecific.vf6Target ?? 0) * 50).toFixed(2)}
<br />
<ClassBadge
game="sdvx"
playtype="Single"
classSet="vfClass"
classValue={ToVolforceClass(
(formik.values.gameSpecific.vf6Target ?? 0) * 50
)}
/>
</Col>
</Row>
<Form.Text className="text-muted">
Set yourself a VF6 target. {TachiConfig.name} will show how far away you are
from it in the UI!
<br />
Set this to 0 to disable the target.
</Form.Text>
</Form.Group>
)}
<div className="row justify-content-center">
<Button type="submit" variant="success">
Save Changes
</Button>
</div>
</Form>
);
}
function ShowcaseForm({ reqUser, game, playtype }: Props) {
const { settings, setSettings } = useContext(UGPTSettingsContext);
const [stats, setStats] = useState(settings!.preferences.stats);
const [show, setShow] = useState(false);
const SaveChanges = async () => {
const r = await APIFetchV1<UGPTSettings>(
`/users/${reqUser.id}/games/${game}/${playtype}/showcase`,
{
method: "PUT",
body: JSON.stringify(stats),
headers: { "Content-Type": "application/json" },
},
true,
true
);
if (r.success) {
setSettings(r.body);
}
};
const [isFirstPaint, setIsFirstPaint] = useState(true);
useEffect(() => {
if (isFirstPaint) {
setIsFirstPaint(false);
} else {
SaveChanges();
}
}, [stats]);
return (
<div className="row">
{stats.length < 6 && (
<div className="col-12">
<div className="row justify-content-center align-items-center mt-4">
<Button variant="info" onClick={() => setShow(true)}>
Add Statistic
</Button>
</div>
</div>
)}
<RenderCurrentStats {...{ reqUser, game, playtype, stats, setStats }} />
<UGPTStatCreator
game={game}
playtype={playtype}
show={show}
setShow={setShow}
reqUser={reqUser}
onCreate={stat => {
setStats([...stats, stat]);
}}
/>
</div>
);
}
function RenderCurrentStats({
stats,
setStats,
reqUser,
game,
playtype,
}: {
stats: ShowcaseStatDetails[];
setStats: SetState<ShowcaseStatDetails[]>;
} & Props) {
function RemoveStatAtIndex(index: number) {
setStats(stats.filter((e, i) => i !== index));
}
if (stats.length === 0) {
return (
<div className="w-100 text-center">
<Muted>You have no stats set, Why not set some?</Muted>
</div>
);
}
return (
<>
{stats.map((e, i) => (
<div key={i} className="col-12 col-lg-6">
<UGPTStatContainer stat={e} reqUser={reqUser} game={game} playtype={playtype} />
<div className="row justify-content-center mt-4">
<Button variant="danger" onClick={() => RemoveStatAtIndex(i)}>
Delete
</Button>
</div>
</div>
))}
</>
);
}
// Straight up stolen from tachi-server.
enum SDVXVFClasses {
SIENNA_I,
SIENNA_II,
SIENNA_III,
SIENNA_IV,
COBALT_I,
COBALT_II,
COBALT_III,
COBALT_IV,
DANDELION_I,
DANDELION_II,
DANDELION_III,
DANDELION_IV,
CYAN_I,
CYAN_II,
CYAN_III,
CYAN_IV,
SCARLET_I,
SCARLET_II,
SCARLET_III,
SCARLET_IV,
CORAL_I,
CORAL_II,
CORAL_III,
CORAL_IV,
ARGENTO_I,
ARGENTO_II,
ARGENTO_III,
ARGENTO_IV,
ELDORA_I,
ELDORA_II,
ELDORA_III,
ELDORA_IV,
CRIMSON_I,
CRIMSON_II,
CRIMSON_III,
CRIMSON_IV,
IMPERIAL_I,
IMPERIAL_II,
IMPERIAL_III,
IMPERIAL_IV,
}
function ToVolforceClass(vf: number) {
if (vf >= 24) {
return SDVXVFClasses.IMPERIAL_IV;
} else if (vf >= 20) {
// imperial i -> iv has gaps of 1
return SDVXVFClasses.IMPERIAL_I + Math.floor(vf - 20);
} else if (vf >= 14) {
// cyan i -> crimson iv has gaps of 0.25
return SDVXVFClasses.CYAN_I + Math.floor(4 * (vf - 14));
} else if (vf >= 10) {
// cobalt i -> dandelion iv have gaps of 0.5
return SDVXVFClasses.COBALT_I + Math.floor(2 * (vf - 10));
}
return Math.floor(vf / 2.5);
}
@@ -0,0 +1,160 @@
import { APIFetchV1 } from "util/api";
import { Reverse } from "util/misc";
import DistributionTable from "components/game/folder/FolderDistributionTable";
import Card from "components/layout/page/Card";
import DebounceSearch from "components/util/DebounceSearch";
import Divider from "components/util/Divider";
import Icon from "components/util/Icon";
import LinkButton from "components/util/LinkButton";
import Loading from "components/util/Loading";
import useApiQuery from "components/util/query/useApiQuery";
import SelectButton from "components/util/SelectButton";
import { useBucket } from "components/util/useBucket";
import useUGPTBase from "components/util/useUGPTBase";
import { UserContext } from "context/UserContext";
import React, { useContext, useMemo, useState } from "react";
import { FolderDocument, GetGamePTConfig, PublicUserDocument } from "tachi-common";
import { FolderStatsInfo, UGPTFolderSearch } from "types/api-returns";
import { GamePT } from "types/react";
type Props = { reqUser: PublicUserDocument } & GamePT;
export default function FoldersSearch({ reqUser, game, playtype }: Props) {
const [search, setSearch] = useState("");
const params = useMemo(() => new URLSearchParams({ search }), [search]);
const { data, isLoading, error } = useApiQuery<UGPTFolderSearch>(
`/users/${reqUser.id}/games/${game}/${playtype}/folders?${params.toString()}`
);
let body = <></>;
if (error) {
body = <>{error.description}</>;
} else if (isLoading || !data) {
body = <Loading />;
} else {
const statMap = new Map();
for (const stat of data.stats) {
statMap.set(stat.folderID, stat);
}
body = (
<>
{data.folders.length === 0 && (
<div className="col-12 text-center">Found nothin'.</div>
)}
{data.folders.map(e => (
<FolderInfoComponent
key={e.folderID}
folder={e}
folderStats={statMap.get(e.folderID)!}
game={game}
playtype={playtype}
reqUser={reqUser}
/>
))}
</>
);
}
return (
<>
<div className="col-12">
<DebounceSearch
className="form-control-lg"
setSearch={setSearch}
placeholder="Search all Folders..."
/>
</div>
<div className="col-12 mt-8">
<div className="row">{search !== "" && body}</div>
</div>
</>
);
}
export function FolderInfoComponent({
reqUser,
game,
playtype,
folderStats,
folder,
}: Props & { folder: FolderDocument; folderStats: FolderStatsInfo }) {
const gptConfig = GetGamePTConfig(game, playtype);
const scoreBucket = useBucket(game, playtype);
const [elements, setElements] = useState<"grade" | "lamp">(scoreBucket);
const base = useUGPTBase({ reqUser, game, playtype });
const dataset = useMemo(() => {
if (elements === "grade") {
return (
<DistributionTable
colours={gptConfig.gradeColours}
keys={Reverse(gptConfig.grades)}
values={folderStats.grades}
max={folderStats.chartCount}
/>
);
}
return (
<DistributionTable
colours={gptConfig.lampColours}
keys={Reverse(gptConfig.lamps)}
values={folderStats.lamps}
max={folderStats.chartCount}
/>
);
}, [elements]);
const { user } = useContext(UserContext);
return (
<div className="col-12 col-lg-6 mb-4">
<Card
header={folder.title}
footer={
<div className="w-100 d-flex justify-content-center">
<LinkButton
onClick={() => {
if (user?.id === reqUser.id) {
APIFetchV1(
`/users/${reqUser.id}/games/${game}/${playtype}/folders/${folder.folderID}/viewed`,
{ method: "POST" }
);
}
}}
to={`${base}/folders/${folder.folderID}`}
className="btn-outline-info"
>
View
</LinkButton>
</div>
}
>
<div className="row text-center">
<div className="col-12">
<div className="btn-group">
<SelectButton value={elements} setValue={setElements} id="grade">
<Icon type="sort-alpha-up" />
Grades
</SelectButton>
<SelectButton value={elements} setValue={setElements} id="lamp">
<Icon type="lightbulb" />
Lamps
</SelectButton>
</div>
<Divider />
{dataset}
</div>
</div>
</Card>
</div>
);
}
@@ -0,0 +1,416 @@
import { APIFetchV1 } from "util/api";
import { DEFAULT_BAR_PROPS } from "util/charts";
import { ChangeOpacity } from "util/color-opacity";
import { Reverse } from "util/misc";
import { NumericSOV, StrSOV } from "util/sorts";
import { ResponsiveBar } from "@nivo/bar";
import { BarChartTooltip } from "components/charts/ChartTooltip";
import Card from "components/layout/page/Card";
import TachiTable from "components/tables/components/TachiTable";
import ApiError from "components/util/ApiError";
import Divider from "components/util/Divider";
import Icon from "components/util/Icon";
import LinkButton from "components/util/LinkButton";
import Loading from "components/util/Loading";
import Muted from "components/util/Muted";
import useApiQuery from "components/util/query/useApiQuery";
import SelectButton from "components/util/SelectButton";
import { useBucket } from "components/util/useBucket";
import useUGPTSettings from "components/util/useUGPTSettings";
import { UserContext } from "context/UserContext";
import React, { useContext, useEffect, useMemo, useState } from "react";
import { Form, InputGroup } from "react-bootstrap";
import {
FolderDocument,
Game,
GamePTConfig,
GetGamePTConfig,
PublicUserDocument,
TableDocument,
} from "tachi-common";
import { FolderStatsInfo, UGPTTableReturns } from "types/api-returns";
import { Playtype } from "types/tachi";
interface Props {
reqUser: PublicUserDocument;
game: Game;
playtype: Playtype;
}
export default function FolderTablePage({ reqUser, game, playtype }: Props) {
const { data, isLoading, error } = useApiQuery<TableDocument[]>(
`/games/${game}/${playtype}/tables?showInactive=true`
);
const { settings } = useUGPTSettings();
const [tableID, setTableID] = useState("");
const [tableMap, setTableMap] = useState(new Map());
const table = useMemo(() => tableMap.get(tableID), [tableID, tableMap]);
useEffect(() => {
if (data) {
const newMap = new Map();
let foundDefault = false;
for (const table of data) {
newMap.set(table.tableID, table);
// If the user has a preference
if (settings?.preferences.defaultTable) {
if (settings.preferences.defaultTable === table.tableID) {
setTableID(table.tableID);
foundDefault = true;
}
// Otherwise just use the provided default.
}
if (table.default && !foundDefault) {
setTableID(table.tableID);
foundDefault = true;
}
}
setTableMap(newMap);
if (!foundDefault) {
console.warn(`No default table returned? Falling back to the first thing we saw.`);
setTableID(data[0].tableID);
}
}
}, [data]);
if (error) {
return <ApiError error={error} />;
}
if (isLoading || !data) {
return <Loading />;
}
const displayableTables = data.filter(
e =>
!e.inactive ||
(settings?.preferences.defaultTable && settings.preferences.defaultTable === e.tableID)
);
return (
<>
<InputGroup>
<InputGroup.Prepend>
<InputGroup.Text>Table</InputGroup.Text>
</InputGroup.Prepend>
<Form.Control
as="select"
size="lg"
value={tableID}
onChange={e => setTableID(e.target.value)}
>
{displayableTables.map(e => (
<option key={e.tableID} value={e.tableID}>
{e.title}
</option>
))}
</Form.Control>
</InputGroup>
<Divider />
{table && <TableFolderViewer {...{ reqUser, game, playtype, table }} />}
</>
);
}
interface UGPTFolderStats {
folder: FolderDocument;
stats: FolderStatsInfo;
}
function TableFolderViewer({ reqUser, game, playtype, table }: Props & { table: TableDocument }) {
const { data, isLoading, error } = useApiQuery<UGPTTableReturns>(
`/users/${reqUser.id}/games/${game}/${playtype}/tables/${table.tableID}`
);
const [dataMap, setDataMap] = useState<Map<string, UGPTFolderStats>>(new Map());
const [hasLoadedFolderMap, setHasLoadedFolderMap] = useState(false);
useEffect(() => {
if (data) {
const statMap = new Map();
for (const stat of data.stats) {
statMap.set(stat.folderID, stat);
}
const newMap = new Map();
for (const folder of data.folders) {
const stats = statMap.get(folder.folderID)!;
newMap.set(folder.folderID, { folder, stats });
}
setDataMap(newMap);
setHasLoadedFolderMap(true);
}
}, [data]);
if (error) {
return <ApiError error={error} />;
}
if (isLoading || !data || !hasLoadedFolderMap) {
return <Loading />;
}
return (
<>
<TableBarChart table={table} dataMap={dataMap} />
<Divider />
<TableFolderTable
reqUser={reqUser}
game={game}
playtype={playtype}
table={table}
dataMap={dataMap}
/>
</>
);
}
function TableFolderTable({
table,
dataMap,
reqUser,
game,
playtype,
}: {
table: TableDocument;
dataMap: Map<string, UGPTFolderStats>;
} & Props) {
const dataset = useMemo(() => {
const arr = [];
for (const folder of table.folders) {
const data = dataMap.get(folder);
if (!data) {
continue;
}
arr.push(data);
}
return arr;
}, [dataMap, table]);
const { user } = useContext(UserContext);
return (
<TachiTable
dataset={dataset}
pageLen={25}
headers={[
["Folder", "Folder", StrSOV(x => x.folder.title)],
["Chart Count", "Charts", NumericSOV(x => x.stats.chartCount)],
["Buttons", "Buttons"],
]}
entryName="Folders"
searchFunctions={{
name: x => x.folder.title,
title: x => x.folder.title,
charts: x => x.stats.chartCount,
}}
rowFunction={data => (
<tr>
<td>{data.folder.title}</td>
<td>{data.stats.chartCount}</td>
<td>
<LinkButton
to={`/dashboard/users/${reqUser.username}/games/${game}/${playtype}/folders/${data.folder.folderID}`}
onClick={() => {
if (user?.id === reqUser.id) {
APIFetchV1(
`/users/${reqUser.id}/games/${game}/${playtype}/folders/${data.folder.folderID}/viewed`,
{ method: "POST" }
);
}
}}
className="btn-info"
>
View Breakdown
</LinkButton>
</td>
</tr>
)}
/>
);
}
function TableBarChart({
table,
dataMap,
}: {
table: TableDocument;
dataMap: Map<string, UGPTFolderStats>;
}) {
const gptConfig = GetGamePTConfig(table.game, table.playtype);
const bucket = useBucket(table.game, table.playtype);
const [mode, setMode] = useState<"grades" | "lamps">(bucket === "grade" ? "grades" : "lamps");
const colours = useMemo(() => {
if (mode === "grades") {
return gptConfig.gradeColours;
}
return gptConfig.lampColours;
}, [mode]);
const dataset = useMemo(() => {
const arr = [];
for (const folderID of table.folders) {
const data = dataMap.get(folderID)!;
if (!data) {
continue;
}
const realData = {
__chartCount: data.stats.chartCount,
};
// this is a series of stupid hacks and i dont really care.
for (const key in data.stats[mode]) {
// @ts-expect-error stupid key stuff
realData[key] = (100 * data.stats[mode][key]) / data.stats.chartCount;
// @ts-expect-error stupid key stuff
realData[`${key}-count`] = data.stats[mode][key];
}
arr.push({
folder:
data.folder.title.length > 10
? `${data.folder.title.slice(0, 10)}...`
: data.folder.title,
...realData,
});
}
return arr.reverse();
}, [dataMap, mode]);
return (
<Card header="Overview">
<div className="row">
<div className="col-12 d-flex justify-content-center">
<div className="btn-group">
<SelectButton value={mode} setValue={setMode} id="grades">
<Icon type="sort-alpha-up" />
Grades
</SelectButton>
<SelectButton value={mode} setValue={setMode} id="lamps">
<Icon type="lightbulb" />
Lamps
</SelectButton>
</div>
</div>
<div className="col-12">
<Divider />
</div>
<div className="col-12">
<div
style={{
height: dataset.length * 40,
}}
>
{/* This hack is necessary because otherwise
nivo gets caught in a render loop
and dies */}
{mode === "grades" ? (
<OverviewBarChart
key="grade"
gptConfig={gptConfig}
mode={mode}
dataset={dataset}
colours={colours}
/>
) : (
<OverviewBarChart
key="lamp"
gptConfig={gptConfig}
mode={mode}
dataset={dataset}
colours={colours}
/>
)}
</div>
</div>
</div>
</Card>
);
}
function OverviewBarChart({
gptConfig,
mode,
dataset,
colours,
}: {
gptConfig: GamePTConfig;
mode: "grades" | "lamps";
dataset: any;
colours: any;
}) {
const longestTitle = useMemo(
() =>
Math.min(
dataset.map((e: any) => e.folder).sort(NumericSOV<string>(x => x.length, true))[0]
?.length ?? 0,
10
),
[dataset]
);
return (
<ResponsiveBar
indexBy="folder"
tooltip={d => (
<BarChartTooltip
point={d}
renderFn={d => (
<div className="w-100 text-center">
{d.label}
<br />
{d.formattedValue}
<br />
<Muted>
({(d.data as any)[`${d.id}-count`] ?? 0} /{" "}
{/* @ts-expect-error cba */}
{d.data.__chartCount})
</Muted>
</div>
)}
/>
)}
keys={Reverse(gptConfig[mode])}
colors={k => ChangeOpacity(colours[k.id], 0.5)}
borderColor={k => colours[k.data.id]}
borderWidth={1}
data={dataset}
minValue={0}
maxValue={100}
valueFormat={v => `${v.toFixed(2)}%`}
margin={{ left: 10 + longestTitle * 8, bottom: 50, top: 20, right: 20 }}
layout="horizontal"
axisBottom={{
tickSize: 5,
tickPadding: 5,
tickRotation: 0,
legendPosition: "middle",
legendOffset: 32,
format: v => `${v}%`,
}}
enableGridX
enableGridY={false}
{...DEFAULT_BAR_PROPS}
labelSkipWidth={40}
labelSkipHeight={undefined}
/>
);
}
@@ -0,0 +1,84 @@
import useSetSubheader from "components/layout/header/useSetSubheader";
import Divider from "components/util/Divider";
import Icon from "components/util/Icon";
import SelectLinkButton from "components/util/SelectLinkButton";
import useUGPTBase from "components/util/useUGPTBase";
import { UserContext } from "context/UserContext";
import { UserGameStatsContext } from "context/UserGameStatsContext";
import React, { useContext } from "react";
import { Route, Switch } from "react-router-dom";
import { FormatGame, GetGameConfig, PublicUserDocument } from "tachi-common";
import { GamePT } from "types/react";
import FolderSelectPage from "./FolderSelectPage";
import FolderTablePage from "./FolderTablePage";
import RecentFoldersPage from "./RecentFoldersPage";
import SpecificFolderPage from "./SpecificFolderPage";
type Props = { reqUser: PublicUserDocument } & GamePT;
export default function FoldersMainPage({ reqUser, game, playtype }: Props) {
const gameConfig = GetGameConfig(game);
const { user } = useContext(UserContext);
const { ugs } = useContext(UserGameStatsContext);
useSetSubheader(
["Users", reqUser.username, "Games", gameConfig.name, playtype, "Folders"],
[reqUser, game, playtype],
`${reqUser.username}'s ${FormatGame(game, playtype)} Folders`
);
const base = useUGPTBase({ reqUser, game, playtype });
return (
<div className="row">
<div className="col-12 text-center">
<div className="btn-group">
{user && ugs?.find(x => x.game === game && x.playtype === playtype) && (
<SelectLinkButton to={`${base}/folders/recent`}>
<Icon type="clock" />
{user.id === reqUser.id
? "Recent Folders"
: "Your Recently Viewed Folders"}
</SelectLinkButton>
)}
<SelectLinkButton to={`${base}/folders`}>
<Icon type="table" />
Table Overview
</SelectLinkButton>
<SelectLinkButton to={`${base}/folders/search`}>
<Icon type="search" />
Folder Select
</SelectLinkButton>
</div>
<Divider />
</div>
<div className="col-12">
<Switch>
<Route exact path="/dashboard/users/:userID/games/:game/:playtype/folders">
<FolderTablePage {...{ reqUser, game, playtype }} />
</Route>
<Route
exact
path="/dashboard/users/:userID/games/:game/:playtype/folders/search"
>
<FolderSelectPage {...{ reqUser, game, playtype }} />
</Route>
<Route
exact
path="/dashboard/users/:userID/games/:game/:playtype/folders/recent"
>
<RecentFoldersPage {...{ reqUser, game, playtype }} />
</Route>
<Route
exact
path="/dashboard/users/:userID/games/:game/:playtype/folders/:folderID"
>
<SpecificFolderPage {...{ reqUser, game, playtype }} />
</Route>
</Switch>
</div>
</div>
);
}
@@ -0,0 +1,74 @@
import ApiError from "components/util/ApiError";
import Loading from "components/util/Loading";
import useApiQuery from "components/util/query/useApiQuery";
import { UserContext } from "context/UserContext";
import React, { useContext } from "react";
import { Row } from "react-bootstrap";
import { Link } from "react-router-dom";
import { FolderDocument, PublicUserDocument, RecentlyViewedFolderDocument } from "tachi-common";
import { FolderStatsInfo } from "types/api-returns";
import { GamePT } from "types/react";
import { FolderInfoComponent } from "./FolderSelectPage";
export default function RecentFoldersPage({
reqUser,
game,
playtype,
}: { reqUser: PublicUserDocument } & GamePT) {
const { user } = useContext(UserContext);
if (!user) {
return <>Hey, you're not logged in. How did you get here!</>;
}
const { data, isLoading, error } = useApiQuery<{
folders: FolderDocument[];
views: RecentlyViewedFolderDocument[];
stats: FolderStatsInfo[];
}>(`/users/${user.id}/games/${game}/${playtype}/folders/recent`);
if (error) {
return <ApiError error={error} />;
}
if (isLoading || !data) {
return <Loading />;
}
if (data.folders.length === 0) {
return (
<div className="text-center">
Looks like you've not recently interacted with any folders.{" "}
<Link to={`/dashboard/users/${user.username}/games/${game}/${playtype}/folders`}>
Go do that!
</Link>
</div>
);
}
const dataset = [];
for (const recent of data.views) {
dataset.push({
view: recent,
// Is it really O(n^2) if the input is capped at 4?
folder: data.folders.find(x => x.folderID === recent.folderID)!,
stats: data.stats.find(x => x.folderID === recent.folderID)!,
});
}
return (
<Row>
{dataset.map(e => (
<FolderInfoComponent
key={e.folder.folderID}
reqUser={reqUser}
folder={e.folder}
folderStats={e.stats}
game={game}
playtype={playtype}
/>
))}
</Row>
);
}

Some files were not shown because too many files have changed in this diff Show More