mirror of
https://github.com/zkldi/Tachi.git
synced 2026-10-03 04:18:16 +03:00
Merge branch 'client-develop'
This commit is contained in:
Vendored
+10
@@ -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']
|
||||
Vendored
+28
@@ -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
|
||||
@@ -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
|
||||
@@ -0,0 +1,3 @@
|
||||
[submodule "src/_assets/metronic-scss"]
|
||||
path = src/_assets/metronic-scss
|
||||
url = https://github.com/tng-dev/tachi-client-scss
|
||||
@@ -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
@@ -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/>.
|
||||
@@ -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
|
||||
```
|
||||
@@ -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;
|
||||
@@ -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';
|
||||
},
|
||||
};
|
||||
@@ -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};`;
|
||||
},
|
||||
};
|
||||
@@ -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();
|
||||
@@ -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;
|
||||
@@ -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
|
||||
);
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
@@ -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());
|
||||
},
|
||||
};
|
||||
};
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
Generated
+12382
File diff suppressed because it is too large
Load Diff
@@ -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>
|
||||
@@ -0,0 +1 @@
|
||||
# We have no special rules. This is probably OK!
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
@@ -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
@@ -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.
@@ -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=""
|
||||
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=""
|
||||
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=""
|
||||
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=""
|
||||
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=""
|
||||
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=""
|
||||
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=""
|
||||
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=""
|
||||
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=""
|
||||
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=""
|
||||
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=""
|
||||
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=""
|
||||
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=""
|
||||
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=""
|
||||
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=""
|
||||
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=""
|
||||
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=""
|
||||
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=""
|
||||
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=""
|
||||
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=""
|
||||
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=""
|
||||
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=""
|
||||
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=""
|
||||
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="" 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=""
|
||||
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=""
|
||||
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=""
|
||||
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=""
|
||||
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=""
|
||||
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=""
|
||||
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=""
|
||||
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=""
|
||||
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=""
|
||||
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=""
|
||||
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=""
|
||||
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=""
|
||||
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=""
|
||||
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=""
|
||||
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=""
|
||||
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=""
|
||||
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=""
|
||||
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=""
|
||||
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=""
|
||||
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=""
|
||||
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=""
|
||||
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=""
|
||||
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=""
|
||||
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=""
|
||||
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=""
|
||||
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=""
|
||||
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=""
|
||||
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=""
|
||||
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=""
|
||||
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=""
|
||||
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=""
|
||||
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=""
|
||||
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=""
|
||||
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=""
|
||||
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=""
|
||||
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=""
|
||||
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=""
|
||||
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=""
|
||||
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=""
|
||||
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=""
|
||||
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=""
|
||||
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=""
|
||||
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=""
|
||||
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=""
|
||||
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
+1
Submodule client/src/_assets/metronic-scss added at 3924275dec
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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} />;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
+160
@@ -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>
|
||||
);
|
||||
}
|
||||
+416
@@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
+84
@@ -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>
|
||||
);
|
||||
}
|
||||
+74
@@ -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
Reference in New Issue
Block a user