Merge remote-tracking branch 'upstream/main'
This commit is contained in:
2
packages/api-client/.prettierignore
Normal file
2
packages/api-client/.prettierignore
Normal file
@@ -0,0 +1,2 @@
|
||||
# Autogenerated files
|
||||
dist
|
||||
@@ -1,674 +1,165 @@
|
||||
GNU GENERAL PUBLIC LICENSE
|
||||
GNU LESSER GENERAL PUBLIC LICENSE
|
||||
Version 3, 29 June 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 General Public License is a free, copyleft license for
|
||||
software and other kinds of works.
|
||||
|
||||
The licenses for most software and other practical works are designed
|
||||
to take away your freedom to share and change the works. By contrast,
|
||||
the GNU General Public License is 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. We, the Free Software Foundation, use the
|
||||
GNU General Public License for most of our software; it applies also to
|
||||
any other work released this way by its authors. You can apply it to
|
||||
your programs, too.
|
||||
|
||||
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.
|
||||
|
||||
To protect your rights, we need to prevent others from denying you
|
||||
these rights or asking you to surrender the rights. Therefore, you have
|
||||
certain responsibilities if you distribute copies of the software, or if
|
||||
you modify it: responsibilities to respect the freedom of others.
|
||||
|
||||
For example, if you distribute copies of such a program, whether
|
||||
gratis or for a fee, you must pass on to the recipients the same
|
||||
freedoms that you received. You must make sure that they, too, receive
|
||||
or can get the source code. And you must show them these terms so they
|
||||
know their rights.
|
||||
|
||||
Developers that use the GNU GPL protect your rights with two steps:
|
||||
(1) assert copyright on the software, and (2) offer you this License
|
||||
giving you legal permission to copy, distribute and/or modify it.
|
||||
|
||||
For the developers' and authors' protection, the GPL clearly explains
|
||||
that there is no warranty for this free software. For both users' and
|
||||
authors' sake, the GPL requires that modified versions be marked as
|
||||
changed, so that their problems will not be attributed erroneously to
|
||||
authors of previous versions.
|
||||
|
||||
Some devices are designed to deny users access to install or run
|
||||
modified versions of the software inside them, although the manufacturer
|
||||
can do so. This is fundamentally incompatible with the aim of
|
||||
protecting users' freedom to change the software. The systematic
|
||||
pattern of such abuse occurs in the area of products for individuals to
|
||||
use, which is precisely where it is most unacceptable. Therefore, we
|
||||
have designed this version of the GPL to prohibit the practice for those
|
||||
products. If such problems arise substantially in other domains, we
|
||||
stand ready to extend this provision to those domains in future versions
|
||||
of the GPL, as needed to protect the freedom of users.
|
||||
|
||||
Finally, every program is threatened constantly by software patents.
|
||||
States should not allow patents to restrict development and use of
|
||||
software on general-purpose computers, but in those that do, we wish to
|
||||
avoid the special danger that patents applied to a free program could
|
||||
make it effectively proprietary. To prevent this, the GPL assures that
|
||||
patents cannot be used to render the program non-free.
|
||||
|
||||
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 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. Use with the GNU Affero General Public License.
|
||||
|
||||
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 Affero 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 special requirements of the GNU Affero General Public License,
|
||||
section 13, concerning interaction through a network will apply to the
|
||||
combination as such.
|
||||
|
||||
14. Revised Versions of this License.
|
||||
|
||||
The Free Software Foundation may publish revised and/or new versions of
|
||||
the GNU 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 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 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 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 General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
Also add information on how to contact you by electronic and paper mail.
|
||||
|
||||
If the program does terminal interaction, make it output a short
|
||||
notice like this when it starts in an interactive mode:
|
||||
|
||||
<program> Copyright (C) <year> <name of author>
|
||||
This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
|
||||
This is free software, and you are welcome to redistribute it
|
||||
under certain conditions; type `show c' for details.
|
||||
|
||||
The hypothetical commands `show w' and `show c' should show the appropriate
|
||||
parts of the General Public License. Of course, your program's commands
|
||||
might be different; for a GUI interface, you would use an "about box".
|
||||
|
||||
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 GPL, see
|
||||
<https://www.gnu.org/licenses/>.
|
||||
|
||||
The GNU General Public License does not permit incorporating your program
|
||||
into proprietary programs. If your program is a subroutine library, you
|
||||
may consider it more useful to permit linking proprietary applications with
|
||||
the library. If this is what you want to do, use the GNU Lesser General
|
||||
Public License instead of this License. But first, please read
|
||||
<https://www.gnu.org/licenses/why-not-lgpl.html>.
|
||||
|
||||
This version of the GNU Lesser General Public License incorporates
|
||||
the terms and conditions of version 3 of the GNU General Public
|
||||
License, supplemented by the additional permissions listed below.
|
||||
|
||||
0. Additional Definitions.
|
||||
|
||||
As used herein, "this License" refers to version 3 of the GNU Lesser
|
||||
General Public License, and the "GNU GPL" refers to version 3 of the GNU
|
||||
General Public License.
|
||||
|
||||
"The Library" refers to a covered work governed by this License,
|
||||
other than an Application or a Combined Work as defined below.
|
||||
|
||||
An "Application" is any work that makes use of an interface provided
|
||||
by the Library, but which is not otherwise based on the Library.
|
||||
Defining a subclass of a class defined by the Library is deemed a mode
|
||||
of using an interface provided by the Library.
|
||||
|
||||
A "Combined Work" is a work produced by combining or linking an
|
||||
Application with the Library. The particular version of the Library
|
||||
with which the Combined Work was made is also called the "Linked
|
||||
Version".
|
||||
|
||||
The "Minimal Corresponding Source" for a Combined Work means the
|
||||
Corresponding Source for the Combined Work, excluding any source code
|
||||
for portions of the Combined Work that, considered in isolation, are
|
||||
based on the Application, and not on the Linked Version.
|
||||
|
||||
The "Corresponding Application Code" for a Combined Work means the
|
||||
object code and/or source code for the Application, including any data
|
||||
and utility programs needed for reproducing the Combined Work from the
|
||||
Application, but excluding the System Libraries of the Combined Work.
|
||||
|
||||
1. Exception to Section 3 of the GNU GPL.
|
||||
|
||||
You may convey a covered work under sections 3 and 4 of this License
|
||||
without being bound by section 3 of the GNU GPL.
|
||||
|
||||
2. Conveying Modified Versions.
|
||||
|
||||
If you modify a copy of the Library, and, in your modifications, a
|
||||
facility refers to a function or data to be supplied by an Application
|
||||
that uses the facility (other than as an argument passed when the
|
||||
facility is invoked), then you may convey a copy of the modified
|
||||
version:
|
||||
|
||||
a) under this License, provided that you make a good faith effort to
|
||||
ensure that, in the event an Application does not supply the
|
||||
function or data, the facility still operates, and performs
|
||||
whatever part of its purpose remains meaningful, or
|
||||
|
||||
b) under the GNU GPL, with none of the additional permissions of
|
||||
this License applicable to that copy.
|
||||
|
||||
3. Object Code Incorporating Material from Library Header Files.
|
||||
|
||||
The object code form of an Application may incorporate material from
|
||||
a header file that is part of the Library. You may convey such object
|
||||
code under terms of your choice, provided that, if the incorporated
|
||||
material is not limited to numerical parameters, data structure
|
||||
layouts and accessors, or small macros, inline functions and templates
|
||||
(ten or fewer lines in length), you do both of the following:
|
||||
|
||||
a) Give prominent notice with each copy of the object code that the
|
||||
Library is used in it and that the Library and its use are
|
||||
covered by this License.
|
||||
|
||||
b) Accompany the object code with a copy of the GNU GPL and this license
|
||||
document.
|
||||
|
||||
4. Combined Works.
|
||||
|
||||
You may convey a Combined Work under terms of your choice that,
|
||||
taken together, effectively do not restrict modification of the
|
||||
portions of the Library contained in the Combined Work and reverse
|
||||
engineering for debugging such modifications, if you also do each of
|
||||
the following:
|
||||
|
||||
a) Give prominent notice with each copy of the Combined Work that
|
||||
the Library is used in it and that the Library and its use are
|
||||
covered by this License.
|
||||
|
||||
b) Accompany the Combined Work with a copy of the GNU GPL and this license
|
||||
document.
|
||||
|
||||
c) For a Combined Work that displays copyright notices during
|
||||
execution, include the copyright notice for the Library among
|
||||
these notices, as well as a reference directing the user to the
|
||||
copies of the GNU GPL and this license document.
|
||||
|
||||
d) Do one of the following:
|
||||
|
||||
0) Convey the Minimal Corresponding Source under the terms of this
|
||||
License, and the Corresponding Application Code in a form
|
||||
suitable for, and under terms that permit, the user to
|
||||
recombine or relink the Application with a modified version of
|
||||
the Linked Version to produce a modified Combined Work, in the
|
||||
manner specified by section 6 of the GNU GPL for conveying
|
||||
Corresponding Source.
|
||||
|
||||
1) Use a suitable shared library mechanism for linking with the
|
||||
Library. A suitable mechanism is one that (a) uses at run time
|
||||
a copy of the Library already present on the user's computer
|
||||
system, and (b) will operate properly with a modified version
|
||||
of the Library that is interface-compatible with the Linked
|
||||
Version.
|
||||
|
||||
e) Provide Installation Information, but only if you would otherwise
|
||||
be required to provide such information under section 6 of the
|
||||
GNU GPL, and only to the extent that such information is
|
||||
necessary to install and execute a modified version of the
|
||||
Combined Work produced by recombining or relinking the
|
||||
Application with a modified version of the Linked Version. (If
|
||||
you use option 4d0, the Installation Information must accompany
|
||||
the Minimal Corresponding Source and Corresponding Application
|
||||
Code. If you use option 4d1, you must provide the Installation
|
||||
Information in the manner specified by section 6 of the GNU GPL
|
||||
for conveying Corresponding Source.)
|
||||
|
||||
5. Combined Libraries.
|
||||
|
||||
You may place library facilities that are a work based on the
|
||||
Library side by side in a single library together with other library
|
||||
facilities that are not Applications and are not covered by this
|
||||
License, and convey such a combined library under terms of your
|
||||
choice, if you do both of the following:
|
||||
|
||||
a) Accompany the combined library with a copy of the same work based
|
||||
on the Library, uncombined with any other library facilities,
|
||||
conveyed under the terms of this License.
|
||||
|
||||
b) Give prominent notice with the combined library that part of it
|
||||
is a work based on the Library, and explaining where to find the
|
||||
accompanying uncombined form of the same work.
|
||||
|
||||
6. Revised Versions of the GNU Lesser General Public License.
|
||||
|
||||
The Free Software Foundation may publish revised and/or new versions
|
||||
of the GNU Lesser 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
|
||||
Library as you received it specifies that a certain numbered version
|
||||
of the GNU Lesser General Public License "or any later version"
|
||||
applies to it, you have the option of following the terms and
|
||||
conditions either of that published version or of any later version
|
||||
published by the Free Software Foundation. If the Library as you
|
||||
received it does not specify a version number of the GNU Lesser
|
||||
General Public License, you may choose any version of the GNU Lesser
|
||||
General Public License ever published by the Free Software Foundation.
|
||||
|
||||
If the Library as you received it specifies that a proxy can decide
|
||||
whether future versions of the GNU Lesser General Public License shall
|
||||
apply, that proxy's public statement of acceptance of any version is
|
||||
permanent authorization for you to choose that version for the
|
||||
Library.
|
||||
|
||||
@@ -1,62 +1,63 @@
|
||||

|
||||
|
||||
# @modrinth/api-client
|
||||
|
||||
[](https://www.typescriptlang.org/)
|
||||
[](LICENSE)
|
||||
[](LICENSE)
|
||||
|
||||
A flexible, type-safe API client for Modrinth's APIs (Labrinth, Kyros & Archon). Works across Node.js, browsers, Nuxt, and Tauri with a feature system for authentication, retries, circuit breaking and other custom processing of requests and responses.
|
||||
Platform-agnostic TypeScript client for Modrinth's API across Node.js, browsers, Nuxt, and Tauri.
|
||||
|
||||
**⚠️ We use this internally to power modrinth.com, Modrinth App, and Modrinth Hosting frontends. It may break without any notice, but you are welcome to use it.**
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
pnpm add @modrinth/api-client
|
||||
# or
|
||||
npm install @modrinth/api-client
|
||||
# or
|
||||
yarn add @modrinth/api-client
|
||||
```
|
||||
|
||||
Tauri apps also need the optional peer dependency:
|
||||
|
||||
```bash
|
||||
pnpm add @modrinth/api-client @tauri-apps/plugin-http
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
### Plain JavaScript/Node.js
|
||||
### Generic Node.js or Browser Client
|
||||
|
||||
```typescript
|
||||
import { GenericModrinthClient, AuthFeature, ProjectV2 } from '@modrinth/api-client'
|
||||
```ts
|
||||
import { AuthFeature, GenericModrinthClient, type Labrinth } from '@modrinth/api-client'
|
||||
|
||||
const client = new GenericModrinthClient({
|
||||
userAgent: 'my-app/1.0.0',
|
||||
features: [new AuthFeature({ token: 'mrp_...' })],
|
||||
features: [new AuthFeature({ token: process.env.MODRINTH_TOKEN })],
|
||||
})
|
||||
|
||||
// Explicitly make a request using client.request
|
||||
const project: any = await client.request('/project/sodium', { api: 'labrinth', version: 2 })
|
||||
const project: Labrinth.Projects.v2.Project = await client.labrinth.projects_v2.get('sodium')
|
||||
const members = await client.labrinth.projects_v3.getMembers(project.id)
|
||||
```
|
||||
|
||||
// Example for archon (Modrinth Hosting)
|
||||
const servers = await client.request('/servers?limit=10', { api: 'archon', version: 0 })
|
||||
You can still make direct requests through the same platform layer:
|
||||
|
||||
// Or use the provided wrappers for better type support.
|
||||
const project: ProjectV2 = await client.projects_v2.get('sodium')
|
||||
```ts
|
||||
const project = await client.request<Labrinth.Projects.v2.Project>('/project/sodium', {
|
||||
api: 'labrinth',
|
||||
version: 2,
|
||||
})
|
||||
```
|
||||
|
||||
### Nuxt
|
||||
|
||||
```typescript
|
||||
import { NuxtModrinthClient, AuthFeature, NuxtCircuitBreakerStorage } from '@modrinth/api-client'
|
||||
```ts
|
||||
import { AuthFeature, CircuitBreakerFeature, NuxtCircuitBreakerStorage, NuxtModrinthClient } from '@modrinth/api-client'
|
||||
|
||||
// Alternatively you can create a singleton of the client and provide it via DI.
|
||||
export const useModrinthClient = () => {
|
||||
export const useModrinthClient = async () => {
|
||||
const config = useRuntimeConfig()
|
||||
|
||||
// example using the useAuth composable from our frontend, replace this with whatever you're using to store auth token
|
||||
const auth = await useAuth()
|
||||
|
||||
return new NuxtModrinthClient({
|
||||
userAgent: 'my-nuxt-app/1.0.0', // leave blank to use default user agent from fetch function
|
||||
userAgent: 'my-nuxt-app/1.0.0',
|
||||
rateLimitKey: import.meta.server ? config.rateLimitKey : undefined,
|
||||
features: [
|
||||
new AuthFeature({
|
||||
token: async () => auth.value.token,
|
||||
token: process.env.MODRINTH_TOKEN,
|
||||
}),
|
||||
new CircuitBreakerFeature({
|
||||
storage: new NuxtCircuitBreakerStorage(),
|
||||
@@ -64,116 +65,113 @@ export const useModrinthClient = () => {
|
||||
],
|
||||
})
|
||||
}
|
||||
|
||||
const client = useModrinthClient()
|
||||
const project = await client.request('/project/sodium', { api: 'labrinth', version: 2 })
|
||||
```
|
||||
|
||||
### Tauri
|
||||
|
||||
```typescript
|
||||
import { TauriModrinthClient, AuthFeature } from '@modrinth/api-client'
|
||||
```ts
|
||||
import { getVersion } from '@tauri-apps/api/app'
|
||||
import { AuthFeature, TauriModrinthClient } from '@modrinth/api-client'
|
||||
|
||||
const version = await getVersion()
|
||||
const client = new TauriModrinthClient({
|
||||
userAgent: `modrinth/theseus/${version} (support@modrinth.com)`,
|
||||
features: [new AuthFeature({ token: 'mrp_...' })],
|
||||
userAgent: async () => `modrinth/theseus/${await getVersion()} (support@modrinth.com)`,
|
||||
features: [new AuthFeature({ token: process.env.MODRINTH_TOKEN })],
|
||||
})
|
||||
|
||||
const project = await client.request('/project/sodium', { api: 'labrinth', version: 2 })
|
||||
const project = await client.labrinth.projects_v2.get('sodium')
|
||||
```
|
||||
|
||||
### Overriding Base URLs
|
||||
## API Modules
|
||||
|
||||
By default, the client uses the production base URLs:
|
||||
Modules are available as nested properties on the client:
|
||||
|
||||
- `labrinthBaseUrl`: `https://api.modrinth.com/` (Labrinth API)
|
||||
- `archonBaseUrl`: `https://archon.modrinth.com/` (Archon/Servers API)
|
||||
```ts
|
||||
client.labrinth.projects_v2
|
||||
client.labrinth.projects_v3
|
||||
client.labrinth.versions_v3
|
||||
```
|
||||
|
||||
You can override these for staging environments or custom instances:
|
||||
Types are exported from the package root:
|
||||
|
||||
```typescript
|
||||
```ts
|
||||
import type { Labrinth } from '@modrinth/api-client'
|
||||
|
||||
const project: Labrinth.Projects.v3.Project = await client.labrinth.projects_v3.get('sodium')
|
||||
```
|
||||
|
||||
## Modrinth Hosting API Modules
|
||||
|
||||
- These modules are internal to Modrinth and are only supported inside the Modrinth Hosting panel in Modrinth App and on modrinth.com. They should not be expected to work in third-party clients today. We are discussing how to safely expose access to your own server through these APIs in the future.
|
||||
|
||||
## Base URLs
|
||||
|
||||
By default, the client uses Modrinth production services:
|
||||
|
||||
- `labrinthBaseUrl`: `https://api.modrinth.com`
|
||||
|
||||
Override them for staging or custom deployments:
|
||||
|
||||
```ts
|
||||
const client = new GenericModrinthClient({
|
||||
userAgent: 'my-app/1.0.0',
|
||||
labrinthBaseUrl: 'https://staging-api.modrinth.com/',
|
||||
archonBaseUrl: 'https://staging-archon.modrinth.com/',
|
||||
features: [new AuthFeature({ token: 'mrp_...' })],
|
||||
labrinthBaseUrl: 'https://staging-api.modrinth.com',
|
||||
})
|
||||
|
||||
// Now requests will use the staging URLs
|
||||
await client.request('/project/sodium', { api: 'labrinth', version: 2 })
|
||||
// -> https://staging-api.modrinth.com/v2/project/sodium
|
||||
```
|
||||
|
||||
You can also use custom URLs directly in requests:
|
||||
External APIs can be targeted per request by passing a full URL as `api` and disabling auth:
|
||||
|
||||
```typescript
|
||||
// One-off custom URL (useful for Kyros nodes or dynamic endpoints)
|
||||
await client.request('/some-endpoint', {
|
||||
api: 'https://eu-lim16.nodes.modrinth.com/',
|
||||
version: 0,
|
||||
```ts
|
||||
await client.request('/endpoint', {
|
||||
api: 'https://example.com',
|
||||
version: 1,
|
||||
skipAuth: true,
|
||||
})
|
||||
```
|
||||
|
||||
## Features
|
||||
|
||||
### Authentication
|
||||
Features wrap requests before they reach the platform implementation:
|
||||
|
||||
Supports both static and dynamic tokens:
|
||||
```ts
|
||||
import { AuthFeature, CircuitBreakerFeature, RetryFeature } from '@modrinth/api-client'
|
||||
|
||||
```typescript
|
||||
// Static token
|
||||
new AuthFeature({ token: 'mrp_...' })
|
||||
|
||||
// Dynamic token (e.g., from auth state)
|
||||
const auth = await useAuth()
|
||||
new AuthFeature({
|
||||
token: async () => auth.value.token,
|
||||
const client = new GenericModrinthClient({
|
||||
features: [new AuthFeature({ token: async () => process.env.MODRINTH_TOKEN }), new RetryFeature({ maxAttempts: 3, backoffStrategy: 'exponential' }), new CircuitBreakerFeature({ maxFailures: 3, resetTimeout: 30_000 })],
|
||||
})
|
||||
```
|
||||
|
||||
### Retry
|
||||
Built-in features include authentication, node auth, retries, circuit breaking, panel version headers, and verbose logging.
|
||||
|
||||
Automatically retries failed requests with configurable backoff:
|
||||
## Uploads
|
||||
|
||||
```typescript
|
||||
new RetryFeature({
|
||||
maxAttempts: 3,
|
||||
backoffStrategy: 'exponential',
|
||||
initialDelay: 1000,
|
||||
maxDelay: 15000,
|
||||
Upload endpoints return an `UploadHandle<T>` with progress and cancellation support:
|
||||
|
||||
```ts
|
||||
const upload = client.kyros.files_v0.uploadFile(path, file)
|
||||
|
||||
upload.onProgress(({ progress }) => {
|
||||
console.log(Math.round(progress * 100))
|
||||
})
|
||||
|
||||
await upload.promise
|
||||
```
|
||||
|
||||
### Circuit Breaker
|
||||
Uploads use `XMLHttpRequest` for progress tracking and are only available in browser-capable contexts. `NuxtModrinthClient.upload()` throws during SSR.
|
||||
|
||||
Prevents cascade failures by opening circuits after repeated failures:
|
||||
## Third-Party API Typings
|
||||
|
||||
```typescript
|
||||
new CircuitBreakerFeature({
|
||||
maxFailures: 3,
|
||||
resetTimeout: 30000,
|
||||
failureStatusCodes: [500, 502, 503, 504],
|
||||
})
|
||||
- This package also includes some third-party API modules and typings used by Modrinth internals. They are not part of the stable public API surface and should be used at your own risk.
|
||||
|
||||
## Development
|
||||
|
||||
```bash
|
||||
pnpm --filter @modrinth/api-client build
|
||||
pnpm --filter @modrinth/api-client lint
|
||||
# or pnpm prepr:frontend:lib in turborepo root.
|
||||
```
|
||||
|
||||
## Documentation
|
||||
|
||||
This package is **self-documenting** through TypeScript types and JSDoc comments. Use your IDE's IntelliSense to explore available methods, classes, and configuration options.
|
||||
|
||||
For Modrinth API endpoints and routes, refer to the [Modrinth API Documentation](https://docs.modrinth.com).
|
||||
|
||||
## Contributing
|
||||
|
||||
- Modules are available in the `modules/<api>/...` folders.
|
||||
- When a module has different versions available, you should do it like so: `modules/labrinth/projects/v2.ts` etc.
|
||||
- Types for a module's requests should be made available in `modules/<api>/module/types.ts` or `.../types/v2.ts`.
|
||||
- You should expose these types in the `modules/types.ts` file.
|
||||
- When creating a new module, add it to the `modules/index.ts`'s `MODULE_REGISTRY` for it to become available in the api client class.
|
||||
|
||||
Dont forget to run `pnpm fix` before committing.
|
||||
When adding a module, add it to `src/modules/index.ts` so it is included in the typed client structure.
|
||||
|
||||
## License
|
||||
|
||||
Licensed under GPL-3.0 - see the [LICENSE](LICENSE) file for details.
|
||||
Licensed under LGPL-3.0. See [LICENSE](LICENSE).
|
||||
|
||||
@@ -1,2 +1,7 @@
|
||||
import config from '@modrinth/tooling-config/eslint/nuxt.mjs'
|
||||
export default config
|
||||
|
||||
export default config.append([
|
||||
{
|
||||
ignores: ['dist/'],
|
||||
},
|
||||
])
|
||||
|
||||
@@ -1,9 +1,41 @@
|
||||
{
|
||||
"name": "@modrinth/api-client",
|
||||
"version": "0.1.0",
|
||||
"version": "0.0.0",
|
||||
"description": "An API client for Modrinth's API for use in nuxt, tauri and plain node/browser environments.",
|
||||
"main": "./src/index.ts",
|
||||
"license": "LGPL-3.0-only",
|
||||
"type": "module",
|
||||
"main": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"import": "./dist/index.js",
|
||||
"default": "./dist/index.js"
|
||||
},
|
||||
"./package.json": "./package.json"
|
||||
},
|
||||
"files": [
|
||||
"dist",
|
||||
"README.md",
|
||||
"LICENSE"
|
||||
],
|
||||
"sideEffects": false,
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/modrinth/code.git",
|
||||
"directory": "packages/api-client"
|
||||
},
|
||||
"bugs": {
|
||||
"url": "https://github.com/modrinth/code/issues"
|
||||
},
|
||||
"homepage": "https://github.com/modrinth/code/tree/main/packages/api-client#readme",
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
},
|
||||
"scripts": {
|
||||
"clean": "node --eval \"require('node:fs').rmSync('dist', { recursive: true, force: true })\"",
|
||||
"build": "pnpm run clean && esbuild src/index.ts --bundle --format=esm --platform=neutral --target=es2020 --minify --legal-comments=none --outfile=dist/index.js --external:ofetch --external:mitt --external:@tauri-apps/plugin-http && tsc -p tsconfig.build.json",
|
||||
"prepare": "pnpm run build",
|
||||
"lint": "eslint . && prettier --check .",
|
||||
"fix": "eslint . --fix && prettier --write ."
|
||||
},
|
||||
@@ -12,7 +44,10 @@
|
||||
"ofetch": "^1.4.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@modrinth/tooling-config": "workspace:*"
|
||||
"@modrinth/tooling-config": "workspace:*",
|
||||
"@tauri-apps/plugin-http": "^2.0.0",
|
||||
"esbuild": "0.27.2",
|
||||
"typescript": "^5.9.3"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tauri-apps/plugin-http": "^2.0.0"
|
||||
|
||||
@@ -125,13 +125,15 @@ export abstract class AbstractModrinthClient extends AbstractUploadClient {
|
||||
|
||||
const url = this.buildUrl(path, baseUrl, options.version)
|
||||
|
||||
const defaultHeaders = await this.buildDefaultHeaders()
|
||||
|
||||
// Merge options with defaults
|
||||
const mergedOptions: RequestOptions = {
|
||||
method: 'GET',
|
||||
timeout: this.config.timeout,
|
||||
...options,
|
||||
headers: {
|
||||
...this.buildDefaultHeaders(),
|
||||
...defaultHeaders,
|
||||
...options.headers,
|
||||
},
|
||||
}
|
||||
@@ -306,19 +308,25 @@ export abstract class AbstractModrinthClient extends AbstractUploadClient {
|
||||
* Subclasses can override this to add platform-specific headers
|
||||
* (e.g., Nuxt rate limit key)
|
||||
*/
|
||||
protected buildDefaultHeaders(): Record<string, string> {
|
||||
protected async buildDefaultHeaders(): Promise<Record<string, string>> {
|
||||
const headers: Record<string, string> = {
|
||||
'Content-Type': 'application/json',
|
||||
...this.config.headers,
|
||||
}
|
||||
|
||||
if (this.config.userAgent) {
|
||||
headers['User-Agent'] = this.config.userAgent
|
||||
const userAgent = await this.resolveUserAgent()
|
||||
if (userAgent) {
|
||||
headers['User-Agent'] = userAgent
|
||||
}
|
||||
|
||||
return headers
|
||||
}
|
||||
|
||||
private async resolveUserAgent(): Promise<string | undefined> {
|
||||
const userAgent = this.config.userAgent
|
||||
return typeof userAgent === 'function' ? await userAgent() : userAgent
|
||||
}
|
||||
|
||||
protected attachArchonSentryCaptureHeader(options: RequestOptions): void {
|
||||
if (options.api !== 'archon' || !options.headers || !this.shouldCaptureArchonRequests()) {
|
||||
return
|
||||
@@ -404,7 +412,7 @@ export abstract class AbstractModrinthClient extends AbstractUploadClient {
|
||||
* @example
|
||||
* ```typescript
|
||||
* const client = new GenericModrinthClient()
|
||||
* client.addFeature(new AuthFeature({ token: 'mrp_...' }))
|
||||
* client.addFeature(new AuthFeature({ token: async () => getOAuthToken() }))
|
||||
* client.addFeature(new RetryFeature({ maxAttempts: 3 }))
|
||||
* ```
|
||||
*/
|
||||
|
||||
@@ -33,14 +33,8 @@ export interface AuthConfig extends FeatureConfig {
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* // Static token
|
||||
* const auth = new AuthFeature({
|
||||
* token: 'mrp_...'
|
||||
* })
|
||||
*
|
||||
* // Dynamic token (e.g., from auth state)
|
||||
* const auth = new AuthFeature({
|
||||
* token: async () => await getAuthToken()
|
||||
* token: async () => process.env.MODRINTH_TOKEN
|
||||
* })
|
||||
* ```
|
||||
*/
|
||||
|
||||
@@ -49,6 +49,20 @@ export class ArchonContentV1Module extends AbstractModule {
|
||||
})
|
||||
}
|
||||
|
||||
/** POST /v1/:server_id/worlds/:world_id/addons/install-many */
|
||||
public async addAddons(
|
||||
serverId: string,
|
||||
worldId: string,
|
||||
addons: Archon.Content.v1.AddAddonRequest[],
|
||||
): Promise<void> {
|
||||
await this.client.request<void>(`/servers/${serverId}/worlds/${worldId}/addons/install-many`, {
|
||||
api: 'archon',
|
||||
version: 1,
|
||||
method: 'POST',
|
||||
body: addons satisfies Archon.Content.v1.AddAddonsRequest,
|
||||
})
|
||||
}
|
||||
|
||||
/** POST /v1/:server_id/worlds/:world_id/addons/delete */
|
||||
public async deleteAddon(
|
||||
serverId: string,
|
||||
|
||||
@@ -51,6 +51,8 @@ export namespace Archon {
|
||||
kind?: AddonKind
|
||||
}
|
||||
|
||||
export type AddAddonsRequest = AddAddonRequest[]
|
||||
|
||||
export type RemoveAddonRequest = {
|
||||
kind: AddonKind
|
||||
filename: string
|
||||
|
||||
@@ -17,6 +17,7 @@ import { LabrinthAuthInternalModule } from './labrinth/auth/internal'
|
||||
import { LabrinthAuthV2Module } from './labrinth/auth/v2'
|
||||
import { LabrinthBillingInternalModule } from './labrinth/billing/internal'
|
||||
import { LabrinthCollectionsModule } from './labrinth/collections'
|
||||
import { LabrinthExternalProjectsInternalModule } from './labrinth/external-projects/internal'
|
||||
import { LabrinthGlobalsInternalModule } from './labrinth/globals/internal'
|
||||
import { LabrinthLimitsV3Module } from './labrinth/limits/v3'
|
||||
import { LabrinthModerationInternalModule } from './labrinth/moderation/internal'
|
||||
@@ -75,6 +76,7 @@ export const MODULE_REGISTRY = {
|
||||
labrinth_auth_v2: LabrinthAuthV2Module,
|
||||
labrinth_billing_internal: LabrinthBillingInternalModule,
|
||||
labrinth_collections: LabrinthCollectionsModule,
|
||||
labrinth_external_projects_internal: LabrinthExternalProjectsInternalModule,
|
||||
labrinth_globals_internal: LabrinthGlobalsInternalModule,
|
||||
labrinth_moderation_internal: LabrinthModerationInternalModule,
|
||||
labrinth_notifications_v2: LabrinthNotificationsV2Module,
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
import { AbstractModule } from '../../../core/abstract-module'
|
||||
import type { Labrinth } from '../types'
|
||||
|
||||
export class LabrinthExternalProjectsInternalModule extends AbstractModule {
|
||||
public getModuleID(): string {
|
||||
return 'labrinth_external_projects_internal'
|
||||
}
|
||||
|
||||
public async search(
|
||||
data: Labrinth.ExternalProjects.Internal.SearchRequest,
|
||||
): Promise<Labrinth.ExternalProjects.Internal.ExternalProject[]> {
|
||||
return this.client.request<Labrinth.ExternalProjects.Internal.ExternalProject[]>(
|
||||
'/moderation/external-license/search',
|
||||
{
|
||||
api: 'labrinth',
|
||||
version: 'internal',
|
||||
method: 'POST',
|
||||
body: data,
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
public async getBySha1(
|
||||
sha1: string,
|
||||
): Promise<Labrinth.ExternalProjects.Internal.ExternalProject> {
|
||||
return this.client.request<Labrinth.ExternalProjects.Internal.ExternalProject>(
|
||||
`/moderation/external-license/by-sha1/${sha1}`,
|
||||
{
|
||||
api: 'labrinth',
|
||||
version: 'internal',
|
||||
method: 'GET',
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
public async update(
|
||||
id: number,
|
||||
data: Labrinth.ExternalProjects.Internal.UpdateLicenseRequest,
|
||||
): Promise<Labrinth.ExternalProjects.Internal.ExternalProject> {
|
||||
return this.client.request<Labrinth.ExternalProjects.Internal.ExternalProject>(
|
||||
`/moderation/external-license/${id}`,
|
||||
{
|
||||
api: 'labrinth',
|
||||
version: 'internal',
|
||||
method: 'PATCH',
|
||||
body: data,
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@ export * from './auth/internal'
|
||||
export * from './auth/v2'
|
||||
export * from './billing/internal'
|
||||
export * from './collections'
|
||||
export * from './external-projects/internal'
|
||||
export * from './globals/internal'
|
||||
export * from './limits/v3'
|
||||
export * from './moderation/internal'
|
||||
|
||||
@@ -26,6 +26,23 @@ export class LabrinthProjectsV2Module extends AbstractModule {
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Check that a project slug or ID exists and return its canonical project ID.
|
||||
*
|
||||
* @param idOrSlug - Project ID or slug (e.g. `sodium` or `AANobbMI`)
|
||||
*/
|
||||
public async check(idOrSlug: string): Promise<Labrinth.Projects.v2.ProjectCheckResponse> {
|
||||
const encoded = encodeURIComponent(idOrSlug)
|
||||
return this.client.request<Labrinth.Projects.v2.ProjectCheckResponse>(
|
||||
`/project/${encoded}/check`,
|
||||
{
|
||||
api: 'labrinth',
|
||||
version: 2,
|
||||
method: 'GET',
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get multiple projects by IDs
|
||||
*
|
||||
|
||||
@@ -468,6 +468,10 @@ export namespace Labrinth {
|
||||
monetization_status: MonetizationStatus
|
||||
}
|
||||
|
||||
export type ProjectCheckResponse = {
|
||||
id: string
|
||||
}
|
||||
|
||||
export type SearchResultHit = {
|
||||
project_id: string
|
||||
project_type: ProjectType
|
||||
@@ -876,6 +880,7 @@ export namespace Labrinth {
|
||||
include_changelog?: boolean
|
||||
limit?: number
|
||||
offset?: number
|
||||
apiVersion?: 2 | 3
|
||||
}
|
||||
|
||||
export type VersionChannel = 'release' | 'beta' | 'alpha'
|
||||
@@ -1462,6 +1467,52 @@ export namespace Labrinth {
|
||||
}
|
||||
}
|
||||
|
||||
export namespace ExternalProjects {
|
||||
export namespace Internal {
|
||||
export type ExternalLicenseStatus =
|
||||
| 'yes'
|
||||
| 'with-attribution-and-source'
|
||||
| 'with-attribution'
|
||||
| 'no'
|
||||
| 'permanent-no'
|
||||
| 'unidentified'
|
||||
|
||||
export type LinkedFile = {
|
||||
name: string | null
|
||||
sha1: string
|
||||
}
|
||||
|
||||
export type ExternalProject = {
|
||||
id: number
|
||||
title: string | null
|
||||
status: ExternalLicenseStatus
|
||||
link: string | null
|
||||
exceptions: string | null
|
||||
proof: string | null
|
||||
flame_project_id: number | null
|
||||
inserted_at: string | null
|
||||
inserted_by: number | null
|
||||
updated_at: string | null
|
||||
updated_by: number | null
|
||||
linked_files: LinkedFile[]
|
||||
}
|
||||
|
||||
export type SearchRequest = {
|
||||
title?: string
|
||||
flame_id?: number
|
||||
}
|
||||
|
||||
export type UpdateLicenseRequest = {
|
||||
title?: string
|
||||
status: ExternalLicenseStatus
|
||||
link?: string
|
||||
exceptions?: string
|
||||
proof?: string
|
||||
flame_project_id?: number
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export namespace TechReview {
|
||||
export namespace Internal {
|
||||
export type SearchProjectsRequest = {
|
||||
|
||||
@@ -9,14 +9,14 @@ import { XHRUploadClient } from './xhr-upload-client'
|
||||
/**
|
||||
* Generic platform client using ofetch
|
||||
*
|
||||
* This client works in any JavaScript environment (Node.js, browser, workers).
|
||||
* This client works in any JavaScript environment (Node.js, browser, workers, etc).
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const client = new GenericModrinthClient({
|
||||
* userAgent: 'my-app/1.0.0',
|
||||
* features: [
|
||||
* new AuthFeature({ token: 'mrp_...' }),
|
||||
* new AuthFeature({ token: async () => getOAuthToken() }),
|
||||
* new RetryFeature({ maxAttempts: 3 })
|
||||
* ]
|
||||
* })
|
||||
|
||||
@@ -66,13 +66,17 @@ export interface NuxtClientConfig extends ClientConfig {
|
||||
* ```typescript
|
||||
* // In a Nuxt composable
|
||||
* const config = useRuntimeConfig()
|
||||
* const auth = await useAuth()
|
||||
*
|
||||
* const client = new NuxtModrinthClient({
|
||||
* userAgent: 'my-nuxt-app/1.0.0',
|
||||
* rateLimitKey: import.meta.server ? config.rateLimitKey : undefined,
|
||||
* features: [
|
||||
* new AuthFeature({ token: () => auth.value.token })
|
||||
* new AuthFeature({
|
||||
* token: async () => getOAuthToken()
|
||||
* }),
|
||||
* new CircuitBreakerFeature({
|
||||
* storage: new NuxtCircuitBreakerStorage()
|
||||
* })
|
||||
* ]
|
||||
* })
|
||||
*
|
||||
@@ -171,9 +175,9 @@ export class NuxtModrinthClient extends XHRUploadClient {
|
||||
return super.normalizeError(error)
|
||||
}
|
||||
|
||||
protected buildDefaultHeaders(): Record<string, string> {
|
||||
protected async buildDefaultHeaders(): Promise<Record<string, string>> {
|
||||
const headers: Record<string, string> = {
|
||||
...super.buildDefaultHeaders(),
|
||||
...(await super.buildDefaultHeaders()),
|
||||
}
|
||||
|
||||
// Use the resolved key (populated by resolveRateLimitKey in request())
|
||||
|
||||
@@ -27,11 +27,10 @@ interface HttpError extends Error {
|
||||
* ```typescript
|
||||
* import { getVersion } from '@tauri-apps/api/app'
|
||||
*
|
||||
* const version = await getVersion()
|
||||
* const client = new TauriModrinthClient({
|
||||
* userAgent: `modrinth/theseus/${version} (support@modrinth.com)`,
|
||||
* userAgent: async () => `modrinth/theseus/${await getVersion()} (support@modrinth.com)`,
|
||||
* features: [
|
||||
* new AuthFeature({ token: 'mrp_...' })
|
||||
* new AuthFeature({ token: async () => getOAuthToken() })
|
||||
* ]
|
||||
* })
|
||||
*
|
||||
|
||||
@@ -27,50 +27,57 @@ export abstract class XHRUploadClient extends AbstractModrinthClient {
|
||||
|
||||
const url = this.buildUrl(path, baseUrl, options.version)
|
||||
|
||||
// For FormData uploads, don't set Content-Type (let browser set multipart boundary)
|
||||
// For file uploads, use application/octet-stream
|
||||
const isFormData = 'formData' in options && options.formData instanceof FormData
|
||||
const baseHeaders = this.buildDefaultHeaders()
|
||||
// Remove Content-Type for FormData so browser can set multipart/form-data with boundary
|
||||
if (isFormData) {
|
||||
delete baseHeaders['Content-Type']
|
||||
} else {
|
||||
baseHeaders['Content-Type'] = 'application/octet-stream'
|
||||
}
|
||||
|
||||
const mergedOptions: UploadRequestOptions = {
|
||||
retry: false, // default: don't retry uploads
|
||||
...options,
|
||||
headers: {
|
||||
...baseHeaders,
|
||||
...options.headers,
|
||||
},
|
||||
}
|
||||
this.attachArchonSentryCaptureHeader(mergedOptions)
|
||||
|
||||
const context = this.buildUploadContext(url, path, mergedOptions)
|
||||
|
||||
const progressCallbacks: Array<(p: UploadProgress) => void> = []
|
||||
if (mergedOptions.onProgress) {
|
||||
progressCallbacks.push(mergedOptions.onProgress)
|
||||
if (options.onProgress) {
|
||||
progressCallbacks.push(options.onProgress)
|
||||
}
|
||||
const abortController = new AbortController()
|
||||
|
||||
if (mergedOptions.signal) {
|
||||
mergedOptions.signal.addEventListener('abort', () => abortController.abort())
|
||||
if (options.signal) {
|
||||
options.signal.addEventListener('abort', () => abortController.abort())
|
||||
}
|
||||
|
||||
let context: RequestContext | undefined
|
||||
const handle: UploadHandle<T> = {
|
||||
promise: this.executeUploadFeatureChain<T>(context, progressCallbacks, abortController)
|
||||
.then(async (result) => {
|
||||
await this.config.hooks?.onResponse?.(result, context)
|
||||
return result
|
||||
})
|
||||
.catch(async (error) => {
|
||||
const apiError = this.normalizeError(error, context)
|
||||
promise: (async () => {
|
||||
const isFormData = 'formData' in options && options.formData instanceof FormData
|
||||
const baseHeaders = await this.buildDefaultHeaders()
|
||||
if (isFormData) {
|
||||
delete baseHeaders['Content-Type']
|
||||
} else {
|
||||
baseHeaders['Content-Type'] = 'application/octet-stream'
|
||||
}
|
||||
|
||||
const mergedOptions: UploadRequestOptions = {
|
||||
retry: false,
|
||||
...options,
|
||||
headers: {
|
||||
...baseHeaders,
|
||||
...options.headers,
|
||||
},
|
||||
}
|
||||
this.attachArchonSentryCaptureHeader(mergedOptions)
|
||||
|
||||
const uploadContext = this.buildUploadContext(url, path, mergedOptions)
|
||||
context = uploadContext
|
||||
if (abortController.signal.aborted) {
|
||||
throw new ModrinthApiError('Upload cancelled')
|
||||
}
|
||||
|
||||
const result = await this.executeUploadFeatureChain<T>(
|
||||
uploadContext,
|
||||
progressCallbacks,
|
||||
abortController,
|
||||
)
|
||||
await this.config.hooks?.onResponse?.(result, uploadContext)
|
||||
return result
|
||||
})().catch(async (error) => {
|
||||
const apiError = this.normalizeError(error, context)
|
||||
if (context) {
|
||||
await this.config.hooks?.onError?.(apiError, context)
|
||||
throw apiError
|
||||
}),
|
||||
}
|
||||
throw apiError
|
||||
}),
|
||||
onProgress: (callback) => {
|
||||
progressCallbacks.push(callback)
|
||||
return handle
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import type { AbstractFeature } from '../core/abstract-feature'
|
||||
import type { RequestContext } from './request'
|
||||
|
||||
export type MaybePromise<T> = T | Promise<T>
|
||||
export type UserAgentProvider = string | (() => MaybePromise<string | undefined>)
|
||||
|
||||
/**
|
||||
* Request lifecycle hooks
|
||||
*/
|
||||
@@ -26,11 +29,11 @@ export type RequestHooks = {
|
||||
*/
|
||||
export interface ClientConfig {
|
||||
/**
|
||||
* User agent string for requests
|
||||
* User agent string or provider for requests
|
||||
* Should identify your application (e.g., 'my-app/1.0.0')
|
||||
* If not provided, the platform's default user agent will be used
|
||||
*/
|
||||
userAgent?: string
|
||||
userAgent?: UserAgentProvider
|
||||
|
||||
/**
|
||||
* Base URL for Labrinth API (main Modrinth API)
|
||||
|
||||
12
packages/api-client/tsconfig.build.json
Normal file
12
packages/api-client/tsconfig.build.json
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"declaration": true,
|
||||
"emitDeclarationOnly": true,
|
||||
"noEmit": false,
|
||||
"outDir": "dist",
|
||||
"rootDir": "src"
|
||||
},
|
||||
"include": ["src/**/*.ts"],
|
||||
"exclude": ["dist", "node_modules"]
|
||||
}
|
||||
@@ -218,9 +218,8 @@ pub async fn generate_pack_from_version_id(
|
||||
icon_url: Option<String>,
|
||||
profile_path: String,
|
||||
|
||||
// Existing loading bar. Unlike when existing_loading_bar is used, this one is pre-initialized with PackFileDownload
|
||||
// For example, you might use this if multiple packs are being downloaded at once and you want to use the same loading bar
|
||||
initialized_loading_bar: Option<LoadingBarId>,
|
||||
reason: DownloadReason,
|
||||
) -> crate::Result<CreatePack> {
|
||||
let state = State::get().await?;
|
||||
let has_icon_url = icon_url.is_some();
|
||||
@@ -301,7 +300,7 @@ pub async fn generate_pack_from_version_id(
|
||||
})?;
|
||||
|
||||
let download_meta = DownloadMeta {
|
||||
reason: DownloadReason::Modpack,
|
||||
reason,
|
||||
game_version: profile.game_version.clone(),
|
||||
loader: profile.loader.as_str().to_string(),
|
||||
};
|
||||
|
||||
@@ -48,6 +48,7 @@ pub async fn install_zipped_mrpack(
|
||||
icon_url,
|
||||
profile_path.clone(),
|
||||
None,
|
||||
DownloadReason::Modpack,
|
||||
)
|
||||
.await?
|
||||
}
|
||||
@@ -57,7 +58,12 @@ pub async fn install_zipped_mrpack(
|
||||
};
|
||||
|
||||
// Install pack files, and if it fails, fail safely by removing the profile
|
||||
let result = install_zipped_mrpack_files(create_pack, false).await;
|
||||
let result = install_zipped_mrpack_files(
|
||||
create_pack,
|
||||
false,
|
||||
DownloadReason::Modpack,
|
||||
)
|
||||
.await;
|
||||
|
||||
match result {
|
||||
Ok(profile) => Ok(profile),
|
||||
@@ -74,6 +80,7 @@ pub async fn install_zipped_mrpack(
|
||||
pub async fn install_zipped_mrpack_files(
|
||||
create_pack: CreatePack,
|
||||
ignore_lock: bool,
|
||||
reason: DownloadReason,
|
||||
) -> crate::Result<String> {
|
||||
let state = &State::get().await?;
|
||||
|
||||
@@ -221,7 +228,7 @@ pub async fn install_zipped_mrpack_files(
|
||||
})?;
|
||||
|
||||
let download_meta = DownloadMeta {
|
||||
reason: DownloadReason::Modpack,
|
||||
reason,
|
||||
game_version: profile.game_version.clone(),
|
||||
loader: profile.loader.as_str().to_string(),
|
||||
};
|
||||
|
||||
@@ -461,7 +461,7 @@ pub async fn update_project(
|
||||
let mut path = Profile::add_project_version(
|
||||
profile_path,
|
||||
update_version,
|
||||
fetch::DownloadReason::Standalone,
|
||||
fetch::DownloadReason::Update,
|
||||
&state.pool,
|
||||
&state.fetch_semaphore,
|
||||
&state.io_semaphore,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
use crate::state::CacheBehaviour;
|
||||
use crate::util::fetch::DownloadReason;
|
||||
use crate::{
|
||||
LoadingBarType,
|
||||
event::{
|
||||
@@ -162,7 +163,8 @@ async fn replace_managed_modrinth(
|
||||
profile.name.clone(),
|
||||
None,
|
||||
profile_path.to_string(),
|
||||
Some(shared_loading_bar.clone())
|
||||
Some(shared_loading_bar.clone()),
|
||||
DownloadReason::Update,
|
||||
),
|
||||
generate_pack_from_version_id(
|
||||
project_id.clone(),
|
||||
@@ -170,7 +172,8 @@ async fn replace_managed_modrinth(
|
||||
profile.name.clone(),
|
||||
None,
|
||||
profile_path.to_string(),
|
||||
Some(shared_loading_bar)
|
||||
Some(shared_loading_bar),
|
||||
DownloadReason::Update,
|
||||
)
|
||||
)?
|
||||
} else {
|
||||
@@ -182,6 +185,7 @@ async fn replace_managed_modrinth(
|
||||
None,
|
||||
profile_path.to_string(),
|
||||
None,
|
||||
DownloadReason::Update,
|
||||
)
|
||||
.await?;
|
||||
old_pack_creator.description.existing_loading_bar = None;
|
||||
@@ -205,6 +209,7 @@ async fn replace_managed_modrinth(
|
||||
pack::install_mrpack::install_zipped_mrpack_files(
|
||||
new_pack_creator,
|
||||
ignore_lock,
|
||||
DownloadReason::Update,
|
||||
)
|
||||
.await?;
|
||||
|
||||
|
||||
@@ -890,17 +890,6 @@ impl CachedEntry {
|
||||
}
|
||||
}
|
||||
|
||||
remaining_keys.retain(|x| {
|
||||
x != &&*row.id
|
||||
&& !row.alias.as_ref().is_some_and(|y| {
|
||||
if type_.case_sensitive_alias().unwrap_or(true) {
|
||||
x == y
|
||||
} else {
|
||||
y.to_lowercase() == x.to_lowercase()
|
||||
}
|
||||
})
|
||||
});
|
||||
|
||||
if let Some(data) = parsed_data {
|
||||
if data.get_type() != type_ {
|
||||
return Err(crate::ErrorKind::OtherError(format!(
|
||||
@@ -912,6 +901,18 @@ impl CachedEntry {
|
||||
.as_error());
|
||||
}
|
||||
|
||||
remaining_keys.retain(|x| {
|
||||
x != &&*row.id
|
||||
&& !row.alias.as_ref().is_some_and(|y| {
|
||||
if type_.case_sensitive_alias().unwrap_or(true)
|
||||
{
|
||||
x == y
|
||||
} else {
|
||||
y.to_lowercase() == x.to_lowercase()
|
||||
}
|
||||
})
|
||||
});
|
||||
|
||||
return_vals.push(Self {
|
||||
id: row.id,
|
||||
alias: row.alias,
|
||||
|
||||
@@ -213,7 +213,7 @@ impl DirectoryInfo {
|
||||
.as_ref()
|
||||
.map_or_else(|| app_dir.clone(), PathBuf::from);
|
||||
|
||||
async fn is_dir_writeable(
|
||||
async fn is_dir_writable(
|
||||
new_config_dir: &Path,
|
||||
) -> crate::Result<bool> {
|
||||
let temp_path = new_config_dir.join(".tmp");
|
||||
@@ -259,8 +259,8 @@ impl DirectoryInfo {
|
||||
)
|
||||
.await?;
|
||||
|
||||
if !is_dir_writeable(&move_dir).await? {
|
||||
return Err(crate::ErrorKind::DirectoryMoveError(format!("Cannot move directory to {}: directory is not writeable", move_dir.display())).into());
|
||||
if !is_dir_writable(&move_dir).await? {
|
||||
return Err(crate::ErrorKind::DirectoryMoveError(format!("Cannot move directory to {}: directory is not writable", move_dir.display())).into());
|
||||
}
|
||||
|
||||
const MOVE_DIRS: &[&str] = &[
|
||||
|
||||
@@ -144,18 +144,19 @@ pub(crate) async fn watch_profiles_init(
|
||||
watcher: &FileWatcher,
|
||||
dirs: &DirectoryInfo,
|
||||
) {
|
||||
if let Ok(profiles_dir) = std::fs::read_dir(dirs.profiles_dir()) {
|
||||
for profile_dir in profiles_dir {
|
||||
if let Ok(file_name) = profile_dir.map(|x| x.file_name())
|
||||
&& let Some(file_name) = file_name.to_str()
|
||||
{
|
||||
if file_name.starts_with(".DS_Store") {
|
||||
continue;
|
||||
};
|
||||
let Ok(mut profiles_dir) = tokio::fs::read_dir(dirs.profiles_dir()).await
|
||||
else {
|
||||
return;
|
||||
};
|
||||
|
||||
watch_profile(file_name, watcher, dirs).await;
|
||||
}
|
||||
while let Ok(Some(profile_dir)) = profiles_dir.next_entry().await {
|
||||
let file_name = profile_dir.file_name();
|
||||
let file_name = file_name.to_string_lossy();
|
||||
if file_name.starts_with(".DS_Store") {
|
||||
continue;
|
||||
}
|
||||
|
||||
watch_profile(&file_name, watcher, dirs).await;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -166,46 +167,58 @@ pub(crate) async fn watch_profile(
|
||||
) {
|
||||
let profile_path = dirs.profiles_dir().join(profile_path);
|
||||
|
||||
if profile_path.exists() && profile_path.is_dir() {
|
||||
for sub_path in ProjectType::iterator()
|
||||
.map(|x| x.get_folder())
|
||||
.chain(["crash-reports", "saves"])
|
||||
{
|
||||
let full_path = profile_path.join(sub_path);
|
||||
let Ok(metadata) = tokio::fs::metadata(&profile_path).await else {
|
||||
return;
|
||||
};
|
||||
|
||||
if !full_path.exists()
|
||||
&& !full_path.is_symlink()
|
||||
&& !sub_path.contains(".")
|
||||
&& let Err(e) =
|
||||
crate::util::io::create_dir_all(&full_path).await
|
||||
{
|
||||
tracing::error!(
|
||||
"Failed to create directory for watcher {full_path:?}: {e}"
|
||||
);
|
||||
return;
|
||||
}
|
||||
if !metadata.is_dir() {
|
||||
return;
|
||||
}
|
||||
|
||||
let mut watcher = watcher.write().await;
|
||||
if let Err(e) = watcher
|
||||
.watcher()
|
||||
.watch(&full_path, RecursiveMode::Recursive)
|
||||
{
|
||||
tracing::error!(
|
||||
"Failed to watch directory for watcher {full_path:?}: {e}"
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
let mut to_watch = Vec::new();
|
||||
for sub_path in ProjectType::iterator()
|
||||
.map(|x| x.get_folder())
|
||||
.chain(["crash-reports", "saves"])
|
||||
{
|
||||
let full_path = profile_path.join(sub_path);
|
||||
|
||||
let mut watcher = watcher.write().await;
|
||||
if let Err(e) = watcher
|
||||
.watcher()
|
||||
.watch(&profile_path, RecursiveMode::NonRecursive)
|
||||
let meta = tokio::fs::symlink_metadata(&full_path).await;
|
||||
let exists = meta.is_ok();
|
||||
let is_symlink = meta.ok().is_some_and(|m| m.file_type().is_symlink());
|
||||
|
||||
if !exists
|
||||
&& !is_symlink
|
||||
&& !sub_path.contains(".")
|
||||
&& let Err(e) = crate::util::io::create_dir_all(&full_path).await
|
||||
{
|
||||
tracing::error!(
|
||||
"Failed to watch root profile directory for watcher {profile_path:?}: {e}"
|
||||
"Failed to create directory for watcher {full_path:?}: {e}"
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
to_watch.push(full_path);
|
||||
}
|
||||
|
||||
let mut watcher = watcher.write().await;
|
||||
for full_path in &to_watch {
|
||||
if let Err(e) =
|
||||
watcher.watcher().watch(full_path, RecursiveMode::Recursive)
|
||||
{
|
||||
tracing::error!(
|
||||
"Failed to watch directory for watcher {full_path:?}: {e}"
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if let Err(e) = watcher
|
||||
.watcher()
|
||||
.watch(&profile_path, RecursiveMode::NonRecursive)
|
||||
{
|
||||
tracing::error!(
|
||||
"Failed to watch root profile directory for watcher {profile_path:?}: {e}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
//! Theseus state management system
|
||||
use crate::util::fetch::{FetchSemaphore, IoSemaphore};
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::AtomicBool;
|
||||
use tokio::sync::{OnceCell, Semaphore};
|
||||
|
||||
use crate::state::fs_watcher::FileWatcher;
|
||||
@@ -83,6 +84,8 @@ pub struct State {
|
||||
/// Friends socket
|
||||
pub friends_socket: FriendsSocket,
|
||||
|
||||
pub restart_after_pending_update: AtomicBool,
|
||||
|
||||
pub(crate) pool: SqlitePool,
|
||||
|
||||
pub(crate) file_watcher: FileWatcher,
|
||||
@@ -95,6 +98,12 @@ impl State {
|
||||
.await?;
|
||||
|
||||
tokio::task::spawn(async move {
|
||||
fs_watcher::watch_profiles_init(
|
||||
&state.file_watcher,
|
||||
&state.directories,
|
||||
)
|
||||
.await;
|
||||
|
||||
let res = tokio::try_join!(
|
||||
state.discord_rpc.clear_to_default(true),
|
||||
Profile::refresh_all(),
|
||||
@@ -140,6 +149,10 @@ impl State {
|
||||
LAUNCHER_STATE.initialized()
|
||||
}
|
||||
|
||||
pub fn get_if_initialized() -> Option<Arc<Self>> {
|
||||
LAUNCHER_STATE.get().map(Arc::clone)
|
||||
}
|
||||
|
||||
#[tracing::instrument]
|
||||
async fn initialize_state(
|
||||
app_identifier: String,
|
||||
@@ -175,7 +188,6 @@ impl State {
|
||||
|
||||
tracing::info!("Initializing file watcher");
|
||||
let file_watcher = fs_watcher::init_watcher().await?;
|
||||
fs_watcher::watch_profiles_init(&file_watcher, &directories).await;
|
||||
|
||||
let process_manager = ProcessManager::new();
|
||||
|
||||
@@ -189,6 +201,7 @@ impl State {
|
||||
discord_rpc,
|
||||
process_manager,
|
||||
friends_socket,
|
||||
restart_after_pending_update: AtomicBool::new(false),
|
||||
pool,
|
||||
file_watcher,
|
||||
// app_identifier,
|
||||
|
||||
@@ -417,6 +417,25 @@ struct InitialScanFile {
|
||||
cache_key: String,
|
||||
}
|
||||
|
||||
fn is_scannable_project_file(
|
||||
project_type: ProjectType,
|
||||
file_name: &str,
|
||||
) -> bool {
|
||||
let Some(extension) = Path::new(file_name.trim_end_matches(".disabled"))
|
||||
.extension()
|
||||
.and_then(|ext| ext.to_str())
|
||||
else {
|
||||
return false;
|
||||
};
|
||||
|
||||
match project_type {
|
||||
ProjectType::Mod => extension.eq_ignore_ascii_case("jar"),
|
||||
ProjectType::DataPack
|
||||
| ProjectType::ResourcePack
|
||||
| ProjectType::ShaderPack => extension.eq_ignore_ascii_case("zip"),
|
||||
}
|
||||
}
|
||||
|
||||
impl Profile {
|
||||
pub async fn get(
|
||||
path: &str,
|
||||
@@ -648,8 +667,10 @@ impl Profile {
|
||||
&& let Some(file_name) = subdirectory
|
||||
.file_name()
|
||||
.and_then(|x| x.to_str())
|
||||
&& !(project_type == ProjectType::ShaderPack
|
||||
&& file_name.ends_with(".txt"))
|
||||
&& is_scannable_project_file(
|
||||
project_type,
|
||||
file_name,
|
||||
)
|
||||
{
|
||||
let file_size = subdirectory
|
||||
.metadata()
|
||||
@@ -951,15 +972,13 @@ impl Profile {
|
||||
InitialScanFile,
|
||||
> = keys.into_iter().map(|k| (k.path.clone(), k)).collect();
|
||||
|
||||
let mut file_info_by_hash: std::collections::HashMap<
|
||||
String,
|
||||
CachedFile,
|
||||
> = file_info.into_iter().map(|f| (f.hash.clone(), f)).collect();
|
||||
let file_info_by_hash: std::collections::HashMap<String, CachedFile> =
|
||||
file_info.into_iter().map(|f| (f.hash.clone(), f)).collect();
|
||||
|
||||
let files = DashMap::new();
|
||||
|
||||
for hash in file_hashes {
|
||||
let file = file_info_by_hash.remove(&hash.hash);
|
||||
let file = file_info_by_hash.get(&hash.hash).cloned();
|
||||
let trimmed = hash.path.trim_end_matches(".disabled");
|
||||
|
||||
if let Some(initial_file) = keys_by_path.remove(trimmed) {
|
||||
@@ -1054,8 +1073,7 @@ impl Profile {
|
||||
if subdirectory.is_file()
|
||||
&& let Some(file_name) =
|
||||
subdirectory.file_name().and_then(|x| x.to_str())
|
||||
&& !(project_type == ProjectType::ShaderPack
|
||||
&& file_name.ends_with(".txt"))
|
||||
&& is_scannable_project_file(project_type, file_name)
|
||||
{
|
||||
let file_size = subdirectory
|
||||
.metadata()
|
||||
|
||||
@@ -27,6 +27,7 @@ pub enum DownloadReason {
|
||||
Standalone,
|
||||
Dependency,
|
||||
Modpack,
|
||||
Update,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
|
||||
@@ -26,6 +26,7 @@ import _BadgeDollarSignIcon from './icons/badge-dollar-sign.svg?component'
|
||||
import _BanIcon from './icons/ban.svg?component'
|
||||
import _BellIcon from './icons/bell.svg?component'
|
||||
import _BellRingIcon from './icons/bell-ring.svg?component'
|
||||
import _BinaryIcon from './icons/binary.svg?component'
|
||||
import _BlendIcon from './icons/blend.svg?component'
|
||||
import _BlocksIcon from './icons/blocks.svg?component'
|
||||
import _BoldIcon from './icons/bold.svg?component'
|
||||
@@ -211,6 +212,7 @@ import _ShieldIcon from './icons/shield.svg?component'
|
||||
import _ShieldAlertIcon from './icons/shield-alert.svg?component'
|
||||
import _ShieldCheckIcon from './icons/shield-check.svg?component'
|
||||
import _SignalIcon from './icons/signal.svg?component'
|
||||
import _SignatureIcon from './icons/signature.svg?component'
|
||||
import _SkullIcon from './icons/skull.svg?component'
|
||||
import _SlashIcon from './icons/slash.svg?component'
|
||||
import _SortAscIcon from './icons/sort-asc.svg?component'
|
||||
@@ -416,6 +418,7 @@ export const BadgeDollarSignIcon = _BadgeDollarSignIcon
|
||||
export const BanIcon = _BanIcon
|
||||
export const BellIcon = _BellIcon
|
||||
export const BellRingIcon = _BellRingIcon
|
||||
export const BinaryIcon = _BinaryIcon
|
||||
export const BlendIcon = _BlendIcon
|
||||
export const BlocksIcon = _BlocksIcon
|
||||
export const BoldIcon = _BoldIcon
|
||||
@@ -601,6 +604,7 @@ export const ShieldIcon = _ShieldIcon
|
||||
export const ShieldAlertIcon = _ShieldAlertIcon
|
||||
export const ShieldCheckIcon = _ShieldCheckIcon
|
||||
export const SignalIcon = _SignalIcon
|
||||
export const SignatureIcon = _SignatureIcon
|
||||
export const SkullIcon = _SkullIcon
|
||||
export const SlashIcon = _SlashIcon
|
||||
export const SortAscIcon = _SortAscIcon
|
||||
|
||||
20
packages/assets/icons/binary.svg
Normal file
20
packages/assets/icons/binary.svg
Normal file
@@ -0,0 +1,20 @@
|
||||
<!-- @license lucide-static v0.562.0 - ISC -->
|
||||
<svg
|
||||
class="lucide lucide-binary"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<rect x="14" y="14" width="4" height="6" rx="2" />
|
||||
<rect x="6" y="4" width="4" height="6" rx="2" />
|
||||
<path d="M6 20h4" />
|
||||
<path d="M14 10h4" />
|
||||
<path d="M6 14h2v6" />
|
||||
<path d="M14 4h2v6" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 487 B |
16
packages/assets/icons/signature.svg
Normal file
16
packages/assets/icons/signature.svg
Normal file
@@ -0,0 +1,16 @@
|
||||
<!-- @license lucide-static v0.562.0 - ISC -->
|
||||
<svg
|
||||
class="lucide lucide-signature"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path d="m21 17-2.156-1.868A.5.5 0 0 0 18 15.5v.5a1 1 0 0 1-1 1h-2a1 1 0 0 1-1-1c0-2.545-3.991-3.97-8.5-4a1 1 0 0 0 0 5c4.153 0 4.745-11.295 5.708-13.5a2.5 2.5 0 1 1 3.31 3.284" />
|
||||
<path d="M3 21h18" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 496 B |
@@ -14,7 +14,7 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@modrinth/tooling-config": "workspace:*",
|
||||
"@types/node": "^20.1.0",
|
||||
"@types/node": "^24",
|
||||
"jiti": "^2.4.2",
|
||||
"lucide-static": "^0.562.0",
|
||||
"vue": "^3.5.13"
|
||||
|
||||
@@ -41,10 +41,6 @@
|
||||
.universal-body {
|
||||
@extend .universal-labels;
|
||||
|
||||
.multiselect {
|
||||
width: 15rem;
|
||||
}
|
||||
|
||||
> :where(input + *, .input-group + *, .chips + *, .input-div + *) {
|
||||
margin-block-start: var(--gap-md);
|
||||
}
|
||||
@@ -162,10 +158,6 @@
|
||||
max-width: 100%;
|
||||
align-items: center;
|
||||
|
||||
.multiselect {
|
||||
width: 15rem;
|
||||
}
|
||||
|
||||
input {
|
||||
flex-shrink: 2;
|
||||
}
|
||||
@@ -189,20 +181,6 @@
|
||||
margin-right: 0;
|
||||
}
|
||||
|
||||
.input-stack {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
> * {
|
||||
margin-bottom: var(--gap-md);
|
||||
}
|
||||
|
||||
> .multiselect {
|
||||
width: unset;
|
||||
height: inherit;
|
||||
}
|
||||
}
|
||||
|
||||
.standard-body {
|
||||
:last-child {
|
||||
margin-bottom: 0;
|
||||
@@ -733,6 +711,8 @@ a:not(.no-click-animation),
|
||||
}
|
||||
|
||||
.v-popper--theme-tooltip {
|
||||
pointer-events: none;
|
||||
|
||||
.v-popper__inner {
|
||||
background: var(--color-tooltip-bg) !important;
|
||||
color: var(--color-tooltip-text) !important;
|
||||
@@ -1020,130 +1000,6 @@ select {
|
||||
filter: brightness(1.2);
|
||||
}
|
||||
|
||||
.multiselect {
|
||||
color: var(--color-base) !important;
|
||||
outline: 2px solid transparent;
|
||||
width: 100% !important;
|
||||
|
||||
.multiselect__input:focus-visible {
|
||||
outline: none !important;
|
||||
box-shadow: none !important;
|
||||
padding: 0 !important;
|
||||
min-height: 0 !important;
|
||||
font-weight: normal !important;
|
||||
margin-left: 0.5rem;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
input {
|
||||
background: transparent;
|
||||
box-shadow: none;
|
||||
border: none !important;
|
||||
|
||||
&:focus {
|
||||
box-shadow: none;
|
||||
}
|
||||
}
|
||||
|
||||
input::placeholder {
|
||||
color: var(--color-base);
|
||||
}
|
||||
|
||||
.multiselect__tags {
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--color-button-bg);
|
||||
box-shadow: var(--shadow-inset-sm);
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
padding-left: 7px;
|
||||
padding-top: 10px;
|
||||
font-size: 1rem;
|
||||
|
||||
transition: background-color 0.1s ease-in-out;
|
||||
|
||||
&:active {
|
||||
filter: brightness(1.25);
|
||||
|
||||
.multiselect__spinner {
|
||||
filter: brightness(1.25);
|
||||
}
|
||||
}
|
||||
|
||||
.multiselect__single {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.multiselect__tag {
|
||||
border-radius: var(--radius-md);
|
||||
color: var(--color-base);
|
||||
background: transparent;
|
||||
border: 2px solid var(--color-brand);
|
||||
}
|
||||
|
||||
.multiselect__tag-icon {
|
||||
background: transparent;
|
||||
|
||||
&:after {
|
||||
color: var(--color-contrast);
|
||||
}
|
||||
}
|
||||
|
||||
.multiselect__placeholder {
|
||||
color: var(--color-base);
|
||||
margin-left: 0.5rem;
|
||||
margin-bottom: 8px;
|
||||
opacity: 0.6;
|
||||
font-size: 1rem;
|
||||
line-height: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
.multiselect__content-wrapper {
|
||||
background: var(--color-button-bg);
|
||||
border: none;
|
||||
overflow-x: hidden;
|
||||
box-shadow: var(--shadow-inset-sm), var(--shadow-floating);
|
||||
width: 100%;
|
||||
|
||||
.multiselect__element {
|
||||
.multiselect__option--highlight {
|
||||
background: var(--color-button-bg);
|
||||
filter: brightness(1.25);
|
||||
color: var(--color-contrast);
|
||||
}
|
||||
|
||||
.multiselect__option--selected {
|
||||
background: var(--color-brand);
|
||||
font-weight: bold;
|
||||
color: var(--color-accent-contrast);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.multiselect__spinner {
|
||||
background: var(--color-button-bg);
|
||||
|
||||
&:active {
|
||||
filter: brightness(1.25);
|
||||
}
|
||||
}
|
||||
|
||||
&.multiselect--disabled {
|
||||
background: none;
|
||||
|
||||
.multiselect__current,
|
||||
.multiselect__select {
|
||||
background: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.multiselect--above .multiselect__content-wrapper {
|
||||
border-top: none !important;
|
||||
border-top-left-radius: var(--radius-md) !important;
|
||||
border-top-right-radius: var(--radius-md) !important;
|
||||
}
|
||||
|
||||
.preview-radio {
|
||||
width: 100% !important;
|
||||
border-radius: var(--radius-md);
|
||||
|
||||
@@ -10,6 +10,188 @@ export type VersionEntry = {
|
||||
}
|
||||
|
||||
const VERSIONS: VersionEntry[] = [
|
||||
{
|
||||
date: `2026-05-13T05:24:14+00:00`,
|
||||
product: 'web',
|
||||
body: `## Changed
|
||||
- Overhauled the 'Moderation' tab on project pages to make the moderation status of your project clearer.
|
||||
- Updated the report page to a more modern style.
|
||||
- Adjusted the colors of certain status banner buttons to make them more readable.
|
||||
- Updated the DMCA registered agent listed on the coypright policy page.
|
||||
|
||||
## Fixed
|
||||
- Fixed status banners at the top of the page having really long buttons.
|
||||
- Fixed status banners at the top of the page having an unusual amount of padding on the bottom when they didn't have an action.`,
|
||||
},
|
||||
{
|
||||
date: `2026-05-12T20:06:07+00:00`,
|
||||
product: 'app',
|
||||
version: '0.13.17',
|
||||
body: `## Fixed
|
||||
- Fixed the app automatically re-opening after installing a pending update when the user closes the app.
|
||||
- Fixed "Open in browser" not working.
|
||||
- Fixed the lack of margin above the pagination links at the bottom of Discover pages.
|
||||
- Fixed longstanding issue "Unable to read category tags from any source" that occurs sometimes when the app has cached invalid tags.
|
||||
- Fixed Windows app control buttons not being clickable at the exact top right corner.
|
||||
- Fixed Modrinth project links opening inside a broken in-app web browser rather than going to the project's page in the app.
|
||||
- Fixed not being able to hover over project card tooltip items.`,
|
||||
},
|
||||
{
|
||||
date: `2026-05-12T20:06:07+00:00`,
|
||||
product: 'web',
|
||||
body: `## Changed
|
||||
- Changed how dependencies are added and edited on versions to make it clearer when a dependency is added or not when saving.`,
|
||||
},
|
||||
{
|
||||
date: `2026-05-11T20:16:06+00:00`,
|
||||
product: 'app',
|
||||
version: '0.13.15',
|
||||
body: `## Changed
|
||||
- Updated translations.
|
||||
|
||||
## Fixed
|
||||
- Fixed app launch speed being dramatically slowed by having lots of instances.`,
|
||||
},
|
||||
{
|
||||
date: `2026-05-11T20:16:06+00:00`,
|
||||
product: 'web',
|
||||
body: `## Changed
|
||||
- Updated translations.
|
||||
|
||||
## Fixed
|
||||
- Fixed NeoForge version inferring on Minecraft versions 26.1 and newer.
|
||||
- Improved how NeoForge Minecraft versions are inferred to support more cases.`,
|
||||
},
|
||||
{
|
||||
date: `2026-05-11T20:16:06+00:00`,
|
||||
product: 'hosting',
|
||||
body: `## Changed
|
||||
- Updated translations.`,
|
||||
},
|
||||
{
|
||||
date: `2026-05-09T21:42:48+00:00`,
|
||||
product: 'app',
|
||||
version: '0.13.14',
|
||||
body: `## Fixed
|
||||
- Fixed hidden files showing up in the Content tab on instances.
|
||||
- Fixed 'Advanced rendering' option not being applied to most of the interface.`,
|
||||
},
|
||||
{
|
||||
date: `2026-05-09T21:42:48+00:00`,
|
||||
product: 'hosting',
|
||||
body: `## Changed
|
||||
- Improved stability of content install flow.
|
||||
|
||||
## Fixed
|
||||
- Fixed content install flow breaking if you refresh the page mid-install.`,
|
||||
},
|
||||
{
|
||||
date: `2026-05-09T19:06:18+00:00`,
|
||||
product: 'app',
|
||||
version: '0.13.13',
|
||||
body: `## Changed
|
||||
- Improved the Browse page header so the back button no longer shifts the layout.
|
||||
|
||||
## Fixed
|
||||
- Fixed instance redirects opening a broken page state.`,
|
||||
},
|
||||
{
|
||||
date: `2026-05-09T19:06:18+00:00`,
|
||||
product: 'web',
|
||||
body: `## Changed
|
||||
- Improved performance on search pages.`,
|
||||
},
|
||||
{
|
||||
date: `2026-05-09T19:06:18+00:00`,
|
||||
product: 'hosting',
|
||||
body: `## Changed
|
||||
- Updated Modrinth Hosting content browsing so multiple projects can be selected before installation.
|
||||
- After installing selected content, the Modrinth Hosting content page now reopens immediately and shows pending projects as installing.
|
||||
- Project icons now appear in the action bar when selecting multiple projects in the Content tab.
|
||||
|
||||
## Fixed
|
||||
- Fixed selected content and dependencies not staying marked as installing.
|
||||
- Fixed page shifts while dependencies resolve during installation.`,
|
||||
},
|
||||
{
|
||||
date: `2026-05-08T09:58:39+00:00`,
|
||||
product: 'web',
|
||||
body: `## Fixed
|
||||
- Fixed some buttons appearing as disabled even when they weren't, such as the project icon settings.`,
|
||||
},
|
||||
{
|
||||
date: `2026-05-08T02:24:09+00:00`,
|
||||
product: 'app',
|
||||
version: '0.13.12',
|
||||
body: `## Changed
|
||||
- Updated the modpack exporting experience, fixing issues with it and excluding /mods/.connector from the exports.
|
||||
|
||||
## Fixed
|
||||
- Fixed page width changing based on if there is scrollable content or not, causing things to move when you switch tabs between scrollable and non-scrollable content.`,
|
||||
},
|
||||
{
|
||||
date: `2026-05-08T02:24:09+00:00`,
|
||||
product: 'hosting',
|
||||
body: `## Fixed
|
||||
- Fixed failed \`mrpack\` uploads when uploading via the Modrinth App.
|
||||
- Fixed support bubble being broken when the console is in full screen/expand mode.`,
|
||||
},
|
||||
{
|
||||
date: `2026-05-08T02:24:09+00:00`,
|
||||
product: 'web',
|
||||
body: `## Changed
|
||||
- Project pages now have canonical permalink URLs to hopefully optimize SEO a bit.
|
||||
|
||||
## Fixed
|
||||
- Fixed error around editing org member permissions.`,
|
||||
},
|
||||
{
|
||||
date: `2026-05-06T22:11:04+00:00`,
|
||||
product: 'app',
|
||||
version: '0.13.11',
|
||||
body: `## Fixed
|
||||
- Fixed modpack export including duplicated files as overrides.`,
|
||||
},
|
||||
{
|
||||
date: `2026-05-05T01:34:18+00:00`,
|
||||
product: 'web',
|
||||
body: `## Fixed
|
||||
- Fixed unauthorized error when loading a user's own project or organization settings.`,
|
||||
},
|
||||
{
|
||||
date: `2026-05-04T19:57:12+00:00`,
|
||||
product: 'web',
|
||||
body: `## Changed
|
||||
- Users who are not members of a project or organization can no longer view settings pages.
|
||||
|
||||
## Fixed
|
||||
- Fixed some project pages failing to load due to invalid iframe links in their descriptions.`,
|
||||
},
|
||||
{
|
||||
date: `2026-05-04T19:57:12+00:00`,
|
||||
product: 'app',
|
||||
version: '0.13.10',
|
||||
body: `## Fixed
|
||||
- Fixed some project pages failing to load due to invalid iframe links in their descriptions.`,
|
||||
},
|
||||
{
|
||||
date: `2026-05-03T18:07:44+00:00`,
|
||||
product: 'web',
|
||||
body: `## Changed
|
||||
- Added git.gay as a recognized sources link domain.
|
||||
|
||||
## Fixed
|
||||
- Fixed useTheme not defined error on project pages.
|
||||
- Fixed latest snapshot sometimes appearing a second time outside of a version range.`,
|
||||
},
|
||||
{
|
||||
date: `2026-05-03T18:07:44+00:00`,
|
||||
product: 'app',
|
||||
version: '0.13.9',
|
||||
body: `## Fixed
|
||||
- Fixed update notification closing when pressing the Changelog button.
|
||||
- Fixed latest snapshot sometimes appearing a second time outside of a version range.`,
|
||||
},
|
||||
{
|
||||
date: `2026-05-02T22:09:01+00:00`,
|
||||
product: 'app',
|
||||
|
||||
@@ -1,2 +1,3 @@
|
||||
In accordance with the above notice, this project will be temporarily %STATUS%.</br>
|
||||
In accordance with the above notice, this project will be temporarily %STATUS%.
|
||||
|
||||
We ask that you resubmit this project once all moderation concerns have been addressed.
|
||||
|
||||
@@ -3,7 +3,15 @@ import { defineMessage, useVIntl } from '@modrinth/ui'
|
||||
import type { Nag, NagContext } from '../../types/nags'
|
||||
|
||||
export const commonLinkDomains = {
|
||||
source: ['github.com', 'gitlab.com', 'bitbucket.org', 'codeberg.org', 'git.sr.ht', 'tangled.org'],
|
||||
source: [
|
||||
'github.com',
|
||||
'gitlab.com',
|
||||
'bitbucket.org',
|
||||
'codeberg.org',
|
||||
'git.sr.ht',
|
||||
'tangled.org',
|
||||
'git.gay',
|
||||
],
|
||||
issues: [
|
||||
'github.com',
|
||||
'gitlab.com',
|
||||
@@ -11,6 +19,7 @@ export const commonLinkDomains = {
|
||||
'codeberg.org',
|
||||
'docs.google.com',
|
||||
'tangled.org',
|
||||
'git.gay',
|
||||
],
|
||||
discord: ['discord.gg', 'discord.com', 'dsc.gg'],
|
||||
licenseBlocklist: [
|
||||
|
||||
@@ -33,7 +33,7 @@
|
||||
"defaultMessage": "Hai selezionato {totalAvailableTags, plural, one {l'unico tag disponibile} =8 {tutti gli # tag disponibili} =11 {tutti gli # tag disponibili} other {tutti i # tag disponibili}}. Questo rende i tag, che servono ad aiutare gli utenti a trovare progetti pertinenti, del tutto inutili. Si prega di selezionare solo i tag pertinenti al tuo progetto."
|
||||
},
|
||||
"nags.all-tags-selected.title": {
|
||||
"defaultMessage": "Seleziona tag accurati"
|
||||
"defaultMessage": "Seleziona i tag pertinenti"
|
||||
},
|
||||
"nags.description-too-short.description": {
|
||||
"defaultMessage": "La tua descrizione è lunga {length, plural, one {# carattere} other {# caratteri}}. Si consiglia di scriverne almeno {minChars} perché sia chiara e informativa."
|
||||
@@ -138,37 +138,37 @@
|
||||
"defaultMessage": "Hai selezionato {count} tag di risoluzione ({tags}). I pacchetti di risorse dovrebbero avere un solo tag di risoluzione che corrisponda alla loro risoluzione primaria."
|
||||
},
|
||||
"nags.multiple-resolution-tags.title": {
|
||||
"defaultMessage": "Seleziona risoluzione corretta"
|
||||
"defaultMessage": "Seleziona la risoluzione corretta"
|
||||
},
|
||||
"nags.select-compatibility.description": {
|
||||
"defaultMessage": "Seleziona le versioni supportate dal tuo server, scegli un pacchetto di mod, o caricane il tuo."
|
||||
},
|
||||
"nags.select-compatibility.title": {
|
||||
"defaultMessage": "Seleziona compatibilità"
|
||||
"defaultMessage": "Seleziona la compatibilità"
|
||||
},
|
||||
"nags.select-country.description": {
|
||||
"defaultMessage": "Fai sapere ai giocatori in che regione si trova il tuo server."
|
||||
},
|
||||
"nags.select-country.title": {
|
||||
"defaultMessage": "Seleziona una regione"
|
||||
"defaultMessage": "Seleziona la regione"
|
||||
},
|
||||
"nags.select-language.description": {
|
||||
"defaultMessage": "Indica le lingue supportate dal tuo server."
|
||||
},
|
||||
"nags.select-language.title": {
|
||||
"defaultMessage": "Seleziona lingue"
|
||||
"defaultMessage": "Seleziona le lingue"
|
||||
},
|
||||
"nags.select-license.description": {
|
||||
"defaultMessage": "Seleziona la licenza sotto cui {type, select, shader {sono} other {è}} distribuit{type, select, mod {a la tua mod} modpack {o il tuo pacchetto di mod} resourcepack {o il tuo pacchetto di risorse} shader {e le tue shader} plugin {o il tuo plugin} datapack {o il tuo pacchetto di dati} other {o il tuo progetto}}."
|
||||
},
|
||||
"nags.select-license.title": {
|
||||
"defaultMessage": "Scegli una licenza"
|
||||
"defaultMessage": "Seleziona la licenza"
|
||||
},
|
||||
"nags.select-tags.description": {
|
||||
"defaultMessage": "Seleziona i tag che corrispondono al tuo progetto per aiutare gli utenti a trovarlo."
|
||||
},
|
||||
"nags.select-tags.title": {
|
||||
"defaultMessage": "Seleziona tag"
|
||||
"defaultMessage": "Seleziona i tag"
|
||||
},
|
||||
"nags.server.title": {
|
||||
"defaultMessage": "Vedi impostazioni del server"
|
||||
@@ -216,16 +216,16 @@
|
||||
"defaultMessage": "Hai selezionato {tagCount} tag. Scegline al massimo {maxTagCount} cosicché il tuo server appaia in ricerche rilevanti."
|
||||
},
|
||||
"nags.too-many-tags-server.title": {
|
||||
"defaultMessage": "Seleziona tag accurati"
|
||||
"defaultMessage": "Seleziona i tag pertinenti"
|
||||
},
|
||||
"nags.too-many-tags.description": {
|
||||
"defaultMessage": "Hai selezionato {tagCount} tag. Dovresti tenerne al massimo {maxTagCount} per assicurarti che il tuo progetto appaia solo nelle ricerche più rilevanti."
|
||||
},
|
||||
"nags.too-many-tags.title": {
|
||||
"defaultMessage": "Seleziona tag accurati"
|
||||
"defaultMessage": "Seleziona i tag pertinenti"
|
||||
},
|
||||
"nags.upload-gallery-image.description": {
|
||||
"defaultMessage": "È necessaria almeno un'immagine che mostri il contenuto {type, select, resourcepack {del tuo pacchetto di risorse, a meno che contenga solo audio o traduzioni. Se ciò descrive il tuo pacchetto, seleziona il tag appropriato} shader {delle tue shader} other {del tuo progetto}}."
|
||||
"defaultMessage": "È necessaria almeno un'immagine che mostri il contenuto {type, select, resourcepack {del tuo pacchetto di risorse, a meno che contenga solo audio o traduzioni. In tal caso seleziona il tag appropriato} shader {delle tue shader} other {del tuo progetto}}."
|
||||
},
|
||||
"nags.upload-gallery-image.title": {
|
||||
"defaultMessage": "Carica un'immagine"
|
||||
|
||||
@@ -31,19 +31,24 @@
|
||||
"prettier": "^3.6.2",
|
||||
"typescript": ">=5.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.32.0",
|
||||
"dependencies": {
|
||||
"@prettier/plugin-xml": "^3.4.2",
|
||||
"prettier-plugin-sql-cst": "^0.13.0",
|
||||
"prettier-plugin-toml": "^2.0.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/compat": "^1.4.1",
|
||||
"@eslint/js": "^9.32.0",
|
||||
"@nuxt/eslint-config": "^0.5.7",
|
||||
"@vue/tsconfig": "^0.7.0",
|
||||
"eslint": "^9.32.0",
|
||||
"eslint-config-prettier": "^10.1.8",
|
||||
"eslint-plugin-prettier": "^5.5.3",
|
||||
"eslint-plugin-simple-import-sort": "^12.1.1",
|
||||
"eslint-plugin-turbo": "^2.5.4",
|
||||
"eslint-plugin-vue": "^10.4.0",
|
||||
"vue-eslint-parser": "^10.1.3",
|
||||
"globals": "^16.3.0",
|
||||
"prettier-plugin-sql-cst": "^0.13.0",
|
||||
"prettier-plugin-toml": "^2.0.6",
|
||||
"typescript-eslint": "^8.38.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -59,7 +59,7 @@
|
||||
"@modrinth/assets": "workspace:*",
|
||||
"@modrinth/blog": "workspace:*",
|
||||
"@modrinth/utils": "workspace:*",
|
||||
"@tanstack/vue-query": "^5.90.7",
|
||||
"@tanstack/vue-query": "5.90.7",
|
||||
"@tresjs/cientos": "^4.3.0",
|
||||
"@tresjs/core": "^4.3.4",
|
||||
"@tresjs/post-processing": "^2.4.0",
|
||||
@@ -76,6 +76,7 @@
|
||||
"dayjs": "^1.11.10",
|
||||
"dompurify": "^3.1.7",
|
||||
"es-toolkit": "^1.44.0",
|
||||
"flatpickr": "^4.6.13",
|
||||
"floating-vue": "^5.2.2",
|
||||
"fuse.js": "^6.6.2",
|
||||
"highlight.js": "^11.9.0",
|
||||
|
||||
@@ -24,7 +24,7 @@
|
||||
{{ relativeTimeLabel }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="font-normal text-contrast/85">
|
||||
<div class="font-normal text-contrast/85 leading-tight">
|
||||
<slot>{{ body }}</slot>
|
||||
</div>
|
||||
<div v-if="showActionsUnderneath || $slots.actions" class="mt-2">
|
||||
@@ -80,7 +80,7 @@ import ButtonStyled from './ButtonStyled.vue'
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
type?: 'info' | 'warning' | 'critical' | 'success'
|
||||
type?: 'info' | 'warning' | 'critical' | 'success' | 'moderation'
|
||||
header?: string
|
||||
body?: string
|
||||
showActionsUnderneath?: boolean
|
||||
@@ -141,6 +141,7 @@ const typeClasses = {
|
||||
warning: 'border-brand-orange bg-bg-orange',
|
||||
critical: 'border-brand-red bg-bg-red',
|
||||
success: 'border-brand-green bg-bg-green',
|
||||
moderation: 'border-brand-orange bg-bg-orange',
|
||||
}
|
||||
|
||||
const iconClasses = {
|
||||
@@ -148,6 +149,7 @@ const iconClasses = {
|
||||
warning: 'text-brand-orange',
|
||||
critical: 'text-brand-red',
|
||||
success: 'text-brand-green',
|
||||
moderation: 'text-brand-orange',
|
||||
}
|
||||
|
||||
const buttonColors = {
|
||||
@@ -155,6 +157,7 @@ const buttonColors = {
|
||||
warning: 'orange',
|
||||
critical: 'red',
|
||||
success: 'green',
|
||||
moderation: 'orange',
|
||||
} as const
|
||||
|
||||
const progressTrackClasses = {
|
||||
@@ -162,6 +165,7 @@ const progressTrackClasses = {
|
||||
warning: 'bg-brand-orange/20',
|
||||
critical: 'bg-brand-red/20',
|
||||
success: 'bg-brand-green/20',
|
||||
moderation: 'bg-brand-orange/20',
|
||||
}
|
||||
|
||||
const progressFillClasses = {
|
||||
|
||||
@@ -30,9 +30,10 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { formatBytes } from '@modrinth/utils'
|
||||
import { computed, onUnmounted, ref, watch } from 'vue'
|
||||
|
||||
import { useFormatBytes } from '#ui/composables'
|
||||
|
||||
interface Props {
|
||||
maxValue: number
|
||||
currentValue: number
|
||||
@@ -59,6 +60,8 @@ const props = withDefaults(defineProps<Props>(), {
|
||||
],
|
||||
})
|
||||
|
||||
const formatBytes = useFormatBytes()
|
||||
|
||||
const currentPhrase = ref('')
|
||||
const usedPhrases = ref(new Set<number>())
|
||||
let phraseInterval: NodeJS.Timeout | null = null
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
<img
|
||||
v-if="src && !failed"
|
||||
ref="img"
|
||||
class="`experimental-styles-within avatar shrink-0"
|
||||
class="avatar shrink-0"
|
||||
:style="`--_size: ${cssSize}`"
|
||||
:class="{
|
||||
circle: circle,
|
||||
@@ -18,7 +18,7 @@
|
||||
/>
|
||||
<svg
|
||||
v-else
|
||||
class="`experimental-styles-within avatar shrink-0"
|
||||
class="avatar shrink-0"
|
||||
:style="`--_size: ${cssSize}${tint ? `;--_tint:oklch(50% 75% ${tint})` : ''}`"
|
||||
:class="{
|
||||
tint: tint,
|
||||
|
||||
@@ -15,16 +15,16 @@
|
||||
|
||||
<!-- Project statuses -->
|
||||
<template v-else-if="type === 'approved'">
|
||||
<ListIcon aria-hidden="true" /> {{ formatMessage(messages.listedLabel) }}
|
||||
<GlobeIcon aria-hidden="true" /> {{ formatMessage(messages.listedLabel) }}
|
||||
</template>
|
||||
<template v-else-if="type === 'approved-general'">
|
||||
<CheckIcon aria-hidden="true" /> {{ formatMessage(messages.approvedLabel) }}
|
||||
</template>
|
||||
<template v-else-if="type === 'unlisted'">
|
||||
<EyeOffIcon aria-hidden="true" /> {{ formatMessage(messages.unlistedLabel) }}
|
||||
<LinkIcon aria-hidden="true" /> {{ formatMessage(messages.unlistedLabel) }}
|
||||
</template>
|
||||
<template v-else-if="type === 'withheld'">
|
||||
<EyeOffIcon aria-hidden="true" /> {{ formatMessage(messages.withheldLabel) }}
|
||||
<LinkIcon aria-hidden="true" /> {{ formatMessage(messages.withheldLabel) }}
|
||||
</template>
|
||||
<template v-else-if="type === 'private'">
|
||||
<LockIcon aria-hidden="true" /> {{ formatMessage(messages.privateLabel) }}
|
||||
@@ -89,9 +89,9 @@ import {
|
||||
BugIcon,
|
||||
CalendarIcon,
|
||||
CheckIcon,
|
||||
EyeOffIcon,
|
||||
FileTextIcon,
|
||||
ListIcon,
|
||||
GlobeIcon,
|
||||
LinkIcon,
|
||||
LockIcon,
|
||||
ModrinthIcon,
|
||||
ScaleIcon,
|
||||
@@ -134,7 +134,7 @@ const messages = defineMessages({
|
||||
},
|
||||
listedLabel: {
|
||||
id: 'omorphia.component.badge.label.listed',
|
||||
defaultMessage: 'Listed',
|
||||
defaultMessage: 'Public',
|
||||
},
|
||||
moderatorLabel: {
|
||||
id: 'omorphia.component.badge.label.moderator',
|
||||
@@ -186,7 +186,7 @@ const messages = defineMessages({
|
||||
},
|
||||
withheldLabel: {
|
||||
id: 'omorphia.component.badge.label.withheld',
|
||||
defaultMessage: 'Withheld',
|
||||
defaultMessage: 'Unlisted by staff',
|
||||
},
|
||||
})
|
||||
const { formatMessage } = useVIntl()
|
||||
|
||||
@@ -86,7 +86,7 @@ const EMPTY_STATE_BUBBLES: Record<string, string[]> = {
|
||||
instance: [
|
||||
' _____________________________________________________________',
|
||||
' / Start your instance in the top right to start \\',
|
||||
'| recieving live logs! |',
|
||||
'| receiving live logs! |',
|
||||
' \\_____________________________________________________________/',
|
||||
],
|
||||
}
|
||||
|
||||
@@ -11,6 +11,10 @@ const props = defineProps({
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
download: {
|
||||
type: String,
|
||||
default: null,
|
||||
},
|
||||
action: {
|
||||
type: Function,
|
||||
default: null,
|
||||
@@ -106,6 +110,7 @@ const classes = computed(() => {
|
||||
class="btn"
|
||||
:class="classes"
|
||||
:href="disabled ? undefined : link"
|
||||
:download="download || undefined"
|
||||
:target="external ? '_blank' : '_self'"
|
||||
@click="
|
||||
(event) => {
|
||||
|
||||
@@ -241,7 +241,10 @@ const fontSize = computed(() => {
|
||||
<template>
|
||||
<div
|
||||
class="btn-wrapper"
|
||||
:class="[{ outline: type === 'outlined', chip: type === 'chip' }, fontSize]"
|
||||
:class="[
|
||||
{ outline: type === 'outlined', transparent: type === 'transparent', chip: type === 'chip' },
|
||||
fontSize,
|
||||
]"
|
||||
:style="`${colorVariables}--_height:${height};--_width:${width};--_radius: ${radius};--_padding-x:${paddingX};--_padding-y:${paddingY};--_gap:${gap};--_font-weight:${fontWeight};--_icon-size:${iconSize};--_outline-color:${color === 'standard' && type === 'outlined' ? 'var(--surface-5)' : 'currentColor'}`"
|
||||
>
|
||||
<slot />
|
||||
@@ -277,20 +280,20 @@ const fontSize = computed(() => {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
&[disabled],
|
||||
&[disabled]:not([disabled='false']),
|
||||
&[disabled='true'],
|
||||
&.disabled,
|
||||
&.looks-disabled {
|
||||
@apply opacity-50;
|
||||
}
|
||||
|
||||
&[disabled],
|
||||
&[disabled]:not([disabled='false']),
|
||||
&[disabled='true'],
|
||||
&.disabled {
|
||||
@apply cursor-not-allowed;
|
||||
}
|
||||
|
||||
&:not([disabled]):not([disabled='true']):not(.disabled) {
|
||||
&:not([disabled]:not([disabled='false'])):not([disabled='true']):not(.disabled) {
|
||||
@apply hover:brightness-[--hover-brightness] focus-visible:brightness-[--hover-brightness] hover:bg-[--_hover-bg] hover:text-[--_hover-text] focus-visible:bg-[--_hover-bg] focus-visible:text-[--_hover-text];
|
||||
|
||||
&:hover svg:first-child,
|
||||
@@ -309,11 +312,30 @@ const fontSize = computed(() => {
|
||||
> *:first-child
|
||||
> *:first-child
|
||||
> :is(button, a, .button-like):first-child {
|
||||
&:not([disabled]):not([disabled='true']):not(.disabled) {
|
||||
&:not([disabled]:not([disabled='false'])):not([disabled='true']):not(.disabled) {
|
||||
@apply active:scale-95;
|
||||
}
|
||||
}
|
||||
|
||||
.disable-advanced-rendering {
|
||||
.btn-wrapper:not(.outline):not(.transparent) :deep(:is(button, a, .button-like):first-child),
|
||||
.btn-wrapper:not(.outline):not(.transparent) :slotted(:is(button, a, .button-like):first-child),
|
||||
.btn-wrapper:not(.outline):not(.transparent)
|
||||
:slotted(*)
|
||||
> :is(button, a, .button-like):first-child,
|
||||
.btn-wrapper:not(.outline):not(.transparent)
|
||||
:slotted(*)
|
||||
> *:first-child
|
||||
> :is(button, a, .button-like):first-child,
|
||||
.btn-wrapper
|
||||
:slotted(*)
|
||||
> *:first-child
|
||||
> *:first-child
|
||||
> :is(button, a, .button-like):first-child {
|
||||
@apply border border-[rgba(0,0,0,0.2)];
|
||||
}
|
||||
}
|
||||
|
||||
.btn-wrapper.outline :deep(:is(button, a, .button-like):first-child),
|
||||
.btn-wrapper.outline :slotted(:is(button, a, .button-like):first-child),
|
||||
.btn-wrapper.outline :slotted(*) > :is(button, a, .button-like):first-child,
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import { DropdownIcon } from '@modrinth/assets'
|
||||
import { reactive } from 'vue'
|
||||
|
||||
import Button from './Button.vue'
|
||||
import ButtonStyled from './ButtonStyled.vue'
|
||||
|
||||
const props = defineProps({
|
||||
collapsible: {
|
||||
@@ -33,9 +33,11 @@ function toggleCollapsed() {
|
||||
<div v-if="!!$slots.header || collapsible" class="header">
|
||||
<slot name="header"></slot>
|
||||
<div v-if="collapsible" class="btn-group">
|
||||
<Button :action="toggleCollapsed">
|
||||
<DropdownIcon :style="{ transform: `rotate(${state.collapsed ? 0 : 180}deg)` }" />
|
||||
</Button>
|
||||
<ButtonStyled circular>
|
||||
<button @click="toggleCollapsed">
|
||||
<DropdownIcon :style="{ transform: `rotate(${state.collapsed ? 0 : 180}deg)` }" />
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</div>
|
||||
<slot v-if="!state.collapsed" />
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<button
|
||||
class="group bg-transparent border-none p-0 m-0 flex items-center gap-3 checkbox-outer outline-offset-4 text-contrast"
|
||||
class="group bg-transparent border-none p-0 m-0 flex items-center text-left gap-3 checkbox-outer outline-offset-4 text-contrast"
|
||||
:disabled="disabled"
|
||||
:class="
|
||||
disabled
|
||||
@@ -13,13 +13,12 @@
|
||||
@click="toggle"
|
||||
>
|
||||
<span
|
||||
class="w-5 h-5 rounded-md flex items-center justify-center border-[1px] border-solid"
|
||||
:class="
|
||||
(modelValue
|
||||
? 'bg-brand border-button-border text-brand-inverted'
|
||||
: 'bg-surface-2 border-surface-5') +
|
||||
(disabled ? '' : ' checkbox-shadow group-active:scale-95')
|
||||
"
|
||||
class="w-5 h-5 rounded-md flex items-center justify-center border-[1px] border-solid shrink-0"
|
||||
:class="{
|
||||
'bg-brand border-button-border text-brand-inverted': modelValue,
|
||||
'bg-surface-2 border-surface-5 text-primary': !modelValue,
|
||||
'checkbox-shadow group-active:scale-95': !disabled,
|
||||
}"
|
||||
>
|
||||
<MinusIcon v-if="indeterminate" aria-hidden="true" stroke-width="3" />
|
||||
<CheckIcon v-else-if="modelValue" aria-hidden="true" stroke-width="3" />
|
||||
|
||||
@@ -95,7 +95,7 @@ function toggleItem(item: T) {
|
||||
background-color: var(--color-brand-highlight);
|
||||
box-shadow:
|
||||
inset 0 0 0 transparent,
|
||||
0 0 0 2px var(--color-brand);
|
||||
0 0 0 1px var(--color-brand);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -28,7 +28,8 @@
|
||||
class="relative z-[1]"
|
||||
@input="handleSearchInput"
|
||||
@keydown="handleSearchKeydown"
|
||||
@focus="handleSearchFocus"
|
||||
@focusin="handleSearchFocus"
|
||||
@focusout="handleSearchFocusout"
|
||||
@click="handleSearchClick"
|
||||
>
|
||||
<template v-if="showChevron" #right>
|
||||
@@ -90,7 +91,7 @@
|
||||
leave-to-class="opacity-0"
|
||||
>
|
||||
<div
|
||||
v-if="isOpen"
|
||||
v-if="shouldRenderDropdown"
|
||||
ref="dropdownRef"
|
||||
class="fixed z-[9999] flex flex-col overflow-hidden rounded-[14px] bg-surface-4 border border-solid border-surface-5"
|
||||
:class="[
|
||||
@@ -122,6 +123,7 @@
|
||||
class="group/option flex items-center gap-2.5 cursor-pointer rounded-xl p-3 text-left transition-colors duration-150 text-contrast hover:bg-surface-5 focus:bg-surface-5"
|
||||
:class="getOptionClasses(item, index)"
|
||||
tabindex="-1"
|
||||
@mousedown.prevent
|
||||
@click="handleOptionClick(item, index)"
|
||||
@mouseenter="handleOptionMouseEnter(item, index)"
|
||||
>
|
||||
@@ -225,12 +227,15 @@ const props = withDefaults(
|
||||
showIconInSelected?: boolean
|
||||
maxHeight?: number
|
||||
displayValue?: string
|
||||
searchValue?: string
|
||||
triggerClass?: string
|
||||
forceDirection?: 'up' | 'down'
|
||||
noOptionsMessage?: string
|
||||
disableSearchFilter?: boolean
|
||||
/** Keep the selected option's label in the input after selection, and show all options on focus */
|
||||
syncWithSelection?: boolean
|
||||
/** Select the searchable input text when the field receives focus */
|
||||
selectSearchTextOnFocus?: boolean
|
||||
/** Show a search icon in the searchable input */
|
||||
showSearchIcon?: boolean
|
||||
}>(),
|
||||
@@ -245,6 +250,7 @@ const props = withDefaults(
|
||||
maxHeight: DEFAULT_MAX_HEIGHT,
|
||||
noOptionsMessage: 'No results found',
|
||||
syncWithSelection: true,
|
||||
selectSearchTextOnFocus: false,
|
||||
showSearchIcon: false,
|
||||
},
|
||||
)
|
||||
@@ -256,6 +262,7 @@ const emit = defineEmits<{
|
||||
open: []
|
||||
close: []
|
||||
searchInput: [query: string]
|
||||
searchBlur: [query: string]
|
||||
}>()
|
||||
|
||||
const slots = useSlots()
|
||||
@@ -337,6 +344,14 @@ const filteredOptions = computed(() => {
|
||||
})
|
||||
})
|
||||
|
||||
const hasDropdownContent = computed(() => {
|
||||
return filteredOptions.value.length > 0 || !!searchQuery.value || !!slots['dropdown-footer']
|
||||
})
|
||||
|
||||
const shouldRenderDropdown = computed(() => {
|
||||
return isOpen.value && hasDropdownContent.value
|
||||
})
|
||||
|
||||
function getOptionClasses(item: ComboboxOption<T> & { key: string }, index: number) {
|
||||
return [
|
||||
item.class,
|
||||
@@ -432,7 +447,7 @@ async function updateDropdownPosition() {
|
||||
}
|
||||
|
||||
async function openDropdown() {
|
||||
if (props.disabled || isOpen.value) return
|
||||
if (props.disabled || isOpen.value || !hasDropdownContent.value) return
|
||||
|
||||
isOpen.value = true
|
||||
emit('open')
|
||||
@@ -503,15 +518,20 @@ function handleOptionMouseEnter(option: ComboboxOption<T>, index: number) {
|
||||
|
||||
function findNextFocusableOption(currentIndex: number, direction: 'next' | 'previous'): number {
|
||||
const length = filteredOptions.value.length
|
||||
if (length === 0) return -1
|
||||
|
||||
let index = currentIndex
|
||||
let option
|
||||
|
||||
do {
|
||||
for (let i = 0; i < length; i++) {
|
||||
index = direction === 'next' ? (index + 1) % length : (index - 1 + length) % length
|
||||
option = filteredOptions.value[index]
|
||||
} while (isDivider(option) || option.disabled)
|
||||
const option = filteredOptions.value[index]
|
||||
|
||||
return index
|
||||
if (!isDivider(option) && !option.disabled) {
|
||||
return index
|
||||
}
|
||||
}
|
||||
|
||||
return -1
|
||||
}
|
||||
|
||||
function focusOption(index: number) {
|
||||
@@ -627,12 +647,33 @@ function handleSearchInput() {
|
||||
}
|
||||
}
|
||||
|
||||
function handleSearchFocus() {
|
||||
function handleSearchFocus(event: FocusEvent) {
|
||||
const target = event.target
|
||||
if (props.selectSearchTextOnFocus && target instanceof HTMLInputElement) {
|
||||
window.setTimeout(() => {
|
||||
if (document.activeElement === target) {
|
||||
target.select()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
if (!isOpen.value) {
|
||||
openDropdown()
|
||||
}
|
||||
}
|
||||
|
||||
function handleSearchFocusout(event: FocusEvent) {
|
||||
const nextTarget = event.relatedTarget
|
||||
if (nextTarget instanceof Node && containerRef.value?.contains(nextTarget)) return
|
||||
if (nextTarget instanceof Node && dropdownRef.value?.contains(nextTarget)) return
|
||||
|
||||
emit('searchBlur', searchQuery.value)
|
||||
if (props.searchValue !== undefined) {
|
||||
searchQuery.value = props.searchValue
|
||||
}
|
||||
closeDropdown()
|
||||
}
|
||||
|
||||
function handleSearchClick() {
|
||||
if (!isOpen.value) {
|
||||
openDropdown()
|
||||
@@ -683,12 +724,24 @@ watch(isOpen, (value) => {
|
||||
}
|
||||
})
|
||||
|
||||
watch(shouldRenderDropdown, (value) => {
|
||||
if (value) {
|
||||
updateDropdownPosition()
|
||||
}
|
||||
})
|
||||
|
||||
watch(filteredOptions, () => {
|
||||
if (isOpen.value) {
|
||||
updateDropdownPosition()
|
||||
}
|
||||
})
|
||||
|
||||
watch(hasDropdownContent, (value) => {
|
||||
if (!value && isOpen.value) {
|
||||
closeDropdown()
|
||||
}
|
||||
})
|
||||
|
||||
watch(
|
||||
[() => props.modelValue, () => props.options],
|
||||
([val]) => {
|
||||
|
||||
1050
packages/ui/src/components/base/DatePicker.vue
Normal file
1050
packages/ui/src/components/base/DatePicker.vue
Normal file
File diff suppressed because it is too large
Load Diff
1074
packages/ui/src/components/base/DropdownFilterBar.vue
Normal file
1074
packages/ui/src/components/base/DropdownFilterBar.vue
Normal file
File diff suppressed because it is too large
Load Diff
@@ -50,6 +50,7 @@ import { FolderUpIcon } from '@modrinth/assets'
|
||||
import { fileIsValid } from '@modrinth/utils'
|
||||
import { ref } from 'vue'
|
||||
|
||||
import { useFormatBytes } from '../../composables'
|
||||
import { injectNotificationManager } from '../../providers'
|
||||
|
||||
const { addNotification } = injectNotificationManager()
|
||||
@@ -78,6 +79,8 @@ const props = withDefaults(
|
||||
},
|
||||
)
|
||||
|
||||
const formatBytes = useFormatBytes()
|
||||
|
||||
const files = ref<File[]>([])
|
||||
|
||||
function matchesAccept(file: File, accept?: string): boolean {
|
||||
@@ -129,7 +132,7 @@ function addFiles(incoming: FileList, shouldNotReset = false) {
|
||||
alertOnInvalid: true,
|
||||
}
|
||||
|
||||
files.value = files.value.filter((file) => fileIsValid(file, validationOptions))
|
||||
files.value = files.value.filter((file) => fileIsValid(file, validationOptions, formatBytes))
|
||||
|
||||
if (files.value.length > 0) {
|
||||
emit('change', files.value)
|
||||
|
||||
@@ -12,73 +12,62 @@
|
||||
</label>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
<script setup lang="ts">
|
||||
import { fileIsValid } from '@modrinth/utils'
|
||||
import { defineComponent } from 'vue'
|
||||
import { ref } from 'vue'
|
||||
|
||||
export default defineComponent({
|
||||
props: {
|
||||
prompt: {
|
||||
type: String,
|
||||
default: 'Select file',
|
||||
},
|
||||
multiple: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
accept: {
|
||||
type: String,
|
||||
default: null,
|
||||
},
|
||||
import { useFormatBytes } from '../../composables'
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
prompt?: string
|
||||
multiple?: boolean
|
||||
accept?: string
|
||||
/**
|
||||
* The max file size in bytes
|
||||
*/
|
||||
maxSize: {
|
||||
type: Number,
|
||||
default: null,
|
||||
},
|
||||
showIcon: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
shouldAlwaysReset: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
longStyle: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
disabled: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
maxSize?: number | null
|
||||
showIcon?: boolean
|
||||
shouldAlwaysReset?: boolean
|
||||
longStyle?: boolean
|
||||
disabled?: boolean
|
||||
}>(),
|
||||
{
|
||||
prompt: 'Select file',
|
||||
multiple: false,
|
||||
showIcon: true,
|
||||
shouldAlwaysReset: false,
|
||||
longStyle: false,
|
||||
disabled: false,
|
||||
},
|
||||
emits: ['change'],
|
||||
data() {
|
||||
return {
|
||||
files: [],
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
addFiles(files, shouldNotReset) {
|
||||
if (!shouldNotReset || this.shouldAlwaysReset) {
|
||||
this.files = files
|
||||
}
|
||||
const validationOptions = { maxSize: this.maxSize, alertOnInvalid: true }
|
||||
this.files = [...this.files].filter((file) => fileIsValid(file, validationOptions))
|
||||
if (this.files.length > 0) {
|
||||
this.$emit('change', this.files)
|
||||
}
|
||||
},
|
||||
handleDrop(e) {
|
||||
this.addFiles(e.dataTransfer.files)
|
||||
},
|
||||
handleChange(e) {
|
||||
this.addFiles(e.target.files)
|
||||
},
|
||||
},
|
||||
})
|
||||
)
|
||||
|
||||
const emit = defineEmits<{ change: [files: File[]] }>()
|
||||
|
||||
const formatBytes = useFormatBytes()
|
||||
|
||||
const files = ref<File[]>([])
|
||||
|
||||
function addFiles(incoming: FileList, shouldNotReset = false) {
|
||||
if (!shouldNotReset || props.shouldAlwaysReset) {
|
||||
files.value = Array.from(incoming)
|
||||
}
|
||||
const validationOptions = { maxSize: props.maxSize, alertOnInvalid: true }
|
||||
files.value = files.value.filter((file) => fileIsValid(file, validationOptions, formatBytes))
|
||||
if (files.value.length > 0) {
|
||||
emit('change', files.value)
|
||||
}
|
||||
}
|
||||
|
||||
function handleDrop(e: DragEvent) {
|
||||
addFiles(e.dataTransfer!.files)
|
||||
}
|
||||
|
||||
function handleChange(e: Event) {
|
||||
const input = e.target as HTMLInputElement
|
||||
if (!input.files) return
|
||||
addFiles(input.files)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
|
||||
@@ -8,6 +8,7 @@ const props = defineProps<{
|
||||
shown: boolean
|
||||
ariaLabel?: string
|
||||
belowModal?: boolean
|
||||
hideWhenModalOpen?: boolean
|
||||
}>()
|
||||
|
||||
const INTERCOM_BUBBLE_GAP = 8
|
||||
@@ -18,7 +19,7 @@ const compact = ref(false)
|
||||
|
||||
const { stackCount } = useModalStack()
|
||||
const pageContext = injectPageContext(null)
|
||||
const shown = computed(() => props.shown)
|
||||
const shown = computed(() => props.shown && (!props.hideWhenModalOpen || stackCount.value === 0))
|
||||
const intercomBubbleClearanceRequestId = Symbol('floating-action-bar')
|
||||
const zIndex = computed(() => 100 + stackCount.value * 10 + 8 + (!props.belowModal ? 1 : 0))
|
||||
const leftOffset = computed(
|
||||
@@ -82,11 +83,11 @@ function updateIntercomBubbleClearance() {
|
||||
)
|
||||
}
|
||||
|
||||
function updateBodyState(shown = props.shown) {
|
||||
function updateBodyState(isShown = shown.value) {
|
||||
if (typeof document === 'undefined') return
|
||||
|
||||
document.body.classList.toggle('floating-action-bar-shown', shown)
|
||||
if (!shown) {
|
||||
document.body.classList.toggle('floating-action-bar-shown', isShown)
|
||||
if (!isShown) {
|
||||
clearIntercomBubbleClearance()
|
||||
}
|
||||
}
|
||||
@@ -123,10 +124,10 @@ watch(
|
||||
)
|
||||
|
||||
watch(
|
||||
() => props.shown,
|
||||
async (shown) => {
|
||||
shown,
|
||||
async (isShown) => {
|
||||
await nextTick()
|
||||
updateBodyState(shown)
|
||||
updateBodyState(isShown)
|
||||
scheduleIntercomBubbleClearanceUpdate()
|
||||
},
|
||||
{ immediate: true },
|
||||
@@ -175,7 +176,7 @@ onUnmounted(() => {
|
||||
ref="toolbarEl"
|
||||
role="toolbar"
|
||||
:aria-label="ariaLabel"
|
||||
class="relative overflow-clip flex items-center gap-2 rounded-[20px] bg-surface-3 border border-surface-5 border-solid mx-auto max-w-[60vw] px-4 py-3 shadow-[0px_1px_3px_0px_rgba(0,0,0,0.3),0px_6px_10px_0px_rgba(0,0,0,0.15)]"
|
||||
class="relative overflow-clip flex items-center gap-1.5 rounded-[20px] bg-surface-3 border border-surface-5 border-solid mx-auto max-w-[60vw] px-3 py-2.5 shadow-[0px_1px_3px_0px_rgba(0,0,0,0.3),0px_6px_10px_0px_rgba(0,0,0,0.15)]"
|
||||
:class="{ 'bar-compact': compact }"
|
||||
>
|
||||
<slot />
|
||||
|
||||
@@ -1,21 +1,16 @@
|
||||
<template>
|
||||
<div class="joined-buttons">
|
||||
<ButtonStyled
|
||||
:color="color"
|
||||
:size="size"
|
||||
:class="{ 'joined-buttons__primary--muted': primaryMuted }"
|
||||
>
|
||||
<button :disabled="primaryDisabledResolved" @click="handlePrimaryAction">
|
||||
<ButtonStyled :color="color" :size="size">
|
||||
<button
|
||||
:class="{ 'joined-buttons__primary--muted': primaryMuted }"
|
||||
:disabled="primaryDisabledResolved"
|
||||
@click="handlePrimaryAction"
|
||||
>
|
||||
<component :is="primaryAction.icon" v-if="primaryAction.icon" aria-hidden="true" />
|
||||
{{ primaryAction.label }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled
|
||||
v-if="dropdownActions.length > 0"
|
||||
:color="color"
|
||||
:size="size"
|
||||
class="joined-buttons__dropdown"
|
||||
>
|
||||
<ButtonStyled v-if="dropdownActions.length > 0" :color="color" :size="size">
|
||||
<OverflowMenu
|
||||
class="btn-dropdown-animation !w-10"
|
||||
:options="dropdownOptions"
|
||||
|
||||
@@ -1,39 +1,41 @@
|
||||
<template>
|
||||
<NewModal ref="linkModal" header="Insert link">
|
||||
<NewModal ref="linkModal" :header="formatMessage(messages.linkModalHeader)" class="!w-[40rem]">
|
||||
<div class="modal-insert">
|
||||
<label class="label" for="insert-link-label">
|
||||
<span class="label__title">Label</span>
|
||||
<span class="label__title">{{ formatMessage(messages.linkModalLabelFieldTitle) }}</span>
|
||||
</label>
|
||||
<StyledInput
|
||||
id="insert-link-label"
|
||||
v-model="linkText"
|
||||
:icon="AlignLeftIcon"
|
||||
type="text"
|
||||
placeholder="Enter label..."
|
||||
:placeholder="formatMessage(messages.linkModalLabelFieldPlaceholder)"
|
||||
clearable
|
||||
wrapper-class="w-full"
|
||||
/>
|
||||
<label class="label" for="insert-link-url">
|
||||
<span class="label__title">URL<span class="required">*</span></span>
|
||||
<span class="label__title">
|
||||
{{ formatMessage(messages.urlLabel) }}<span class="required">*</span>
|
||||
</span>
|
||||
</label>
|
||||
<StyledInput
|
||||
id="insert-link-url"
|
||||
v-model="linkUrl"
|
||||
:icon="LinkIcon"
|
||||
type="text"
|
||||
placeholder="Enter the link's URL..."
|
||||
:placeholder="formatMessage(messages.linkModalUrlFieldPlaceholder)"
|
||||
clearable
|
||||
wrapper-class="w-full"
|
||||
@input="validateURL"
|
||||
/>
|
||||
<template v-if="linkValidationErrorMessage">
|
||||
<span class="label">
|
||||
<span class="label__title">Error</span>
|
||||
<span class="label__title">{{ formatMessage(messages.errorLabel) }}</span>
|
||||
<span class="label__description">{{ linkValidationErrorMessage }}</span>
|
||||
</span>
|
||||
</template>
|
||||
<span class="label">
|
||||
<span class="label__title">Preview</span>
|
||||
<span class="label__title">{{ formatMessage(messages.previewLabel) }}</span>
|
||||
<span class="label__description"></span>
|
||||
</span>
|
||||
<div class="markdown-body-wrapper">
|
||||
@@ -43,28 +45,37 @@
|
||||
v-html="renderHighlightedString(linkMarkdown)"
|
||||
/>
|
||||
</div>
|
||||
<div class="input-group push-right">
|
||||
<Button :action="() => linkModal?.hide()"><XIcon /> Cancel</Button>
|
||||
<Button
|
||||
color="primary"
|
||||
:disabled="!!linkValidationErrorMessage || !linkUrl"
|
||||
:action="
|
||||
() => {
|
||||
if (editor) markdownCommands.replaceSelection(editor, linkMarkdown)
|
||||
linkModal?.hide()
|
||||
}
|
||||
"
|
||||
><PlusIcon /> Insert</Button
|
||||
>
|
||||
<div class="flex gap-2 justify-end mt-4">
|
||||
<ButtonStyled type="outlined">
|
||||
<button @click="() => linkModal?.hide()">
|
||||
<XIcon /> {{ formatMessage(commonMessages.cancelButton) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled color="brand">
|
||||
<button
|
||||
:disabled="!!linkValidationErrorMessage || !linkUrl"
|
||||
@click="
|
||||
() => {
|
||||
if (editor) markdownCommands.replaceSelection(editor, linkMarkdown)
|
||||
linkModal?.hide()
|
||||
}
|
||||
"
|
||||
>
|
||||
<PlusIcon /> {{ formatMessage(messages.insertButton) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</div>
|
||||
</NewModal>
|
||||
<NewModal ref="imageModal" header="Insert image">
|
||||
<NewModal ref="imageModal" :header="formatMessage(messages.imageModalHeader)" class="!w-[40rem]">
|
||||
<div class="modal-insert">
|
||||
<label class="label" for="insert-image-alt">
|
||||
<span class="label__title">Description (alt text)<span class="required">*</span></span>
|
||||
<span class="label__title">
|
||||
{{ formatMessage(messages.imageModalDescriptionFieldTitle) }}
|
||||
<span class="required">*</span>
|
||||
</span>
|
||||
<span class="label__description">
|
||||
Describe the image completely as you would to someone who could not see the image.
|
||||
{{ formatMessage(messages.imageModalDescriptionFieldDescription) }}
|
||||
</span>
|
||||
</label>
|
||||
<StyledInput
|
||||
@@ -72,15 +83,22 @@
|
||||
v-model="linkText"
|
||||
:icon="AlignLeftIcon"
|
||||
type="text"
|
||||
placeholder="Describe the image..."
|
||||
:placeholder="formatMessage(messages.imageModalDescriptionFieldPlaceholder)"
|
||||
clearable
|
||||
wrapper-class="w-full"
|
||||
/>
|
||||
<label class="label" for="insert-link-url">
|
||||
<span class="label__title">URL<span class="required">*</span></span>
|
||||
<span class="label__title">
|
||||
{{ formatMessage(messages.urlLabel) }}<span class="required">*</span>
|
||||
</span>
|
||||
</label>
|
||||
<div v-if="props.onImageUpload" class="image-strategy-chips">
|
||||
<Chips v-model="imageUploadOption" :items="['upload', 'link']" />
|
||||
<Chips
|
||||
v-model="imageUploadOption"
|
||||
:items="['upload', 'link']"
|
||||
:format-label="formatImageUploadOption"
|
||||
:aria-label="formatMessage(messages.imageModalUploadModeLabel)"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
v-if="props.onImageUpload && imageUploadOption === 'upload'"
|
||||
@@ -88,7 +106,7 @@
|
||||
>
|
||||
<FileInput
|
||||
accept="image/png,image/jpeg,image/gif,image/webp"
|
||||
prompt="Drag and drop to upload or click to select file"
|
||||
:prompt="formatMessage(messages.imageModalUploadPrompt)"
|
||||
long-style
|
||||
should-always-reset
|
||||
class="file-input"
|
||||
@@ -103,19 +121,19 @@
|
||||
v-model="linkUrl"
|
||||
:icon="ImageIcon"
|
||||
type="text"
|
||||
placeholder="Enter the image URL..."
|
||||
:placeholder="formatMessage(messages.imageModalUrlFieldPlaceholder)"
|
||||
clearable
|
||||
wrapper-class="w-full"
|
||||
@input="validateURL"
|
||||
/>
|
||||
<template v-if="linkValidationErrorMessage">
|
||||
<span class="label">
|
||||
<span class="label__title">Error</span>
|
||||
<span class="label__title">{{ formatMessage(messages.errorLabel) }}</span>
|
||||
<span class="label__description">{{ linkValidationErrorMessage }}</span>
|
||||
</span>
|
||||
</template>
|
||||
<span class="label">
|
||||
<span class="label__title">Preview</span>
|
||||
<span class="label__title">{{ formatMessage(messages.previewLabel) }}</span>
|
||||
<span class="label__description"></span>
|
||||
</span>
|
||||
<div class="markdown-body-wrapper">
|
||||
@@ -125,47 +143,56 @@
|
||||
v-html="renderHighlightedString(imageMarkdown)"
|
||||
/>
|
||||
</div>
|
||||
<div class="input-group push-right">
|
||||
<Button :action="() => imageModal?.hide()"><XIcon /> Cancel</Button>
|
||||
<Button
|
||||
color="primary"
|
||||
:disabled="!canInsertImage"
|
||||
:action="
|
||||
() => {
|
||||
if (editor) markdownCommands.replaceSelection(editor, imageMarkdown)
|
||||
imageModal?.hide()
|
||||
}
|
||||
"
|
||||
>
|
||||
<PlusIcon /> Insert
|
||||
</Button>
|
||||
<div class="flex gap-2 justify-end mt-4">
|
||||
<ButtonStyled type="outlined">
|
||||
<button @click="() => imageModal?.hide()">
|
||||
<XIcon /> {{ formatMessage(commonMessages.cancelButton) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled color="brand">
|
||||
<button
|
||||
:disabled="!canInsertImage"
|
||||
@click="
|
||||
() => {
|
||||
if (editor) markdownCommands.replaceSelection(editor, imageMarkdown)
|
||||
imageModal?.hide()
|
||||
}
|
||||
"
|
||||
>
|
||||
<PlusIcon /> {{ formatMessage(messages.insertButton) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</div>
|
||||
</NewModal>
|
||||
<NewModal ref="videoModal" header="Insert YouTube video">
|
||||
<NewModal ref="videoModal" :header="formatMessage(messages.videoModalHeader)" class="!w-[40rem]">
|
||||
<div class="modal-insert">
|
||||
<label class="label" for="insert-video-url">
|
||||
<span class="label__title">YouTube video URL<span class="required">*</span></span>
|
||||
<span class="label__description"> Enter a valid link to a YouTube video. </span>
|
||||
<span class="label__title">
|
||||
{{ formatMessage(messages.videoModalUrlFieldTitle) }}<span class="required">*</span>
|
||||
</span>
|
||||
<span class="label__description">
|
||||
{{ formatMessage(messages.videoModalUrlFieldDescription) }}
|
||||
</span>
|
||||
</label>
|
||||
<StyledInput
|
||||
id="insert-video-url"
|
||||
v-model="linkUrl"
|
||||
:icon="YouTubeIcon"
|
||||
type="text"
|
||||
placeholder="Enter YouTube video URL"
|
||||
:placeholder="formatMessage(messages.videoModalUrlFieldPlaceholder)"
|
||||
clearable
|
||||
wrapper-class="w-full"
|
||||
@input="validateURL"
|
||||
/>
|
||||
<template v-if="linkValidationErrorMessage">
|
||||
<span class="label">
|
||||
<span class="label__title">Error</span>
|
||||
<span class="label__title">{{ formatMessage(messages.errorLabel) }}</span>
|
||||
<span class="label__description">{{ linkValidationErrorMessage }}</span>
|
||||
</span>
|
||||
</template>
|
||||
<span class="label">
|
||||
<span class="label__title">Preview</span>
|
||||
<span class="label__title">{{ formatMessage(messages.previewLabel) }}</span>
|
||||
<span class="label__description"></span>
|
||||
</span>
|
||||
|
||||
@@ -176,47 +203,57 @@
|
||||
v-html="renderHighlightedString(videoMarkdown)"
|
||||
/>
|
||||
</div>
|
||||
<div class="input-group push-right">
|
||||
<Button :action="() => videoModal?.hide()"><XIcon /> Cancel</Button>
|
||||
<Button
|
||||
color="primary"
|
||||
:disabled="!!linkValidationErrorMessage || !linkUrl"
|
||||
:action="
|
||||
() => {
|
||||
if (editor) markdownCommands.replaceSelection(editor, videoMarkdown)
|
||||
videoModal?.hide()
|
||||
}
|
||||
"
|
||||
>
|
||||
<PlusIcon /> Insert
|
||||
</Button>
|
||||
<div class="flex gap-2 justify-end mt-4">
|
||||
<ButtonStyled type="outlined">
|
||||
<button @click="() => videoModal?.hide()">
|
||||
<XIcon /> {{ formatMessage(commonMessages.cancelButton) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled color="brand">
|
||||
<button
|
||||
:disabled="!!linkValidationErrorMessage || !linkUrl"
|
||||
@click="
|
||||
() => {
|
||||
if (editor) markdownCommands.replaceSelection(editor, videoMarkdown)
|
||||
videoModal?.hide()
|
||||
}
|
||||
"
|
||||
>
|
||||
<PlusIcon /> {{ formatMessage(messages.insertButton) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</div>
|
||||
</NewModal>
|
||||
<div class="block grow w-full">
|
||||
<div class="editor-action-row">
|
||||
<div class="editor-actions">
|
||||
<template
|
||||
v-for="(buttonGroup, _i) in Object.values(BUTTONS).filter((bg) => bg.display)"
|
||||
:key="_i"
|
||||
>
|
||||
<div class="divider"></div>
|
||||
<template v-for="button in buttonGroup.buttons" :key="button.label">
|
||||
<Button
|
||||
v-tooltip="button.label"
|
||||
icon-only
|
||||
:aria-label="button.label"
|
||||
:class="{ 'mobile-hidden-group': !!buttonGroup.hideOnMobile }"
|
||||
:action="() => button.action(editor)"
|
||||
:disabled="previewMode || disabled"
|
||||
>
|
||||
<component :is="button.icon" />
|
||||
</Button>
|
||||
<div class="editor-action-row w-full">
|
||||
<div class="w-full flex justify-between items-center flex-wrap gap-2">
|
||||
<div class="editor-actions">
|
||||
<template
|
||||
v-for="(buttonGroup, _i) in Object.values(BUTTONS).filter((bg) => bg.display)"
|
||||
:key="_i"
|
||||
>
|
||||
<div class="divider"></div>
|
||||
<template v-for="button in buttonGroup.buttons" :key="button.label.id">
|
||||
<ButtonStyled circular>
|
||||
<button
|
||||
v-tooltip="formatMessage(button.label)"
|
||||
:aria-label="formatMessage(button.label)"
|
||||
:class="{ 'mobile-hidden-group': !!buttonGroup.hideOnMobile }"
|
||||
:disabled="previewMode || disabled"
|
||||
@click="() => button.action(editor)"
|
||||
>
|
||||
<component :is="button.icon" />
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</template>
|
||||
</template>
|
||||
</template>
|
||||
<div class="preview">
|
||||
<Toggle id="preview" v-model="previewMode" />
|
||||
<label class="label" for="preview"> Preview </label>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<Toggle id="preview" v-model="previewMode" small />
|
||||
<label class="label" for="preview">
|
||||
{{ formatMessage(messages.editorPreviewToggleLabel) }}
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -224,20 +261,29 @@
|
||||
<div v-if="!previewMode" class="info-blurb mt-2">
|
||||
<div class="info-blurb">
|
||||
<InfoIcon />
|
||||
<span
|
||||
>This editor supports
|
||||
<a
|
||||
class="markdown-resource-link"
|
||||
href="https://support.modrinth.com/en/articles/8801962-advanced-markdown-formatting"
|
||||
target="_blank"
|
||||
>Markdown formatting</a
|
||||
>.</span
|
||||
>
|
||||
<IntlFormatted :message-id="messages.editorMarkdownFormattingSupport">
|
||||
<template #markdown-link="{ children }">
|
||||
<a
|
||||
class="markdown-resource-link"
|
||||
href="https://support.modrinth.com/en/articles/8801962-advanced-markdown-formatting"
|
||||
target="_blank"
|
||||
>
|
||||
<component :is="() => children" />
|
||||
</a>
|
||||
</template>
|
||||
</IntlFormatted>
|
||||
</div>
|
||||
<div :class="{ hide: !props.maxLength }" class="max-length-label">
|
||||
<span>Max length: </span>
|
||||
<span>{{ formatMessage(messages.editorMaxLengthLabel) }} </span>
|
||||
<span>
|
||||
{{ props.maxLength ? `${currentValue?.length || 0}/${props.maxLength}` : 'Unlimited' }}
|
||||
{{
|
||||
props.maxLength
|
||||
? formatMessage(messages.editorMaxLengthValue, {
|
||||
currentLength: currentValue?.length || 0,
|
||||
maxLength: props.maxLength,
|
||||
})
|
||||
: formatMessage(messages.editorMaxLengthUnlimited)
|
||||
}}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -287,13 +333,214 @@ import { markdownCommands, modrinthMarkdownEditorKeymap } from '@modrinth/utils/
|
||||
import { renderHighlightedString } from '@modrinth/utils/highlightjs'
|
||||
import { type Component, computed, onBeforeUnmount, onMounted, ref, toRef, watch } from 'vue'
|
||||
|
||||
import { defineMessages, type MessageDescriptor, useVIntl } from '../../composables/i18n'
|
||||
import { commonMessages } from '../../utils/common-messages.ts'
|
||||
import NewModal from '../modal/NewModal.vue'
|
||||
import Button from './Button.vue'
|
||||
import ButtonStyled from './ButtonStyled.vue'
|
||||
import Chips from './Chips.vue'
|
||||
import FileInput from './FileInput.vue'
|
||||
import IntlFormatted from './IntlFormatted.vue'
|
||||
import StyledInput from './StyledInput.vue'
|
||||
import Toggle from './Toggle.vue'
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
|
||||
const messages = defineMessages({
|
||||
insertButton: {
|
||||
id: 'markdown-editor.insert-button',
|
||||
defaultMessage: 'Insert',
|
||||
},
|
||||
urlLabel: {
|
||||
id: 'markdown-editor.url-label',
|
||||
defaultMessage: 'URL',
|
||||
},
|
||||
errorLabel: {
|
||||
id: 'markdown-editor.error-label',
|
||||
defaultMessage: 'Error',
|
||||
},
|
||||
previewLabel: {
|
||||
id: 'markdown-editor.preview-label',
|
||||
defaultMessage: 'Preview',
|
||||
},
|
||||
linkModalHeader: {
|
||||
id: 'markdown-editor.link-modal.header',
|
||||
defaultMessage: 'Insert link',
|
||||
},
|
||||
linkModalLabelFieldTitle: {
|
||||
id: 'markdown-editor.link-modal.label-field.title',
|
||||
defaultMessage: 'Label',
|
||||
},
|
||||
linkModalLabelFieldPlaceholder: {
|
||||
id: 'markdown-editor.link-modal.label-field.placeholder',
|
||||
defaultMessage: 'Enter label...',
|
||||
},
|
||||
linkModalUrlFieldPlaceholder: {
|
||||
id: 'markdown-editor.link-modal.url-field.placeholder',
|
||||
defaultMessage: "Enter the link's URL...",
|
||||
},
|
||||
imageModalHeader: {
|
||||
id: 'markdown-editor.image-modal.header',
|
||||
defaultMessage: 'Insert image',
|
||||
},
|
||||
imageModalDescriptionFieldTitle: {
|
||||
id: 'markdown-editor.image-modal.description-field.title',
|
||||
defaultMessage: 'Description (alt text)',
|
||||
},
|
||||
imageModalDescriptionFieldDescription: {
|
||||
id: 'markdown-editor.image-modal.description-field.description',
|
||||
defaultMessage:
|
||||
'Describe the image completely as you would to someone who could not see the image.',
|
||||
},
|
||||
imageModalDescriptionFieldPlaceholder: {
|
||||
id: 'markdown-editor.image-modal.description-field.placeholder',
|
||||
defaultMessage: 'Describe the image...',
|
||||
},
|
||||
imageModalUploadModeLabel: {
|
||||
id: 'markdown-editor.image-modal.upload-mode.label',
|
||||
defaultMessage: 'Image source',
|
||||
},
|
||||
imageModalUploadModeUpload: {
|
||||
id: 'markdown-editor.image-modal.upload-mode.upload',
|
||||
defaultMessage: 'Upload',
|
||||
},
|
||||
imageModalUploadModeLink: {
|
||||
id: 'markdown-editor.image-modal.upload-mode.link',
|
||||
defaultMessage: 'Link',
|
||||
},
|
||||
imageModalUploadPrompt: {
|
||||
id: 'markdown-editor.image-modal.upload.prompt',
|
||||
defaultMessage: 'Drag and drop to upload or click to select file',
|
||||
},
|
||||
imageModalUrlFieldPlaceholder: {
|
||||
id: 'markdown-editor.image-modal.url-field.placeholder',
|
||||
defaultMessage: 'Enter the image URL...',
|
||||
},
|
||||
videoModalHeader: {
|
||||
id: 'markdown-editor.video-modal.header',
|
||||
defaultMessage: 'Insert YouTube video',
|
||||
},
|
||||
videoModalUrlFieldTitle: {
|
||||
id: 'markdown-editor.video-modal.url-field.title',
|
||||
defaultMessage: 'YouTube video URL',
|
||||
},
|
||||
videoModalUrlFieldDescription: {
|
||||
id: 'markdown-editor.video-modal.url-field.description',
|
||||
defaultMessage: 'Enter a valid link to a YouTube video.',
|
||||
},
|
||||
videoModalUrlFieldPlaceholder: {
|
||||
id: 'markdown-editor.video-modal.url-field.placeholder',
|
||||
defaultMessage: 'Enter YouTube video URL',
|
||||
},
|
||||
editorPreviewToggleLabel: {
|
||||
id: 'markdown-editor.preview-toggle.label',
|
||||
defaultMessage: 'Preview',
|
||||
},
|
||||
editorMarkdownFormattingSupport: {
|
||||
id: 'markdown-editor.markdown-formatting-support',
|
||||
defaultMessage: 'This editor supports <markdown-link>Markdown formatting</markdown-link>.',
|
||||
},
|
||||
editorMaxLengthLabel: {
|
||||
id: 'markdown-editor.max-length.label',
|
||||
defaultMessage: 'Max length:',
|
||||
},
|
||||
editorMaxLengthValue: {
|
||||
id: 'markdown-editor.max-length.value',
|
||||
defaultMessage: '{currentLength}/{maxLength}',
|
||||
},
|
||||
editorMaxLengthUnlimited: {
|
||||
id: 'markdown-editor.max-length.unlimited',
|
||||
defaultMessage: 'Unlimited',
|
||||
},
|
||||
editorPlaceholder: {
|
||||
id: 'markdown-editor.placeholder',
|
||||
defaultMessage: 'Write something...',
|
||||
},
|
||||
toolbarHeading1: {
|
||||
id: 'markdown-editor.toolbar.heading-1',
|
||||
defaultMessage: 'Heading 1',
|
||||
},
|
||||
toolbarHeading2: {
|
||||
id: 'markdown-editor.toolbar.heading-2',
|
||||
defaultMessage: 'Heading 2',
|
||||
},
|
||||
toolbarHeading3: {
|
||||
id: 'markdown-editor.toolbar.heading-3',
|
||||
defaultMessage: 'Heading 3',
|
||||
},
|
||||
toolbarBold: {
|
||||
id: 'markdown-editor.toolbar.bold',
|
||||
defaultMessage: 'Bold',
|
||||
},
|
||||
toolbarItalic: {
|
||||
id: 'markdown-editor.toolbar.italic',
|
||||
defaultMessage: 'Italic',
|
||||
},
|
||||
toolbarStrikethrough: {
|
||||
id: 'markdown-editor.toolbar.strikethrough',
|
||||
defaultMessage: 'Strikethrough',
|
||||
},
|
||||
toolbarCode: {
|
||||
id: 'markdown-editor.toolbar.code',
|
||||
defaultMessage: 'Code',
|
||||
},
|
||||
toolbarSpoiler: {
|
||||
id: 'markdown-editor.toolbar.spoiler',
|
||||
defaultMessage: 'Spoiler',
|
||||
},
|
||||
toolbarBulletedList: {
|
||||
id: 'markdown-editor.toolbar.bulleted-list',
|
||||
defaultMessage: 'Bulleted list',
|
||||
},
|
||||
toolbarOrderedList: {
|
||||
id: 'markdown-editor.toolbar.ordered-list',
|
||||
defaultMessage: 'Ordered list',
|
||||
},
|
||||
toolbarQuote: {
|
||||
id: 'markdown-editor.toolbar.quote',
|
||||
defaultMessage: 'Quote',
|
||||
},
|
||||
toolbarLink: {
|
||||
id: 'markdown-editor.toolbar.link',
|
||||
defaultMessage: 'Link',
|
||||
},
|
||||
toolbarImage: {
|
||||
id: 'markdown-editor.toolbar.image',
|
||||
defaultMessage: 'Image',
|
||||
},
|
||||
toolbarVideo: {
|
||||
id: 'markdown-editor.toolbar.video',
|
||||
defaultMessage: 'Video',
|
||||
},
|
||||
videoEmbedTitle: {
|
||||
id: 'markdown-editor.video-embed.title',
|
||||
defaultMessage: 'YouTube video player',
|
||||
},
|
||||
urlValidationErrorMalformed: {
|
||||
id: 'markdown-editor.url-validation-error.malformed',
|
||||
defaultMessage: 'Invalid URL. Make sure the URL is well-formed.',
|
||||
},
|
||||
urlValidationErrorUnsupportedProtocol: {
|
||||
id: 'markdown-editor.url-validation-error.unsupported-protocol',
|
||||
defaultMessage: 'Unsupported protocol. Use http or https.',
|
||||
},
|
||||
urlValidationErrorBlockedDomain: {
|
||||
id: 'markdown-editor.url-validation-error.blocked-domain',
|
||||
defaultMessage: 'Invalid URL. This domain is not allowed.',
|
||||
},
|
||||
uploadErrorNoHandler: {
|
||||
id: 'markdown-editor.upload-error.no-handler',
|
||||
defaultMessage: 'No image upload handler provided',
|
||||
},
|
||||
uploadErrorNoFile: {
|
||||
id: 'markdown-editor.upload-error.no-file',
|
||||
defaultMessage: 'No file provided',
|
||||
},
|
||||
defaultImageAltText: {
|
||||
id: 'markdown-editor.default-image-alt-text',
|
||||
defaultMessage: 'Replace this with a description',
|
||||
},
|
||||
})
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
modelValue: string
|
||||
@@ -314,7 +561,7 @@ const props = withDefaults(
|
||||
disabled: false,
|
||||
headingButtons: true,
|
||||
onImageUpload: undefined,
|
||||
placeholder: 'Write something...',
|
||||
placeholder: undefined,
|
||||
maxLength: undefined,
|
||||
maxHeight: undefined,
|
||||
minHeight: undefined,
|
||||
@@ -327,6 +574,9 @@ let isDisabledCompartment: Compartment | null = null
|
||||
let editorThemeCompartment: Compartment | null = null
|
||||
|
||||
const emit = defineEmits(['update:modelValue'])
|
||||
const resolvedPlaceholder = computed(
|
||||
() => props.placeholder ?? formatMessage(messages.editorPlaceholder),
|
||||
)
|
||||
|
||||
onMounted(() => {
|
||||
const updateListener = EditorView.updateListener.of((update) => {
|
||||
@@ -382,7 +632,7 @@ onMounted(() => {
|
||||
uploadImagesFromList(clipboardData.files)
|
||||
.then(function (url) {
|
||||
const selection = markdownCommands.yankSelection(view)
|
||||
const altText = selection || 'Replace this with a description'
|
||||
const altText = selection || formatMessage(messages.defaultImageAltText)
|
||||
const linkMarkdown = ``
|
||||
return markdownCommands.replaceSelection(view, linkMarkdown)
|
||||
})
|
||||
@@ -455,7 +705,7 @@ onMounted(() => {
|
||||
addKeymap: false,
|
||||
}),
|
||||
keymap.of(historyKeymap),
|
||||
cm_placeholder(props.placeholder || ''),
|
||||
cm_placeholder(resolvedPlaceholder.value),
|
||||
inputFilter,
|
||||
isDisabledCompartment.of(disabledCompartment),
|
||||
editorThemeCompartment.of(theme),
|
||||
@@ -483,7 +733,7 @@ onBeforeUnmount(() => {
|
||||
})
|
||||
|
||||
type ButtonAction = {
|
||||
label: string
|
||||
label: MessageDescriptor
|
||||
icon: Component
|
||||
action: (editor: EditorView | null) => void
|
||||
}
|
||||
@@ -504,12 +754,12 @@ function runEditorCommand(command: (view: EditorView) => boolean, editor: Editor
|
||||
}
|
||||
|
||||
const composeCommandButton = (
|
||||
name: string,
|
||||
label: MessageDescriptor,
|
||||
icon: Component,
|
||||
command: (view: EditorView) => boolean,
|
||||
) => {
|
||||
return {
|
||||
label: name,
|
||||
label,
|
||||
icon,
|
||||
action: (e: EditorView | null) => runEditorCommand(command, e),
|
||||
}
|
||||
@@ -520,33 +770,41 @@ const BUTTONS: ButtonGroupMap = {
|
||||
display: props.headingButtons,
|
||||
hideOnMobile: false,
|
||||
buttons: [
|
||||
composeCommandButton('Heading 1', Heading1Icon, markdownCommands.toggleHeader),
|
||||
composeCommandButton('Heading 2', Heading2Icon, markdownCommands.toggleHeader2),
|
||||
composeCommandButton('Heading 3', Heading3Icon, markdownCommands.toggleHeader3),
|
||||
composeCommandButton(messages.toolbarHeading1, Heading1Icon, markdownCommands.toggleHeader),
|
||||
composeCommandButton(messages.toolbarHeading2, Heading2Icon, markdownCommands.toggleHeader2),
|
||||
composeCommandButton(messages.toolbarHeading3, Heading3Icon, markdownCommands.toggleHeader3),
|
||||
],
|
||||
},
|
||||
stylizing: {
|
||||
display: true,
|
||||
hideOnMobile: false,
|
||||
buttons: [
|
||||
composeCommandButton('Bold', BoldIcon, markdownCommands.toggleBold),
|
||||
composeCommandButton('Italic', ItalicIcon, markdownCommands.toggleItalic),
|
||||
composeCommandButton(messages.toolbarBold, BoldIcon, markdownCommands.toggleBold),
|
||||
composeCommandButton(messages.toolbarItalic, ItalicIcon, markdownCommands.toggleItalic),
|
||||
composeCommandButton(
|
||||
'Strikethrough',
|
||||
messages.toolbarStrikethrough,
|
||||
StrikethroughIcon,
|
||||
markdownCommands.toggleStrikethrough,
|
||||
),
|
||||
composeCommandButton('Code', CodeIcon, markdownCommands.toggleCodeBlock),
|
||||
composeCommandButton('Spoiler', ScanEyeIcon, markdownCommands.toggleSpoiler),
|
||||
composeCommandButton(messages.toolbarCode, CodeIcon, markdownCommands.toggleCodeBlock),
|
||||
composeCommandButton(messages.toolbarSpoiler, ScanEyeIcon, markdownCommands.toggleSpoiler),
|
||||
],
|
||||
},
|
||||
lists: {
|
||||
display: true,
|
||||
hideOnMobile: false,
|
||||
buttons: [
|
||||
composeCommandButton('Bulleted list', ListBulletedIcon, markdownCommands.toggleBulletList),
|
||||
composeCommandButton('Ordered list', ListOrderedIcon, markdownCommands.toggleOrderedList),
|
||||
composeCommandButton('Quote', TextQuoteIcon, markdownCommands.toggleQuote),
|
||||
composeCommandButton(
|
||||
messages.toolbarBulletedList,
|
||||
ListBulletedIcon,
|
||||
markdownCommands.toggleBulletList,
|
||||
),
|
||||
composeCommandButton(
|
||||
messages.toolbarOrderedList,
|
||||
ListOrderedIcon,
|
||||
markdownCommands.toggleOrderedList,
|
||||
),
|
||||
composeCommandButton(messages.toolbarQuote, TextQuoteIcon, markdownCommands.toggleQuote),
|
||||
],
|
||||
},
|
||||
components: {
|
||||
@@ -554,17 +812,17 @@ const BUTTONS: ButtonGroupMap = {
|
||||
hideOnMobile: false,
|
||||
buttons: [
|
||||
{
|
||||
label: 'Link',
|
||||
label: messages.toolbarLink,
|
||||
icon: LinkIcon,
|
||||
action: () => openLinkModal(),
|
||||
},
|
||||
{
|
||||
label: 'Image',
|
||||
label: messages.toolbarImage,
|
||||
icon: ImageIcon,
|
||||
action: () => openImageModal(),
|
||||
},
|
||||
{
|
||||
label: 'Video',
|
||||
label: messages.toolbarVideo,
|
||||
icon: YouTubeIcon,
|
||||
action: () => openVideoModal(),
|
||||
},
|
||||
@@ -682,12 +940,12 @@ function cleanUrl(input: string): string {
|
||||
try {
|
||||
url = new URL(input)
|
||||
} catch {
|
||||
throw new Error('Invalid URL. Make sure the URL is well-formed.')
|
||||
throw new Error(formatMessage(messages.urlValidationErrorMalformed))
|
||||
}
|
||||
|
||||
// Check for unsupported protocols
|
||||
if (url.protocol !== 'http:' && url.protocol !== 'https:') {
|
||||
throw new Error('Unsupported protocol. Use http or https.')
|
||||
throw new Error(formatMessage(messages.urlValidationErrorUnsupportedProtocol))
|
||||
}
|
||||
|
||||
// If the scheme is "http", automatically upgrade it to "https"
|
||||
@@ -698,7 +956,7 @@ function cleanUrl(input: string): string {
|
||||
// Block certain domains for compliance
|
||||
const blockedDomains = ['forgecdn', 'cdn.discordapp', 'media.discordapp']
|
||||
if (blockedDomains.some((domain) => url.hostname.includes(domain))) {
|
||||
throw new Error('Invalid URL. This domain is not allowed.')
|
||||
throw new Error(formatMessage(messages.urlValidationErrorBlockedDomain))
|
||||
}
|
||||
|
||||
return url.toString()
|
||||
@@ -722,7 +980,7 @@ const linkMarkdown = computed(() => {
|
||||
const uploadImagesFromList = async (files: FileList): Promise<string> => {
|
||||
const file = files[0]
|
||||
if (!props.onImageUpload) {
|
||||
throw new Error('No image upload handler provided')
|
||||
throw new Error(formatMessage(messages.uploadErrorNoHandler))
|
||||
}
|
||||
if (file) {
|
||||
try {
|
||||
@@ -735,7 +993,7 @@ const uploadImagesFromList = async (files: FileList): Promise<string> => {
|
||||
}
|
||||
}
|
||||
}
|
||||
throw new Error('No file provided')
|
||||
throw new Error(formatMessage(messages.uploadErrorNoFile))
|
||||
}
|
||||
|
||||
const handleImageUpload = async (files: FileList) => {
|
||||
@@ -754,6 +1012,15 @@ const handleImageUpload = async (files: FileList) => {
|
||||
}
|
||||
|
||||
const imageUploadOption = ref<string>('upload')
|
||||
function formatImageUploadOption(option: string) {
|
||||
if (option === 'upload') {
|
||||
return formatMessage(messages.imageModalUploadModeUpload)
|
||||
}
|
||||
if (option === 'link') {
|
||||
return formatMessage(messages.imageModalUploadModeLink)
|
||||
}
|
||||
return option
|
||||
}
|
||||
const imageMarkdown = computed(() => (linkMarkdown.value.length ? `!${linkMarkdown.value}` : ''))
|
||||
|
||||
const canInsertImage = computed(() => {
|
||||
@@ -770,7 +1037,7 @@ const youtubeRegex =
|
||||
const videoMarkdown = computed(() => {
|
||||
const match = youtubeRegex.exec(linkUrl.value)
|
||||
if (match) {
|
||||
return `<iframe width="560" height="315" src="https://www.youtube-nocookie.com/embed/${match[1]}" title="YouTube video player" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share" allowfullscreen></iframe>`
|
||||
return `<iframe width="560" height="315" src="https://www.youtube-nocookie.com/embed/${match[1]}" title="${formatMessage(messages.videoEmbedTitle)}" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share" allowfullscreen></iframe>`
|
||||
}
|
||||
return ''
|
||||
})
|
||||
@@ -913,11 +1180,13 @@ function openVideoModal() {
|
||||
}
|
||||
|
||||
.modal-insert {
|
||||
padding: var(--gap-lg);
|
||||
|
||||
.label {
|
||||
margin-block: var(--gap-lg) var(--gap-sm);
|
||||
display: block;
|
||||
|
||||
&:first-child {
|
||||
margin-top: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.label__title {
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
<template>
|
||||
<div ref="containerRef" class="relative inline-block w-full">
|
||||
<div ref="containerRef" class="relative inline-block" :class="fitContent ? 'w-auto' : 'w-full'">
|
||||
<span
|
||||
ref="triggerRef"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
class="relative flex w-full items-center overflow-hidden rounded-xl bg-surface-4 px-3 py-1 text-left transition-all duration-200"
|
||||
class="relative flex items-center overflow-hidden rounded-xl bg-surface-4 px-3 py-1 text-left transition-all duration-200"
|
||||
:class="[
|
||||
fitContent ? 'w-auto max-w-full' : 'w-full',
|
||||
triggerClass,
|
||||
{
|
||||
'z-[9999]': isOpen,
|
||||
@@ -19,68 +20,80 @@
|
||||
@click="handleTriggerClick($event)"
|
||||
@keydown="handleTriggerKeydown"
|
||||
>
|
||||
<div
|
||||
ref="tagsContainerRef"
|
||||
class="flex flex-1 items-center gap-1.5 overflow-hidden flex-wrap min-h-8"
|
||||
:style="{ maxHeight: `calc(${maxTagRows} * 30px + ${maxTagRows - 1} * 6px)` }"
|
||||
>
|
||||
<span
|
||||
v-for="tag in visibleTags"
|
||||
:key="String(tag.value)"
|
||||
class="inline-flex items-center gap-1 rounded-full bg-surface-4 px-2.5 py-1 text-sm font-medium text-primary transition-colors border-solid border border-surface-5 hover:brightness-110"
|
||||
@click.stop="removeTag(tag.value)"
|
||||
>
|
||||
{{ tag.label }}
|
||||
<XIcon class="size-3.5 shrink-0 text-secondary" />
|
||||
</span>
|
||||
<Menu
|
||||
v-show="overflowCount > 0"
|
||||
:delay="{ hide: 50, show: 0 }"
|
||||
no-auto-focus
|
||||
:auto-hide="false"
|
||||
@apply-show="popperOverflowTags = [...overflowTags]"
|
||||
<slot
|
||||
v-if="hasCustomInputContent"
|
||||
name="input-content"
|
||||
:is-open="isOpen"
|
||||
:model-value="modelValue"
|
||||
:selected-options="selectedOptions"
|
||||
:clear-all="clearAll"
|
||||
:toggle-open="toggleDropdown"
|
||||
:open-direction="openDirection"
|
||||
/>
|
||||
<template v-else>
|
||||
<div
|
||||
ref="tagsContainerRef"
|
||||
class="flex min-h-8 flex-1 flex-wrap items-center gap-1.5 overflow-hidden"
|
||||
:style="{ maxHeight: `calc(${maxTagRows} * 30px + ${maxTagRows - 1} * 6px)` }"
|
||||
>
|
||||
<span
|
||||
class="inline-flex items-center rounded-full bg-surface-4 px-2 py-1 text-sm font-medium text-secondary border-solid border border-surface-5 select-none cursor-default"
|
||||
@click.stop
|
||||
v-for="tag in visibleTags"
|
||||
:key="String(tag.value)"
|
||||
class="inline-flex items-center gap-1 rounded-full border border-solid border-surface-5 bg-surface-4 px-2.5 py-1 text-sm font-medium text-primary transition-colors hover:brightness-110"
|
||||
@click.stop="removeTag(tag.value)"
|
||||
>
|
||||
+{{ overflowCount }}
|
||||
{{ tag.label }}
|
||||
<XIcon class="size-3.5 shrink-0 text-secondary" />
|
||||
</span>
|
||||
<template #popper>
|
||||
<div class="flex gap-1 flex-wrap max-w-[20rem]" @mousedown.prevent>
|
||||
<span
|
||||
v-for="tag in overflowTags"
|
||||
:key="String(tag.value)"
|
||||
class="inline-flex items-center gap-1 rounded-full bg-surface-4 px-2.5 py-1 text-sm font-medium text-primary border-solid border border-surface-5 cursor-pointer hover:brightness-110"
|
||||
@click.stop="removeTag(tag.value)"
|
||||
>
|
||||
{{ tag.label }}
|
||||
<XIcon class="size-3.5 shrink-0 text-secondary" />
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
</Menu>
|
||||
<span v-if="selectedOptions.length === 0" class="py-1 px-1.5 text-secondary">
|
||||
{{ placeholder }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="ml-2 flex shrink-0 items-center gap-1.5">
|
||||
<button
|
||||
v-if="clearable && modelValue.length > 0"
|
||||
type="button"
|
||||
class="flex items-center justify-center rounded p-0.5 bg-transparent border-none text-secondary hover:text-contrast transition-colors cursor-pointer"
|
||||
aria-label="Clear all"
|
||||
@click.stop="clearAll"
|
||||
>
|
||||
<XIcon class="size-5" />
|
||||
</button>
|
||||
<div class="w-[1px] h-5 bg-surface-5 shrink-0"></div>
|
||||
<ChevronLeftIcon
|
||||
v-if="showChevron"
|
||||
class="size-5 shrink-0 text-secondary transition-transform duration-150"
|
||||
:class="isOpen ? (openDirection === 'down' ? 'rotate-90' : '-rotate-90') : '-rotate-90'"
|
||||
/>
|
||||
</div>
|
||||
<Menu
|
||||
v-show="overflowCount > 0"
|
||||
:delay="{ hide: 50, show: 0 }"
|
||||
no-auto-focus
|
||||
:auto-hide="false"
|
||||
@apply-show="popperOverflowTags = [...overflowTags]"
|
||||
>
|
||||
<span
|
||||
class="inline-flex cursor-default select-none items-center rounded-full border border-solid border-surface-5 bg-surface-4 px-2 py-1 text-sm font-medium text-secondary"
|
||||
@click.stop
|
||||
>
|
||||
+{{ overflowCount }}
|
||||
</span>
|
||||
<template #popper>
|
||||
<div class="flex max-w-[20rem] flex-wrap gap-1" @mousedown.prevent>
|
||||
<span
|
||||
v-for="tag in overflowTags"
|
||||
:key="String(tag.value)"
|
||||
class="inline-flex cursor-pointer items-center gap-1 rounded-full border border-solid border-surface-5 bg-surface-4 px-2.5 py-1 text-sm font-medium text-primary hover:brightness-110"
|
||||
@click.stop="removeTag(tag.value)"
|
||||
>
|
||||
{{ tag.label }}
|
||||
<XIcon class="size-3.5 shrink-0 text-secondary" />
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
</Menu>
|
||||
<span v-if="selectedOptions.length === 0" class="px-1.5 py-1 text-secondary">
|
||||
{{ placeholder }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="ml-2 flex shrink-0 items-center gap-1.5">
|
||||
<button
|
||||
v-if="clearable && modelValue.length > 0"
|
||||
type="button"
|
||||
class="flex cursor-pointer items-center justify-center rounded border-none bg-transparent p-0.5 text-secondary transition-colors hover:text-contrast"
|
||||
aria-label="Clear all"
|
||||
@click.stop="clearAll"
|
||||
>
|
||||
<XIcon class="size-5" />
|
||||
</button>
|
||||
<div class="h-5 w-[1px] shrink-0 bg-surface-5"></div>
|
||||
<ChevronLeftIcon
|
||||
v-if="showChevron"
|
||||
class="size-5 shrink-0 text-secondary transition-transform duration-150"
|
||||
:class="isOpen ? (openDirection === 'down' ? 'rotate-90' : '-rotate-90') : '-rotate-90'"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</span>
|
||||
|
||||
<Teleport to="#teleports">
|
||||
@@ -153,12 +166,38 @@
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="$slots.top" class="border-0 border-b border-solid border-b-surface-5 py-1.5">
|
||||
<slot
|
||||
name="top"
|
||||
:model-value="modelValue"
|
||||
:selected-options="selectedOptions"
|
||||
:clear-all="clearAll"
|
||||
:is-open="isOpen"
|
||||
></slot>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="shouldShowSelectionActions"
|
||||
class="flex items-center justify-between gap-3 border-0 border-b border-solid border-b-surface-5 px-6 py-2.5 text-sm"
|
||||
>
|
||||
<span class="font-semibold text-secondary">{{ selectionActionsLabel }}</span>
|
||||
<button
|
||||
type="button"
|
||||
class="border-0 bg-transparent p-0 text-sm font-semibold text-secondary shadow-none transition-colors hover:bg-transparent hover:text-contrast"
|
||||
@click="clearAll"
|
||||
@keydown.enter.stop
|
||||
@keydown.space.stop
|
||||
>
|
||||
{{ selectionActionsClearLabel }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="filteredOptions.length > 0"
|
||||
ref="optionsContainerRef"
|
||||
class="flex flex-col gap-2 overflow-y-auto px-3 pt-1.5"
|
||||
class="flex flex-col gap-2 overflow-y-auto px-3 py-1.5"
|
||||
:style="{ maxHeight: `${maxHeight}px` }"
|
||||
>
|
||||
<template v-for="(item, index) in filteredOptions" :key="String(item.value)">
|
||||
@@ -168,7 +207,7 @@
|
||||
:aria-selected="isSelected(item.value)"
|
||||
:aria-disabled="item.disabled || undefined"
|
||||
:data-focused="focusedIndex === index"
|
||||
class="flex items-center gap-2.5 cursor-pointer p-3 text-left transition-colors duration-150 text-contrast hover:bg-surface-5 focus:bg-surface-5 rounded-xl"
|
||||
class="flex items-center gap-2.5 cursor-pointer p-3 text-left transition-colors duration-150 text-contrast hover:bg-surface-5 rounded-xl"
|
||||
:class="[
|
||||
item.class,
|
||||
{
|
||||
@@ -214,6 +253,10 @@
|
||||
{{ noResultsMessage }}
|
||||
</div>
|
||||
|
||||
<div v-if="$slots.bottom" @keydown.stop>
|
||||
<slot name="bottom"></slot>
|
||||
</div>
|
||||
|
||||
<slot name="dropdown-footer"></slot>
|
||||
</div>
|
||||
</Transition>
|
||||
@@ -233,6 +276,7 @@ import {
|
||||
onUnmounted,
|
||||
ref,
|
||||
shallowRef,
|
||||
useSlots,
|
||||
watch,
|
||||
} from 'vue'
|
||||
|
||||
@@ -263,12 +307,19 @@ const props = withDefaults(
|
||||
clearable?: boolean
|
||||
maxHeight?: number
|
||||
triggerClass?: string
|
||||
fitContent?: boolean
|
||||
/** Width for the teleported dropdown; defaults to the trigger width */
|
||||
dropdownWidth?: string | number
|
||||
/** Minimum width for the teleported dropdown */
|
||||
dropdownMinWidth?: string | number
|
||||
forceDirection?: 'up' | 'down'
|
||||
noOptionsMessage?: string
|
||||
noResultsMessage?: string
|
||||
disableSearchFilter?: boolean
|
||||
includeSelectAllOption?: boolean
|
||||
selectAllLabel?: string
|
||||
showSelectionActions?: boolean
|
||||
selectionActionsClearLabel?: string
|
||||
maxTagRows?: number
|
||||
}>(),
|
||||
{
|
||||
@@ -279,10 +330,13 @@ const props = withDefaults(
|
||||
showChevron: true,
|
||||
clearable: true,
|
||||
maxHeight: DEFAULT_MAX_HEIGHT,
|
||||
fitContent: false,
|
||||
noOptionsMessage: 'No options available',
|
||||
noResultsMessage: 'No results found',
|
||||
includeSelectAllOption: false,
|
||||
selectAllLabel: 'Select all',
|
||||
showSelectionActions: false,
|
||||
selectionActionsClearLabel: 'Deselect all',
|
||||
maxTagRows: 1,
|
||||
},
|
||||
)
|
||||
@@ -294,6 +348,7 @@ const emit = defineEmits<{
|
||||
searchInput: [query: string]
|
||||
}>()
|
||||
|
||||
const slots = useSlots()
|
||||
const isOpen = ref(false)
|
||||
const searchQuery = ref('')
|
||||
const focusedIndex = ref(-1)
|
||||
@@ -310,9 +365,11 @@ const dropdownStyle = ref({
|
||||
top: '0px',
|
||||
left: '0px',
|
||||
width: '0px',
|
||||
minWidth: '0px',
|
||||
})
|
||||
|
||||
const openDirection = ref<'down' | 'up'>('down')
|
||||
const hasCustomInputContent = computed(() => Boolean(slots['input-content']))
|
||||
|
||||
const selectedOptions = computed(() => {
|
||||
return props.options.filter((opt) => props.modelValue.includes(opt.value))
|
||||
@@ -361,6 +418,12 @@ const filteredOptions = computed(() => {
|
||||
|
||||
const isNoOptionsState = computed(() => props.options.length === 0 && !searchQuery.value)
|
||||
const shouldShowSelectAll = computed(() => props.includeSelectAllOption && props.options.length > 0)
|
||||
const shouldShowSelectionActions = computed(
|
||||
() => props.showSelectionActions && props.modelValue.length > 0,
|
||||
)
|
||||
const selectionActionsLabel = computed(() => {
|
||||
return props.modelValue.length === 1 ? '1 selected' : `${props.modelValue.length} selected`
|
||||
})
|
||||
|
||||
function isSelected(value: T) {
|
||||
return props.modelValue.includes(value)
|
||||
@@ -387,6 +450,14 @@ function clearAll() {
|
||||
emit('update:modelValue', [])
|
||||
}
|
||||
|
||||
function toggleDropdown() {
|
||||
if (isOpen.value) {
|
||||
closeDropdown()
|
||||
} else {
|
||||
openDropdown()
|
||||
}
|
||||
}
|
||||
|
||||
function toggleSelectAll() {
|
||||
if (isAllSelected.value) {
|
||||
emit('update:modelValue', [])
|
||||
@@ -460,12 +531,35 @@ function calculateHorizontalPosition(
|
||||
return left
|
||||
}
|
||||
|
||||
function resolveDropdownWidth(triggerWidth: number): string {
|
||||
if (props.dropdownWidth === undefined) return `${triggerWidth}px`
|
||||
if (typeof props.dropdownWidth === 'number') return `${props.dropdownWidth}px`
|
||||
return props.dropdownWidth
|
||||
}
|
||||
|
||||
function resolveCssSize(size: string | number | undefined): string | undefined {
|
||||
if (size === undefined) return undefined
|
||||
if (typeof size === 'number') return `${size}px`
|
||||
return size
|
||||
}
|
||||
|
||||
async function updateDropdownPosition() {
|
||||
if (!triggerRef.value || !dropdownRef.value) return
|
||||
|
||||
await nextTick()
|
||||
|
||||
const triggerRect = triggerRef.value.getBoundingClientRect()
|
||||
const width = resolveDropdownWidth(triggerRect.width)
|
||||
const minWidth = resolveCssSize(props.dropdownMinWidth) ?? '0px'
|
||||
|
||||
dropdownStyle.value = {
|
||||
...dropdownStyle.value,
|
||||
width,
|
||||
minWidth,
|
||||
}
|
||||
|
||||
await nextTick()
|
||||
|
||||
const dropdownRect = dropdownRef.value.getBoundingClientRect()
|
||||
const viewportHeight = window.innerHeight
|
||||
const viewportWidth = window.innerWidth
|
||||
@@ -477,7 +571,8 @@ async function updateDropdownPosition() {
|
||||
dropdownStyle.value = {
|
||||
top: `${top}px`,
|
||||
left: `${left}px`,
|
||||
width: `${triggerRect.width}px`,
|
||||
width,
|
||||
minWidth,
|
||||
}
|
||||
|
||||
openDirection.value = direction
|
||||
@@ -699,6 +794,9 @@ watch(
|
||||
() => props.modelValue,
|
||||
() => {
|
||||
calculateVisibleTags()
|
||||
if (isOpen.value) {
|
||||
updateDropdownPosition()
|
||||
}
|
||||
},
|
||||
{ deep: true },
|
||||
)
|
||||
|
||||
@@ -74,7 +74,6 @@
|
||||
>
|
||||
<ButtonStyled v-if="leftButtonConfig" type="outlined">
|
||||
<button
|
||||
class="!border-surface-5 !shadow-none"
|
||||
:class="leftButtonConfig.buttonClass"
|
||||
:disabled="leftButtonConfig.disabled"
|
||||
@click="leftButtonConfig.onClick"
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
<nav
|
||||
v-if="filteredLinks.length > 1"
|
||||
ref="scrollContainer"
|
||||
class="relative flex w-fit overflow-x-auto rounded-full bg-bg-raised p-1 text-sm font-bold drop-shadow-xl"
|
||||
:class="{ 'shadow-sm': mode === 'navigation' }"
|
||||
class="relative flex w-fit overflow-x-auto rounded-full bg-bg-raised p-1 text-sm font-bold"
|
||||
:class="{ 'drop-shadow-xl': mode === 'navigation' }"
|
||||
>
|
||||
<template v-if="mode === 'navigation'">
|
||||
<RouterLink
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<nav
|
||||
ref="scrollContainer"
|
||||
class="card-shadow experimental-styles-within relative flex w-fit overflow-x-auto rounded-full bg-bg-raised p-1 text-sm font-bold"
|
||||
class="card-shadow relative flex w-fit overflow-x-auto rounded-full bg-bg-raised p-1 text-sm font-bold"
|
||||
>
|
||||
<button
|
||||
v-for="(option, index) in options"
|
||||
|
||||
@@ -36,6 +36,7 @@
|
||||
: undefined
|
||||
"
|
||||
:link="option.link ? option.link : undefined"
|
||||
:download="option.download ? option.download : undefined"
|
||||
:external="option.external ? option.external : false"
|
||||
:disabled="option.disabled"
|
||||
@click="
|
||||
@@ -76,6 +77,7 @@ interface Item extends BaseOption {
|
||||
icon?: Component
|
||||
action?: (event?: MouseEvent) => void
|
||||
link?: string
|
||||
download?: string
|
||||
external?: boolean
|
||||
color?:
|
||||
| 'primary'
|
||||
|
||||
@@ -48,7 +48,9 @@ defineOptions({
|
||||
|
||||
// When clickable is being hovered or focus-visible, give contents an effect
|
||||
:first-child:hover + .smart-clickable__contents,
|
||||
:first-child:focus-visible + .smart-clickable__contents {
|
||||
:first-child:focus-visible + .smart-clickable__contents,
|
||||
.smart-clickable__contents:hover,
|
||||
.smart-clickable__contents:focus-within {
|
||||
// Utility classes for contents
|
||||
:deep(.smart-clickable\:underline-on-hover) {
|
||||
text-decoration: underline;
|
||||
|
||||
@@ -71,6 +71,9 @@
|
||||
variant === 'outlined'
|
||||
? 'bg-transparent border border-solid border-button-bg rounded-l-xl border-r-0'
|
||||
: 'bg-surface-4 border-none rounded-xl',
|
||||
{
|
||||
'placeholder:text-sm': type === 'search',
|
||||
},
|
||||
]"
|
||||
@input="onInput"
|
||||
@focus="isFocused = true"
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
<template>
|
||||
<div class="overflow-hidden rounded-2xl border border-solid border-surface-5">
|
||||
<div
|
||||
v-if="hasHeaderSlot"
|
||||
class="border-solid border-0 border-b border-surface-5 bg-surface-3 p-4"
|
||||
>
|
||||
<slot name="header" />
|
||||
</div>
|
||||
<table class="w-full table-fixed border-separate border-spacing-0 border-surface-5">
|
||||
<thead class="">
|
||||
<tr class="bg-surface-3">
|
||||
@@ -44,37 +50,62 @@
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr
|
||||
v-for="(row, rowIndex) in data"
|
||||
:key="rowIndex"
|
||||
:class="rowIndex % 2 === 0 ? 'bg-surface-2' : 'bg-surface-1.5'"
|
||||
>
|
||||
<td v-if="showSelection" class="w-10 border-solid border-0 border-t border-surface-5">
|
||||
<Checkbox
|
||||
:model-value="isSelected(row)"
|
||||
class="shrink-0 p-4"
|
||||
@update:model-value="toggleSelection(row)"
|
||||
/>
|
||||
</td>
|
||||
<td
|
||||
v-for="column in columns"
|
||||
:key="column.key"
|
||||
class="text-secondary h-14 overflow-hidden first:pl-4 last:pr-4 border-solid border-0 border-t border-surface-5"
|
||||
:class="`text-${column.align ?? 'left'}`"
|
||||
:style="column.width ? { width: column.width } : undefined"
|
||||
>
|
||||
<slot
|
||||
:name="`cell-${column.key}`"
|
||||
:row="row"
|
||||
:value="row[column.key]"
|
||||
:column="column"
|
||||
:index="rowIndex"
|
||||
>
|
||||
{{ row[column.key] ?? '' }}
|
||||
<tbody :ref="setListContainer">
|
||||
<tr v-if="data.length === 0" class="bg-surface-2">
|
||||
<td :colspan="columnSpan" class="border-solid border-0 border-t border-surface-5 p-0">
|
||||
<slot name="empty-state">
|
||||
<div class="text-secondary flex h-64 items-center justify-center">
|
||||
No data available.
|
||||
</div>
|
||||
</slot>
|
||||
</td>
|
||||
</tr>
|
||||
<template v-else>
|
||||
<tr v-if="virtualized && topSpacerHeight > 0" aria-hidden="true">
|
||||
<td
|
||||
:colspan="columnSpan"
|
||||
class="border-0 p-0"
|
||||
:style="{ height: `${topSpacerHeight}px` }"
|
||||
></td>
|
||||
</tr>
|
||||
<tr
|
||||
v-for="(row, rowIndex) in renderedRows"
|
||||
:key="getRowRenderKey(row, getAbsoluteRowIndex(rowIndex))"
|
||||
:class="getAbsoluteRowIndex(rowIndex) % 2 === 0 ? 'bg-surface-2' : 'bg-surface-1.5'"
|
||||
>
|
||||
<td v-if="showSelection" class="w-10 border-solid border-0 border-t border-surface-5">
|
||||
<Checkbox
|
||||
:model-value="isSelected(row)"
|
||||
class="shrink-0 p-4"
|
||||
@update:model-value="toggleSelection(row)"
|
||||
/>
|
||||
</td>
|
||||
<td
|
||||
v-for="column in columns"
|
||||
:key="column.key"
|
||||
class="text-secondary h-14 overflow-hidden first:pl-4 last:pr-4 border-solid border-0 border-t border-surface-5"
|
||||
:class="`text-${column.align ?? 'left'}`"
|
||||
:style="column.width ? { width: column.width } : undefined"
|
||||
>
|
||||
<slot
|
||||
:name="`cell-${column.key}`"
|
||||
:row="row"
|
||||
:value="row[column.key]"
|
||||
:column="column"
|
||||
:index="getAbsoluteRowIndex(rowIndex)"
|
||||
>
|
||||
{{ row[column.key] ?? '' }}
|
||||
</slot>
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-if="virtualized && bottomSpacerHeight > 0" aria-hidden="true">
|
||||
<td
|
||||
:colspan="columnSpan"
|
||||
class="border-0 p-0"
|
||||
:style="{ height: `${bottomSpacerHeight}px` }"
|
||||
></td>
|
||||
</tr>
|
||||
</template>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
@@ -86,8 +117,9 @@
|
||||
generic="K extends string = string, T extends Record<string, unknown> = Record<K, unknown>"
|
||||
>
|
||||
import { ChevronDownIcon, ChevronUpIcon } from '@modrinth/assets'
|
||||
import { computed } from 'vue'
|
||||
import { computed, toRef, useSlots } from 'vue'
|
||||
|
||||
import { useVirtualScroll } from '../../composables/virtual-scroll'
|
||||
import Checkbox from './Checkbox.vue'
|
||||
|
||||
export type TableColumnAlign = 'left' | 'center' | 'right'
|
||||
@@ -115,16 +147,49 @@ const props = withDefaults(
|
||||
data: T[] /* Row data for table */
|
||||
showSelection?: boolean
|
||||
rowKey?: keyof T /* The key used to uniquely identify each row */
|
||||
virtualized?: boolean
|
||||
virtualRowHeight?: number
|
||||
virtualBufferSize?: number /* The number of extra rows rendered above and below the visible viewport */
|
||||
}>(),
|
||||
{
|
||||
showSelection: false,
|
||||
rowKey: 'id' as keyof T,
|
||||
virtualized: false,
|
||||
virtualRowHeight: 56,
|
||||
virtualBufferSize: 5,
|
||||
},
|
||||
)
|
||||
|
||||
const selectedIds = defineModel<unknown[]>('selectedIds', { default: () => [] })
|
||||
const sortColumn = defineModel<string | undefined>('sortColumn')
|
||||
const sortDirection = defineModel<SortDirection>('sortDirection', { default: 'asc' })
|
||||
const slots = useSlots()
|
||||
const hasHeaderSlot = computed(() => Boolean(slots.header))
|
||||
const columnSpan = computed(() => Math.max(props.columns.length + (props.showSelection ? 1 : 0), 1))
|
||||
|
||||
const {
|
||||
listContainer,
|
||||
totalHeight,
|
||||
visibleRange,
|
||||
visibleTop: topSpacerHeight,
|
||||
visibleItems,
|
||||
} = useVirtualScroll(toRef(props, 'data'), {
|
||||
itemHeight: props.virtualRowHeight,
|
||||
bufferSize: props.virtualBufferSize,
|
||||
enabled: toRef(props, 'virtualized'),
|
||||
})
|
||||
|
||||
const renderedRows = computed(() => (props.virtualized ? visibleItems.value : props.data))
|
||||
const bottomSpacerHeight = computed(() => {
|
||||
if (!props.virtualized) {
|
||||
return 0
|
||||
}
|
||||
|
||||
return Math.max(
|
||||
0,
|
||||
totalHeight.value - topSpacerHeight.value - renderedRows.value.length * props.virtualRowHeight,
|
||||
)
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
sort: [column: string, direction: SortDirection]
|
||||
@@ -141,6 +206,23 @@ function getRowId(row: T): unknown {
|
||||
return row[props.rowKey as keyof T]
|
||||
}
|
||||
|
||||
function setListContainer(element: unknown) {
|
||||
listContainer.value = props.virtualized ? (element as HTMLElement | null) : null
|
||||
}
|
||||
|
||||
function getAbsoluteRowIndex(rowIndex: number): number {
|
||||
return props.virtualized ? visibleRange.value.start + rowIndex : rowIndex
|
||||
}
|
||||
|
||||
function getRowRenderKey(row: T, rowIndex: number): PropertyKey {
|
||||
const rowId = getRowId(row)
|
||||
if (typeof rowId === 'string' || typeof rowId === 'number' || typeof rowId === 'symbol') {
|
||||
return rowId
|
||||
}
|
||||
|
||||
return rowIndex
|
||||
}
|
||||
|
||||
function isSelected(row: T): boolean {
|
||||
return selectedIds.value.includes(getRowId(row))
|
||||
}
|
||||
|
||||
97
packages/ui/src/components/base/Tabs.vue
Normal file
97
packages/ui/src/components/base/Tabs.vue
Normal file
@@ -0,0 +1,97 @@
|
||||
<template>
|
||||
<div
|
||||
v-if="tabs.length > 0"
|
||||
class="inline-flex w-fit items-center overflow-x-auto rounded-xl border border-solid border-surface-5 p-0.5 shadow-sm gap-1"
|
||||
role="tablist"
|
||||
>
|
||||
<button
|
||||
v-for="(tab, index) in tabs"
|
||||
:key="tab.value"
|
||||
ref="tabButtons"
|
||||
type="button"
|
||||
class="flex min-h-6 shrink-0 cursor-pointer items-center justify-center gap-2 rounded-lg border border-solid px-2.5 py-1 text-sm font-medium outline-none transition-all active:scale-[0.97] focus-visible:ring-4 focus-visible:ring-brand-shadow"
|
||||
:class="
|
||||
tab.value === value
|
||||
? 'border-green bg-highlight-green text-green'
|
||||
: 'border-transparent bg-transparent text-primary hover:bg-surface-4'
|
||||
"
|
||||
role="tab"
|
||||
:aria-selected="tab.value === value"
|
||||
:tabindex="tab.value === value || (!hasSelectedTab && index === 0) ? 0 : -1"
|
||||
@click="selectTab(tab)"
|
||||
@keydown="onTabKeydown($event, index)"
|
||||
>
|
||||
<component
|
||||
:is="tab.icon"
|
||||
v-if="tab.icon"
|
||||
class="size-5 shrink-0"
|
||||
:class="tab.value === value ? 'text-green' : 'text-secondary'"
|
||||
/>
|
||||
<span class="text-nowrap">{{ tab.label }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { Component } from 'vue'
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
export type TabsValue = string | number
|
||||
|
||||
export interface TabsTab {
|
||||
value: TabsValue
|
||||
label: string
|
||||
icon?: Component
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
value: TabsValue
|
||||
tabs: TabsTab[]
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:value': [value: TabsValue]
|
||||
change: [tab: TabsTab]
|
||||
}>()
|
||||
|
||||
const tabButtons = ref<HTMLButtonElement[]>()
|
||||
|
||||
const hasSelectedTab = computed(() => props.tabs.some((tab) => tab.value === props.value))
|
||||
|
||||
function selectTab(tab: TabsTab) {
|
||||
emit('update:value', tab.value)
|
||||
emit('change', tab)
|
||||
}
|
||||
|
||||
function selectTabAtIndex(index: number) {
|
||||
const tab = props.tabs[index]
|
||||
if (!tab) return
|
||||
|
||||
selectTab(tab)
|
||||
requestAnimationFrame(() => {
|
||||
tabButtons.value?.[index]?.focus()
|
||||
})
|
||||
}
|
||||
|
||||
function onTabKeydown(event: KeyboardEvent, index: number) {
|
||||
if (props.tabs.length === 0) return
|
||||
|
||||
const lastIndex = props.tabs.length - 1
|
||||
let nextIndex: number | undefined
|
||||
|
||||
if (event.key === 'ArrowRight') {
|
||||
nextIndex = index === lastIndex ? 0 : index + 1
|
||||
} else if (event.key === 'ArrowLeft') {
|
||||
nextIndex = index === 0 ? lastIndex : index - 1
|
||||
} else if (event.key === 'Home') {
|
||||
nextIndex = 0
|
||||
} else if (event.key === 'End') {
|
||||
nextIndex = lastIndex
|
||||
}
|
||||
|
||||
if (nextIndex === undefined) return
|
||||
|
||||
event.preventDefault()
|
||||
selectTabAtIndex(nextIndex)
|
||||
}
|
||||
</script>
|
||||
@@ -26,7 +26,7 @@
|
||||
v-if="isOpen"
|
||||
ref="menuRef"
|
||||
data-pyro-telepopover-root
|
||||
class="experimental-styles-within fixed isolate z-[9999] flex w-fit flex-col gap-2 overflow-hidden rounded-2xl border-[1px] border-solid border-surface-5 bg-bg-raised p-2 shadow-lg"
|
||||
class="fixed isolate z-[9999] flex w-fit flex-col gap-2 overflow-hidden rounded-2xl border-[1px] border-solid border-surface-5 bg-bg-raised p-2 shadow-lg"
|
||||
:style="menuStyle"
|
||||
role="menu"
|
||||
tabindex="-1"
|
||||
|
||||
@@ -21,8 +21,11 @@ export type { ComboboxOption } from './Combobox.vue'
|
||||
export { default as Combobox } from './Combobox.vue'
|
||||
export { default as ContentPageHeader } from './ContentPageHeader.vue'
|
||||
export { default as CopyCode } from './CopyCode.vue'
|
||||
export { default as DatePicker } from './DatePicker.vue'
|
||||
export { default as DoubleIcon } from './DoubleIcon.vue'
|
||||
export { default as DropArea } from './DropArea.vue'
|
||||
export type { DropdownFilterBarCategory, DropdownFilterBarOption } from './DropdownFilterBar.vue'
|
||||
export { default as DropdownFilterBar } from './DropdownFilterBar.vue'
|
||||
export { default as DropdownSelect } from './DropdownSelect.vue'
|
||||
export { default as DropzoneFileInput } from './DropzoneFileInput.vue'
|
||||
export { default as EmptyState } from './EmptyState.vue'
|
||||
@@ -76,6 +79,8 @@ export { default as StatItem } from './StatItem.vue'
|
||||
export { default as StyledInput } from './StyledInput.vue'
|
||||
export type { TableColumn } from './Table.vue'
|
||||
export { default as Table } from './Table.vue'
|
||||
export type { TabsTab, TabsValue } from './Tabs.vue'
|
||||
export { default as Tabs } from './Tabs.vue'
|
||||
export { default as TagItem } from './TagItem.vue'
|
||||
export { default as TagTagItem } from './TagTagItem.vue'
|
||||
export { default as Timeline } from './Timeline.vue'
|
||||
|
||||
@@ -79,7 +79,7 @@
|
||||
<template #actions>
|
||||
<div class="flex justify-end gap-2">
|
||||
<ButtonStyled type="outlined">
|
||||
<button class="!border !border-surface-5" @click="handleCancel">
|
||||
<button @click="handleCancel">
|
||||
<XIcon />
|
||||
{{ formatMessage(messages.cancelButton) }}
|
||||
</button>
|
||||
|
||||
@@ -155,8 +155,8 @@ defineExpose({
|
||||
<div class="font-semibold text-contrast">Sign in to continue your purchase</div>
|
||||
<div class="">You need a Modrinth account to add your billing details.</div>
|
||||
</div>
|
||||
<ButtonStyled color="brand" class="mt-2">
|
||||
<button @click="continueToAuth">
|
||||
<ButtonStyled color="brand">
|
||||
<button class="mt-2" @click="continueToAuth">
|
||||
Sign in or create an account
|
||||
<ExternalIcon class="size-4" />
|
||||
</button>
|
||||
|
||||
@@ -212,7 +212,7 @@ function selectCustom() {
|
||||
</div>
|
||||
</div>
|
||||
<div class="w-full">
|
||||
<ButtonStyled color="blue" class="w-full">
|
||||
<ButtonStyled color="blue">
|
||||
<button
|
||||
class="w-full"
|
||||
:disabled="existingPlan?.id === plansByRam.small.id"
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
<script setup lang="ts">
|
||||
import { TagItem } from '#ui/components'
|
||||
import { externalProjectLicenseStatusMessages } from '#ui/utils'
|
||||
|
||||
import { useVIntl } from '../../composables/i18n'
|
||||
import type { ExternalLicenseStatus } from './types.ts'
|
||||
|
||||
const props = defineProps<{
|
||||
state: ExternalLicenseStatus
|
||||
}>()
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<TagItem>
|
||||
{{ formatMessage(externalProjectLicenseStatusMessages[props.state]) }}
|
||||
</TagItem>
|
||||
</template>
|
||||
@@ -0,0 +1,117 @@
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
ClipboardCopyIcon,
|
||||
CurseForgeIcon,
|
||||
FileIcon,
|
||||
LinkIcon,
|
||||
UnknownIcon,
|
||||
} from '@modrinth/assets'
|
||||
import { Menu } from 'floating-vue'
|
||||
import { computed } from 'vue'
|
||||
|
||||
import { ButtonStyled, CopyCode } from '#ui/components'
|
||||
|
||||
import ExternalProjectLicenseStateTag from './ExternalProjectLicenseStateTag.vue'
|
||||
import type { ExternalLicenseStatus } from './types.ts'
|
||||
|
||||
const props = defineProps<{
|
||||
title: string | null
|
||||
link: string | null
|
||||
state: ExternalLicenseStatus
|
||||
proof: string | null
|
||||
notes: string | null
|
||||
last_updated?: string
|
||||
last_updated_by?: string
|
||||
cf_id?: number | null
|
||||
files: {
|
||||
sha1: string
|
||||
name: string | null
|
||||
}[]
|
||||
}>()
|
||||
|
||||
const lastEditedText = computed(() =>
|
||||
props.last_updated
|
||||
? `Last edited on ${props.last_updated} by ${props.last_updated_by ?? 'unknown'}`
|
||||
: '',
|
||||
)
|
||||
|
||||
async function copyProjectLink() {
|
||||
if (!props.link) return
|
||||
await navigator.clipboard.writeText(props.link)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="bg-surface-3 p-4 rounded-2xl flex flex-col gap-3">
|
||||
<div class="flex gap-4 justify-between">
|
||||
<div class="flex flex-col gap-2">
|
||||
<span class="text-contrast font-semibold">{{ title }}</span>
|
||||
<div v-if="!!link" class="flex gap-2 items-center">
|
||||
<a
|
||||
class="flex items-center gap-2 font-medium hover:underline focus:underline w-fit text-blue"
|
||||
:href="link"
|
||||
target="_blank"
|
||||
>
|
||||
<template v-if="link?.includes('curseforge.com')">
|
||||
<CurseForgeIcon class="size-5 shrink-0" />
|
||||
CurseForge project
|
||||
</template>
|
||||
<template v-else> <LinkIcon class="size-5 shrink-0" /> Project link </template>
|
||||
</a>
|
||||
<ButtonStyled circular type="transparent" size="small">
|
||||
<button v-tooltip="'Copy link'" @click="copyProjectLink">
|
||||
<ClipboardCopyIcon />
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</div>
|
||||
<slot name="actions" />
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-[auto_1fr] items-center gap-x-4 gap-y-2">
|
||||
<div class="font-medium">Allowed:</div>
|
||||
<div>
|
||||
<ExternalProjectLicenseStateTag :state="state" />
|
||||
</div>
|
||||
<div class="font-medium">Proof:</div>
|
||||
<div>{{ proof ?? 'N/A' }}</div>
|
||||
<template v-if="cf_id">
|
||||
<div class="font-medium">CurseForge ID:</div>
|
||||
<CopyCode :text="`${cf_id}`" />
|
||||
</template>
|
||||
<div class="font-medium">Notes:</div>
|
||||
<div>{{ notes ?? 'N/A' }}</div>
|
||||
</div>
|
||||
<div class="bg-surface-2 p-4 rounded-2xl flex flex-col gap-3">
|
||||
<span class="text-contrast font-semibold">Files</span>
|
||||
<span v-if="!(files?.length > 0)" class="text-secondary">
|
||||
No files available for external project.
|
||||
</span>
|
||||
<div v-else class="grid grid-cols-1 sm:grid-cols-2 xl:grid-cols-3 gap-2">
|
||||
<Menu v-for="file in files" :key="file.sha1" :delay="{ hide: 50, show: 0 }">
|
||||
<div
|
||||
class="line-clamp-1 truncate px-2 py-1 flex gap-2 rounded-xl items-center border-solid border-2 border-surface-5 text-sm font-medium text-secondary"
|
||||
>
|
||||
<FileIcon class="shrink-0 size-4" /> {{ file.name ?? file.sha1 }}
|
||||
</div>
|
||||
<template #popper>
|
||||
<div class="text-primary p-2 grid grid-cols-[auto_1fr] gap-x-4 gap-y-2">
|
||||
<div>First identified name:</div>
|
||||
<div v-if="file.name" class="text-sm">
|
||||
{{ file.name }}
|
||||
</div>
|
||||
<div v-else class="text-secondary flex items-center gap-2 text-sm">
|
||||
<UnknownIcon /> Unknown
|
||||
</div>
|
||||
<div>SHA-1:</div>
|
||||
<div class="text-sm"><CopyCode :text="file.sha1" /></div>
|
||||
</div>
|
||||
</template>
|
||||
</Menu>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="last_updated" class="pt-4 border-t-[1px] border-solid border-surface-5">
|
||||
{{ lastEditedText }}
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,176 @@
|
||||
<script setup lang="ts">
|
||||
import { ChevronDownIcon, ListBulletedIcon, SaveIcon, VersionIcon, XIcon } from '@modrinth/assets'
|
||||
import { ref } from 'vue'
|
||||
|
||||
import { Admonition, ButtonStyled, Chips, Collapsible, Combobox, StyledInput } from '#ui/components'
|
||||
|
||||
defineProps<{
|
||||
title: string
|
||||
}>()
|
||||
|
||||
const collapsed = ref(true)
|
||||
|
||||
const selectedPermissionsType = ref('My project')
|
||||
</script>
|
||||
<template>
|
||||
<div
|
||||
class="bg-surface-2 p-0 rounded-2xl flex flex-col border-[1px] border-solid border-surface-5 overflow-hidden"
|
||||
>
|
||||
<div class="flex items-center bg-surface-3">
|
||||
<button
|
||||
class="flex grow m-0 appearance-none p-4 bg-transparent group transition-all"
|
||||
@click="collapsed = !collapsed"
|
||||
>
|
||||
<span class="flex items-center gap-3 group-active:scale-[0.98]">
|
||||
<ChevronDownIcon
|
||||
class="size-6 text-primary transition-transform duration-300"
|
||||
:class="{ 'rotate-180': !collapsed }"
|
||||
/>
|
||||
<span class="text-contrast font-semibold">{{ title }}</span>
|
||||
</span>
|
||||
</button>
|
||||
<div class="flex items-center gap-2 m-4 ml-0">
|
||||
<ButtonStyled type="outlined">
|
||||
<button>
|
||||
<ListBulletedIcon />
|
||||
Versions
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</div>
|
||||
<Collapsible
|
||||
:collapsed="collapsed"
|
||||
class="border-0 border-solid border-t border-surface-5 rounded-b-2xl"
|
||||
>
|
||||
<div class="flex flex-col gap-2 p-4">
|
||||
<span class="text-contrast font-semibold">Included in versions:</span>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<template v-for="version in ['4.0.0', '3.5.15', '3.5.14']" :key="version">
|
||||
<div
|
||||
class="px-3 py-2 rounded-xl flex items-center gap-2 border-[1px] border-solid border-surface-5"
|
||||
>
|
||||
<VersionIcon />
|
||||
{{ version }}
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
<div
|
||||
class="rounded-2xl p-4 mt-2 border-[1px] border-solid border-surface-5 flex flex-col gap-3"
|
||||
>
|
||||
<span class="text-contrast font-semibold">Type</span>
|
||||
<Chips
|
||||
v-model="selectedPermissionsType"
|
||||
:items="['License', 'My project', 'Special permission', 'No permission']"
|
||||
/>
|
||||
<template v-if="selectedPermissionsType === 'License'">
|
||||
<span>The license of this work permits you to redistribute it in your modpack.</span>
|
||||
<span class="text-contrast font-semibold mt-1">License</span>
|
||||
<Combobox
|
||||
class="max-w-80"
|
||||
:options="[{ label: 'MIT', value: 'MIT' }]"
|
||||
:model-value="'MIT'"
|
||||
/>
|
||||
<span class="text-contrast font-semibold mt-1"> Link to work </span>
|
||||
<StyledInput
|
||||
type="text"
|
||||
class="max-w-[30rem]"
|
||||
placeholder="https://example.com/link-to-work"
|
||||
/>
|
||||
<span class="text-contrast font-semibold mt-1">
|
||||
Notes
|
||||
<span class="font-normal text-primary">(optional)</span>
|
||||
</span>
|
||||
<StyledInput
|
||||
type="text"
|
||||
resize="both"
|
||||
multiline
|
||||
class="max-w-[40rem]"
|
||||
placeholder="Write something here..."
|
||||
/>
|
||||
</template>
|
||||
<template v-else-if="selectedPermissionsType === 'My project'">
|
||||
<span>Original work created by you.</span>
|
||||
<span class="text-contrast font-semibold mt-1">License</span>
|
||||
<Combobox
|
||||
class="max-w-80"
|
||||
:options="[{ label: 'MIT', value: 'MIT' }]"
|
||||
:model-value="'MIT'"
|
||||
/>
|
||||
<span class="text-contrast font-semibold mt-1">
|
||||
Notes
|
||||
<span class="font-normal text-primary">(optional)</span>
|
||||
</span>
|
||||
<StyledInput
|
||||
type="text"
|
||||
resize="both"
|
||||
multiline
|
||||
class="max-w-[40rem]"
|
||||
placeholder="Write something here..."
|
||||
/>
|
||||
</template>
|
||||
<template v-else-if="selectedPermissionsType === 'Special permission'">
|
||||
<span>
|
||||
You have obtained special permission to redistribute this work in your modpack.
|
||||
</span>
|
||||
<span class="text-contrast font-semibold mt-1"> Link to work </span>
|
||||
<StyledInput
|
||||
type="text"
|
||||
class="max-w-[30rem]"
|
||||
placeholder="https://example.com/link-to-work"
|
||||
/>
|
||||
<div class="flex flex-col gap-1 mt-1">
|
||||
<span class="text-contrast font-semibold"> Proof and explanation </span>
|
||||
<span>
|
||||
Include screenshots of messages, emails, or replies from the copyright owner showing
|
||||
that they granted you permission to redistribute their work in your modpack.
|
||||
</span>
|
||||
</div>
|
||||
<StyledInput
|
||||
type="text"
|
||||
resize="both"
|
||||
multiline
|
||||
class="max-w-[40rem]"
|
||||
placeholder="Write something here..."
|
||||
/>
|
||||
<Admonition
|
||||
type="warning"
|
||||
header="Modrinth staff may attempt to verify submitted proof"
|
||||
>
|
||||
If you are found to have lied or manipulated the images uploaded, your project and
|
||||
account may be terminated.
|
||||
</Admonition>
|
||||
</template>
|
||||
<template v-else-if="selectedPermissionsType === 'No permission'">
|
||||
<span>You don't have permission to use this work.</span>
|
||||
<span class="text-contrast font-semibold mt-1">
|
||||
Notes
|
||||
<span class="font-normal text-primary">(optional)</span>
|
||||
</span>
|
||||
<StyledInput
|
||||
type="text"
|
||||
resize="both"
|
||||
multiline
|
||||
class="max-w-[40rem]"
|
||||
placeholder="Write something here..."
|
||||
/>
|
||||
</template>
|
||||
<hr class="mt-1 bg-surface-5 border-none h-[1px] w-full" />
|
||||
<div class="flex items-center gap-2 justify-end">
|
||||
<ButtonStyled type="outlined">
|
||||
<button>
|
||||
<XIcon />
|
||||
Cancel
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled color="brand">
|
||||
<button>
|
||||
<SaveIcon />
|
||||
Save
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Collapsible>
|
||||
</div>
|
||||
</template>
|
||||
3
packages/ui/src/components/external_files/index.ts
Normal file
3
packages/ui/src/components/external_files/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export { default as ExternalProjectLicenseStateTag } from './ExternalProjectLicenseStateTag.vue'
|
||||
export { default as ExternalProjectLookupCard } from './ExternalProjectLookupCard.vue'
|
||||
export type { ExternalLicenseStatus } from './types.ts'
|
||||
7
packages/ui/src/components/external_files/types.ts
Normal file
7
packages/ui/src/components/external_files/types.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
export type ExternalLicenseStatus =
|
||||
| 'yes'
|
||||
| 'with-attribution-and-source'
|
||||
| 'with-attribution'
|
||||
| 'no'
|
||||
| 'permanent-no'
|
||||
| 'unidentified'
|
||||
@@ -5,13 +5,13 @@
|
||||
<Avatar :src="ctx.instanceIconUrl.value ?? undefined" size="5rem" />
|
||||
<div class="flex flex-col gap-2">
|
||||
<ButtonStyled type="outlined">
|
||||
<button class="!border-surface-5" @click="triggerIconInput">
|
||||
<button @click="triggerIconInput">
|
||||
<UploadIcon />
|
||||
{{ formatMessage(messages.selectIcon) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled type="outlined">
|
||||
<button class="!border-surface-5" :disabled="!ctx.instanceIcon.value" @click="removeIcon">
|
||||
<button :disabled="!ctx.instanceIcon.value" @click="removeIcon">
|
||||
<XIcon />
|
||||
{{ formatMessage(messages.removeIcon) }}
|
||||
</button>
|
||||
|
||||
@@ -83,10 +83,11 @@
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
<div v-else class="flex items-center gap-2">
|
||||
<ButtonStyled icon-only
|
||||
><button class="!shadow-none" @click="browseForLauncherPath">
|
||||
<FolderSearchIcon class="size-5" /></button
|
||||
></ButtonStyled>
|
||||
<ButtonStyled circular>
|
||||
<button class="!shadow-none" @click="browseForLauncherPath">
|
||||
<FolderSearchIcon />
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<StyledInput
|
||||
v-model="newLauncherPath"
|
||||
:placeholder="formatMessage(messages.launcherPathPlaceholder)"
|
||||
|
||||
@@ -29,7 +29,7 @@
|
||||
</div>
|
||||
<div class="flex gap-3">
|
||||
<ButtonStyled type="outlined">
|
||||
<button class="flex-1 !border-surface-4" @click="triggerFileInput">
|
||||
<button class="flex-1" @click="triggerFileInput">
|
||||
<ImportIcon />
|
||||
{{ formatMessage(messages.importModpack) }}
|
||||
</button>
|
||||
|
||||
@@ -5,6 +5,7 @@ export * from './brand'
|
||||
export * from './changelog'
|
||||
export * from './chart'
|
||||
export * from './content'
|
||||
export * from './external_files'
|
||||
export * from './modal'
|
||||
export * from './nav'
|
||||
export * from './page'
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
<template #actions>
|
||||
<div class="flex justify-end gap-2">
|
||||
<ButtonStyled type="outlined">
|
||||
<button class="!border !border-surface-4" @click="cancel">
|
||||
<button @click="cancel">
|
||||
<XIcon />
|
||||
{{ localizeIfPossible(stayLabel) }}
|
||||
</button>
|
||||
|
||||
@@ -18,13 +18,14 @@
|
||||
]"
|
||||
@click="() => (closeOnClickOutside && closable ? hide() : {})"
|
||||
/>
|
||||
<div class="modal-container experimental-styles-within" :class="{ shown: visible }">
|
||||
<div class="modal-container" :class="{ shown: visible }">
|
||||
<div
|
||||
ref="modalBodyRef"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
:aria-labelledby="headerId"
|
||||
class="modal-body flex flex-col bg-bg-raised rounded-2xl border border-solid border-surface-5"
|
||||
v-bind="$attrs"
|
||||
@keydown="handleKeyDown"
|
||||
>
|
||||
<div
|
||||
@@ -345,6 +346,10 @@ function handleKeyDown(event: KeyboardEvent) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
defineOptions({
|
||||
inheritAttrs: false,
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
|
||||
@@ -88,14 +88,19 @@
|
||||
formatMessage(messages.openingAutomatically)
|
||||
}}</span>
|
||||
<div v-else class="grid grid-cols-2 gap-2 w-full">
|
||||
<ButtonStyled class="flex-1">
|
||||
<button @click="hide">
|
||||
<ButtonStyled>
|
||||
<button class="flex-1" @click="hide">
|
||||
<XIcon />
|
||||
{{ formatMessage(commonMessages.closeButton) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled color="green" class="flex-1">
|
||||
<a href="https://modrinth.com/app" target="_blank" rel="noopener noreferrer">
|
||||
<ButtonStyled color="brand">
|
||||
<a
|
||||
class="flex-1"
|
||||
href="https://modrinth.com/app"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<DownloadIcon />
|
||||
{{ formatMessage(messages.getApp) }}
|
||||
</a>
|
||||
|
||||
@@ -14,7 +14,7 @@ import { computed, nextTick, ref } from 'vue'
|
||||
|
||||
import { injectNotificationManager } from '#ui/providers'
|
||||
|
||||
import { Button, ButtonStyled, NewModal, StyledInput } from '../index'
|
||||
import { ButtonStyled, NewModal, StyledInput } from '../index'
|
||||
|
||||
const props = defineProps({
|
||||
header: {
|
||||
@@ -148,15 +148,15 @@ defineExpose({
|
||||
|
||||
<template>
|
||||
<NewModal ref="shareModal" :header="header" :noblur="noblur" :on-hide="onHide">
|
||||
<div class="flex flex-row flex-wrap items-center gap-2">
|
||||
<div class="flex flex-col flex-wrap items-center gap-2">
|
||||
<div v-if="link" class="group relative mx-auto">
|
||||
<div ref="qrCode">
|
||||
<QrcodeVue :value="url" class="!bg-white rounded-[var(--radius-md)]" margin="3" />
|
||||
</div>
|
||||
<ButtonStyled circular>
|
||||
<ButtonStyled circular type="transparent">
|
||||
<button
|
||||
v-tooltip="'Copy QR code'"
|
||||
class="absolute top-0 right-0 m-2 opacity-0 transition-all duration-200 ease-in-out group-hover:opacity-100 group-focus-within:opacity-100 motion-reduce:transition-none"
|
||||
class="absolute top-0 right-0 m-2"
|
||||
aria-label="Copy QR code"
|
||||
@click="copyImage"
|
||||
>
|
||||
@@ -164,17 +164,25 @@ defineExpose({
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
<StyledInput v-else v-model="content" multiline resize="vertical" wrapper-class="h-full">
|
||||
<StyledInput
|
||||
v-else
|
||||
v-model="content"
|
||||
multiline
|
||||
resize="vertical"
|
||||
wrapper-class="h-full w-[30rem]"
|
||||
>
|
||||
<template #right>
|
||||
<button
|
||||
v-tooltip="'Copy Text'"
|
||||
type="button"
|
||||
aria-label="Copy Text"
|
||||
class="absolute top-0 right-0 m-2 grid h-10 w-10 cursor-pointer place-content-center rounded-lg border-none bg-button-bg text-primary transition-all hover:bg-button-bg-hover hover:brightness-125 active:scale-95"
|
||||
@click="copyText"
|
||||
>
|
||||
<ClipboardCopyIcon class="h-5 w-5" aria-hidden="true" />
|
||||
</button>
|
||||
<ButtonStyled circular type="transparent">
|
||||
<button
|
||||
v-tooltip="'Copy Text'"
|
||||
type="button"
|
||||
aria-label="Copy Text"
|
||||
class="absolute top-0 right-0 m-2"
|
||||
@click="copyText"
|
||||
>
|
||||
<ClipboardCopyIcon class="h-5 w-5" aria-hidden="true" />
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</template>
|
||||
</StyledInput>
|
||||
<div class="flex flex-grow flex-col justify-center gap-2">
|
||||
@@ -199,56 +207,63 @@ defineExpose({
|
||||
<ExternalIcon aria-hidden="true" />
|
||||
</a>
|
||||
</ButtonStyled>
|
||||
<div v-if="socialButtons" class="flex flex-row gap-2">
|
||||
<Button v-if="canShare" v-tooltip="'Share'" aria-label="Share" icon-only @click="share">
|
||||
<ShareIcon aria-hidden="true" />
|
||||
</Button>
|
||||
<a
|
||||
v-tooltip="'Send as an email'"
|
||||
class="btn icon-only fill-contrast text-contrast"
|
||||
:href="sendEmail"
|
||||
:target="targetParameter"
|
||||
aria-label="Send as an email"
|
||||
>
|
||||
<MailIcon aria-hidden="true" />
|
||||
</a>
|
||||
<a
|
||||
v-if="link"
|
||||
v-tooltip="'Open link in browser'"
|
||||
class="btn icon-only fill-contrast text-contrast"
|
||||
:target="targetParameter"
|
||||
:href="url"
|
||||
aria-label="Open link in browser"
|
||||
>
|
||||
<GlobeIcon aria-hidden="true" />
|
||||
</a>
|
||||
<a
|
||||
v-tooltip="'Toot about it'"
|
||||
class="btn icon-only fill-contrast text-contrast bg-[#563acc]"
|
||||
:target="targetParameter"
|
||||
:href="sendToot"
|
||||
aria-label="Toot about it"
|
||||
>
|
||||
<MastodonIcon aria-hidden="true" />
|
||||
</a>
|
||||
<a
|
||||
v-tooltip="'Tweet about it'"
|
||||
class="btn icon-only fill-contrast text-contrast bg-[#1da1f2]"
|
||||
:target="targetParameter"
|
||||
:href="sendTweet"
|
||||
aria-label="Tweet about it"
|
||||
>
|
||||
<TwitterIcon aria-hidden="true" />
|
||||
</a>
|
||||
<a
|
||||
v-tooltip="'Share on Reddit'"
|
||||
class="btn icon-only fill-contrast text-contrast bg-[#ff4500]"
|
||||
:target="targetParameter"
|
||||
:href="postOnReddit"
|
||||
aria-label="Share on Reddit"
|
||||
>
|
||||
<RedditIcon aria-hidden="true" />
|
||||
</a>
|
||||
<div v-if="socialButtons" class="flex flex-row gap-1">
|
||||
<ButtonStyled v-if="canShare" circular>
|
||||
<button v-tooltip="'Share'" aria-label="Share" @click="share">
|
||||
<ShareIcon aria-hidden="true" />
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled circular>
|
||||
<a
|
||||
v-tooltip="'Send as an email'"
|
||||
:href="sendEmail"
|
||||
:target="targetParameter"
|
||||
aria-label="Send as an email"
|
||||
>
|
||||
<MailIcon aria-hidden="true" />
|
||||
</a>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled circular>
|
||||
<a
|
||||
v-if="link"
|
||||
v-tooltip="'Open link in browser'"
|
||||
:target="targetParameter"
|
||||
:href="url"
|
||||
aria-label="Open link in browser"
|
||||
>
|
||||
<GlobeIcon aria-hidden="true" />
|
||||
</a>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled circular>
|
||||
<a
|
||||
v-tooltip="'Toot about it'"
|
||||
:target="targetParameter"
|
||||
:href="sendToot"
|
||||
aria-label="Toot about it"
|
||||
>
|
||||
<MastodonIcon aria-hidden="true" />
|
||||
</a>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled circular>
|
||||
<a
|
||||
v-tooltip="'Tweet about it'"
|
||||
:target="targetParameter"
|
||||
:href="sendTweet"
|
||||
aria-label="Tweet about it"
|
||||
>
|
||||
<TwitterIcon aria-hidden="true" />
|
||||
</a>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled circular>
|
||||
<a
|
||||
v-tooltip="'Share on Reddit'"
|
||||
:target="targetParameter"
|
||||
:href="postOnReddit"
|
||||
aria-label="Share on Reddit"
|
||||
>
|
||||
<RedditIcon aria-hidden="true" />
|
||||
</a>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<div
|
||||
class="vue-notification-group experimental-styles-within"
|
||||
class="vue-notification-group"
|
||||
:class="{
|
||||
'intercom-present': isIntercomPresent,
|
||||
'location-left': notificationLocation === 'left',
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
<template>
|
||||
<div
|
||||
:class="['banner-grid relative border-b-2 border-solid border-0', containerClasses[variant]]"
|
||||
:class="[
|
||||
'banner-grid relative border-b-2 border-solid border-0 z-10',
|
||||
containerClasses[variant],
|
||||
{ 'no-actions': !$slots.actions, slim: slim },
|
||||
]"
|
||||
>
|
||||
<div
|
||||
:class="[
|
||||
@@ -16,12 +20,20 @@
|
||||
<slot name="description" />
|
||||
</div>
|
||||
|
||||
<div v-if="$slots.actions" class="grid-area-[actions]">
|
||||
<div v-if="$slots.actions" class="grid-area-[actions] flex items-center gap-2">
|
||||
<slot name="actions" />
|
||||
</div>
|
||||
|
||||
<div v-if="$slots.actions_right" class="grid-area-[actions_right]">
|
||||
<slot name="actions_right" />
|
||||
<div
|
||||
v-if="$slots.actions_right || $slots.actions_top_right"
|
||||
class="grid-area-[actions_right] flex flex-col gap-2 items-end"
|
||||
>
|
||||
<div v-if="$slots.actions_top_right" class="flex items-center gap-2 justify-end">
|
||||
<slot name="actions_top_right" />
|
||||
</div>
|
||||
<div v-if="$slots.actions_right" class="flex items-center gap-2 justify-end my-auto">
|
||||
<slot name="actions_right" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -29,9 +41,15 @@
|
||||
<script lang="ts" setup>
|
||||
import { getSeverityIcon } from '../../utils'
|
||||
|
||||
defineProps<{
|
||||
variant: 'error' | 'warning' | 'info'
|
||||
}>()
|
||||
withDefaults(
|
||||
defineProps<{
|
||||
variant: 'error' | 'warning' | 'info'
|
||||
slim?: boolean
|
||||
}>(),
|
||||
{
|
||||
slim: false,
|
||||
},
|
||||
)
|
||||
|
||||
const containerClasses = {
|
||||
error: 'bg-banners-error-bg text-banners-error-text border-banners-error-border',
|
||||
@@ -58,6 +76,16 @@ const iconClasses = {
|
||||
padding-inline: max(calc((100% - 80rem) / 2 + var(--gap-md)), var(--gap-xl));
|
||||
}
|
||||
|
||||
.banner-grid.no-actions {
|
||||
grid-template-areas:
|
||||
'title actions_right'
|
||||
'description actions_right';
|
||||
}
|
||||
|
||||
.banner-grid.slim {
|
||||
@apply flex py-4 gap-2 items-center;
|
||||
}
|
||||
|
||||
.grid-area-\[title\] {
|
||||
grid-area: title;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<div
|
||||
class="popup-notification-group experimental-styles-within"
|
||||
class="popup-notification-group"
|
||||
:class="{
|
||||
'has-sidebar': hasSidebar,
|
||||
}"
|
||||
@@ -130,7 +130,9 @@ const dismiss = (id: string | number) => popupNotificationManager.removeNotifica
|
||||
|
||||
function handleButtonClick(id: string | number, btn: PopupNotificationButton) {
|
||||
btn.action()
|
||||
popupNotificationManager.removeNotification(id)
|
||||
if (!btn.keepOpen) {
|
||||
popupNotificationManager.removeNotification(id)
|
||||
}
|
||||
}
|
||||
|
||||
function progressColorForType(type: PopupNotification['type']) {
|
||||
|
||||
@@ -268,16 +268,12 @@ import {
|
||||
Pagination,
|
||||
TagItem,
|
||||
useCompactNumber,
|
||||
useFormatBytes,
|
||||
useFormatDateTime,
|
||||
VersionChannelIndicator,
|
||||
VersionFilterControl,
|
||||
} from '@modrinth/ui'
|
||||
import {
|
||||
formatBytes,
|
||||
formatVersionsForDisplay,
|
||||
type GameVersionTag,
|
||||
type Version,
|
||||
} from '@modrinth/utils'
|
||||
import { formatVersionsForDisplay, type GameVersionTag, type Version } from '@modrinth/utils'
|
||||
import { Menu } from 'floating-vue'
|
||||
import { computed, type Ref, ref } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
@@ -293,6 +289,7 @@ const formatDateTime = useFormatDateTime({
|
||||
timeStyle: 'short',
|
||||
dateStyle: 'long',
|
||||
})
|
||||
const formatBytes = useFormatBytes()
|
||||
|
||||
type VersionWithDisplayUrlEnding = Version & {
|
||||
displayUrlEnding: string
|
||||
|
||||
@@ -62,7 +62,11 @@
|
||||
:hide-label="true"
|
||||
/>
|
||||
<ServerPing v-if="serverPing && serverStatusOnline" :ping="serverPing" />
|
||||
<ServerRegion v-if="serverRegion" :region="serverRegion" />
|
||||
<ServerRegion
|
||||
v-if="serverRegion"
|
||||
:region="serverRegion"
|
||||
class="smart-clickable:allow-pointer-events"
|
||||
/>
|
||||
</template>
|
||||
<ProjectCardEnvironment
|
||||
v-if="environment"
|
||||
@@ -158,7 +162,11 @@
|
||||
<div class="flex items-center gap-1">
|
||||
<template v-if="isServerProject">
|
||||
<ServerPing v-if="serverPing && serverStatusOnline" :ping="serverPing" />
|
||||
<ServerRegion v-if="serverRegion" :region="serverRegion" />
|
||||
<ServerRegion
|
||||
v-if="serverRegion"
|
||||
:region="serverRegion"
|
||||
class="smart-clickable:allow-pointer-events"
|
||||
/>
|
||||
</template>
|
||||
<ProjectCardEnvironment
|
||||
v-if="environment"
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<div class="experimental-styles-within flex flex-wrap items-center gap-1 empty:hidden">
|
||||
<div class="flex flex-wrap items-center gap-1 empty:hidden">
|
||||
<TagItem
|
||||
v-if="selectedItems.length > 1"
|
||||
class="transition-transform active:scale-[0.95]"
|
||||
|
||||
@@ -7,18 +7,13 @@
|
||||
:waiting="isWaiting"
|
||||
@dismiss="emit('dismiss')"
|
||||
>
|
||||
<template #icon>
|
||||
<slot v-if="!contentError" name="icon">
|
||||
<SpinnerIcon class="h-6 w-6 flex-none animate-spin text-brand-blue" />
|
||||
</slot>
|
||||
</template>
|
||||
<template #header>
|
||||
{{ contentError ? 'Installation failed' : "We're preparing your server" }}
|
||||
{{ headerLabel }}
|
||||
</template>
|
||||
<template v-if="contentError">
|
||||
{{ errorLabel }}
|
||||
</template>
|
||||
<template v-else-if="progress">{{ phaseLabel }}</template>
|
||||
<template v-else-if="effectivePhase">{{ phaseLabel }}</template>
|
||||
<div v-else class="ticker-container">
|
||||
<div class="ticker-content">
|
||||
<div
|
||||
@@ -35,7 +30,7 @@
|
||||
<ButtonStyled color="red" type="outlined">
|
||||
<button class="!border" type="button" @click="emit('retry')">
|
||||
<RotateCounterClockwiseIcon class="size-5" />
|
||||
Retry
|
||||
{{ formatMessage(commonMessages.retryButton) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</template>
|
||||
@@ -44,9 +39,11 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { RotateCounterClockwiseIcon } from '@modrinth/assets'
|
||||
import SpinnerIcon from '@modrinth/assets/icons/spinner.svg'
|
||||
import { computed, onMounted, onUnmounted, ref } from 'vue'
|
||||
|
||||
import { defineMessages, useVIntl } from '#ui/composables/i18n'
|
||||
import { commonMessages } from '#ui/utils/common-messages'
|
||||
|
||||
import Admonition from '../base/Admonition.vue'
|
||||
import ButtonStyled from '../base/ButtonStyled.vue'
|
||||
|
||||
@@ -62,6 +59,7 @@ export interface ContentError {
|
||||
|
||||
const props = defineProps<{
|
||||
progress?: SyncProgress | null
|
||||
fallbackPhase?: SyncProgress['phase'] | null
|
||||
contentError?: ContentError | null
|
||||
dismissible?: boolean
|
||||
}>()
|
||||
@@ -71,44 +69,123 @@ const emit = defineEmits<{
|
||||
dismiss: []
|
||||
}>()
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
|
||||
const messages = defineMessages({
|
||||
errorHeader: {
|
||||
id: 'servers.installing-banner.error.header',
|
||||
defaultMessage: 'Installation failed',
|
||||
},
|
||||
preparingHeader: {
|
||||
id: 'servers.installing-banner.preparing.header',
|
||||
defaultMessage: "We're preparing your server",
|
||||
},
|
||||
invalidLoaderVersionError: {
|
||||
id: 'servers.installing-banner.error.invalid-loader-version',
|
||||
defaultMessage:
|
||||
'The specified loader or Minecraft version could not be installed. It may be invalid or unsupported.',
|
||||
},
|
||||
unsupportedLoaderVersionError: {
|
||||
id: 'servers.installing-banner.error.unsupported-loader-version',
|
||||
defaultMessage: 'This version of Minecraft or loader is not yet supported by Modrinth Hosting.',
|
||||
},
|
||||
internalPlatformError: {
|
||||
id: 'servers.installing-banner.error.internal-platform',
|
||||
defaultMessage: 'An internal error occurred while installing the platform. Please try again.',
|
||||
},
|
||||
noPrimaryFileError: {
|
||||
id: 'servers.installing-banner.error.no-primary-file',
|
||||
defaultMessage:
|
||||
'This modpack version does not include a downloadable file. It may have been packaged incorrectly.',
|
||||
},
|
||||
modpackInstallFailedError: {
|
||||
id: 'servers.installing-banner.error.modpack-install-failed',
|
||||
defaultMessage: 'The modpack could not be installed. It may be corrupted or incompatible.',
|
||||
},
|
||||
unknownError: {
|
||||
id: 'servers.installing-banner.error.unknown',
|
||||
defaultMessage: 'An unexpected error occurred during installation.',
|
||||
},
|
||||
installingPlatform: {
|
||||
id: 'servers.installing-banner.phase.installing-platform',
|
||||
defaultMessage: 'Installing platform...',
|
||||
},
|
||||
installingModpack: {
|
||||
id: 'servers.installing-banner.phase.installing-modpack',
|
||||
defaultMessage: 'Installing modpack...',
|
||||
},
|
||||
installingAddons: {
|
||||
id: 'servers.installing-banner.phase.installing-addons',
|
||||
defaultMessage: 'Installing addons...',
|
||||
},
|
||||
tickerOrganizingFiles: {
|
||||
id: 'servers.installing-banner.ticker.organizing-files',
|
||||
defaultMessage: 'Organizing files...',
|
||||
},
|
||||
tickerDownloadingMods: {
|
||||
id: 'servers.installing-banner.ticker.downloading-mods',
|
||||
defaultMessage: 'Downloading mods...',
|
||||
},
|
||||
tickerConfiguringServer: {
|
||||
id: 'servers.installing-banner.ticker.configuring-server',
|
||||
defaultMessage: 'Configuring server...',
|
||||
},
|
||||
tickerSettingUpEnvironment: {
|
||||
id: 'servers.installing-banner.ticker.setting-up-environment',
|
||||
defaultMessage: 'Setting up environment...',
|
||||
},
|
||||
tickerAddingJava: {
|
||||
id: 'servers.installing-banner.ticker.adding-java',
|
||||
defaultMessage: 'Adding Java...',
|
||||
},
|
||||
})
|
||||
|
||||
const errorLabel = computed(() => {
|
||||
const desc = props.contentError?.description?.toLowerCase()
|
||||
const step = props.contentError?.step
|
||||
|
||||
if (step === 'modloader') {
|
||||
if (desc === 'the specified version may be incorrect') {
|
||||
return 'The specified loader or Minecraft version could not be installed. It may be invalid or unsupported.'
|
||||
return formatMessage(messages.invalidLoaderVersionError)
|
||||
}
|
||||
if (desc === 'this version is not yet supported') {
|
||||
return 'This version of Minecraft or loader is not yet supported by Modrinth Hosting.'
|
||||
return formatMessage(messages.unsupportedLoaderVersionError)
|
||||
}
|
||||
if (desc === 'internal error') {
|
||||
return 'An internal error occurred while installing the platform. Please try again.'
|
||||
return formatMessage(messages.internalPlatformError)
|
||||
}
|
||||
}
|
||||
|
||||
if (step === 'modpack') {
|
||||
if (desc?.includes('no primary file')) {
|
||||
return 'This modpack version does not include a downloadable file. It may have been packaged incorrectly.'
|
||||
return formatMessage(messages.noPrimaryFileError)
|
||||
}
|
||||
if (desc?.includes('failed to install')) {
|
||||
return 'The modpack could not be installed. It may be corrupted or incompatible.'
|
||||
return formatMessage(messages.modpackInstallFailedError)
|
||||
}
|
||||
}
|
||||
|
||||
return props.contentError?.description ?? 'An unexpected error occurred during installation.'
|
||||
return props.contentError?.description ?? formatMessage(messages.unknownError)
|
||||
})
|
||||
|
||||
const effectivePhase = computed(() => props.progress?.phase ?? props.fallbackPhase ?? null)
|
||||
|
||||
const headerLabel = computed(() => {
|
||||
if (props.contentError) return formatMessage(messages.errorHeader)
|
||||
if (effectivePhase.value === 'Addons') return formatMessage(commonMessages.installingContentLabel)
|
||||
return formatMessage(messages.preparingHeader)
|
||||
})
|
||||
|
||||
const phaseLabel = computed(() => {
|
||||
switch (props.progress?.phase) {
|
||||
switch (effectivePhase.value) {
|
||||
case 'InstallingLoader':
|
||||
return 'Installing platform...'
|
||||
return formatMessage(messages.installingPlatform)
|
||||
case 'InstallingPack':
|
||||
return 'Installing modpack...'
|
||||
return formatMessage(messages.installingModpack)
|
||||
case 'Addons':
|
||||
return 'Installing addons...'
|
||||
return formatMessage(messages.installingAddons)
|
||||
default:
|
||||
return 'Installing...'
|
||||
return formatMessage(commonMessages.installingLabel)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -122,13 +199,13 @@ const isWaiting = computed(() => {
|
||||
return !props.progress || props.progress.percent <= 0
|
||||
})
|
||||
|
||||
const tickerMessages = [
|
||||
'Organizing files...',
|
||||
'Downloading mods...',
|
||||
'Configuring server...',
|
||||
'Setting up environment...',
|
||||
'Adding Java...',
|
||||
]
|
||||
const tickerMessages = computed(() => [
|
||||
formatMessage(messages.tickerOrganizingFiles),
|
||||
formatMessage(messages.tickerDownloadingMods),
|
||||
formatMessage(messages.tickerConfiguringServer),
|
||||
formatMessage(messages.tickerSettingUpEnvironment),
|
||||
formatMessage(messages.tickerAddingJava),
|
||||
])
|
||||
|
||||
const currentIndex = ref(0)
|
||||
|
||||
@@ -136,7 +213,7 @@ let intervalId: ReturnType<typeof setInterval> | null = null
|
||||
|
||||
onMounted(() => {
|
||||
intervalId = setInterval(() => {
|
||||
currentIndex.value = (currentIndex.value + 1) % tickerMessages.length
|
||||
currentIndex.value = (currentIndex.value + 1) % tickerMessages.value.length
|
||||
}, 3000)
|
||||
})
|
||||
|
||||
|
||||
@@ -174,7 +174,6 @@
|
||||
>
|
||||
<button
|
||||
v-tooltip="formatMessage(messages.downloadLatestBackupTooltip)"
|
||||
class="!border-surface-4"
|
||||
data-server-listing-button
|
||||
@click="onDownloadBackup"
|
||||
>
|
||||
@@ -184,7 +183,6 @@
|
||||
<ButtonStyled v-if="noticeButtons.copyId" type="outlined">
|
||||
<button
|
||||
v-tooltip="formatMessage(messages.copyCodeToClipboardTooltip)"
|
||||
class="!border-surface-4"
|
||||
data-server-listing-button
|
||||
@click="copyToClipboard(server_id)"
|
||||
>
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
<span>
|
||||
{{
|
||||
formatMessage(messages.extracted, {
|
||||
size: 'bytes_processed' in op ? formatBytes(op.bytes_processed ?? 0) : '0 B',
|
||||
size: formatBytes(op.bytes_processed ?? 0),
|
||||
})
|
||||
}}
|
||||
</span>
|
||||
@@ -35,11 +35,11 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { PackageOpenIcon } from '@modrinth/assets'
|
||||
import { formatBytes } from '@modrinth/utils'
|
||||
import { computed } from 'vue'
|
||||
|
||||
import Admonition from '#ui/components/base/Admonition.vue'
|
||||
import ButtonStyled from '#ui/components/base/ButtonStyled.vue'
|
||||
import { useFormatBytes } from '#ui/composables'
|
||||
import { defineMessages, useVIntl } from '#ui/composables/i18n'
|
||||
import type { FileOperation } from '#ui/layouts/shared/files-tab/types'
|
||||
import { injectModrinthServerContext } from '#ui/providers'
|
||||
@@ -53,6 +53,7 @@ const props = defineProps<{
|
||||
}>()
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
const formatBytes = useFormatBytes()
|
||||
const ctx = injectModrinthServerContext()
|
||||
|
||||
const messages = defineMessages({
|
||||
|
||||
@@ -6,7 +6,6 @@ import Admonition from '#ui/components/base/Admonition.vue'
|
||||
import StackedAdmonitions, {
|
||||
type StackedAdmonitionItem,
|
||||
} from '#ui/components/base/StackedAdmonitions.vue'
|
||||
import { ServerIcon } from '#ui/components/servers/icons'
|
||||
import InstallingBanner, {
|
||||
type ContentError,
|
||||
type SyncProgress,
|
||||
@@ -23,7 +22,6 @@ import UploadAdmonition from './UploadAdmonition.vue'
|
||||
const props = defineProps<{
|
||||
syncProgress?: SyncProgress | null
|
||||
contentError?: ContentError | null
|
||||
serverImage?: string
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
@@ -59,7 +57,13 @@ const isOnContentTab = computed(() => route.path.includes('/content'))
|
||||
const isOnFilesTab = computed(() => route.path.includes('/files'))
|
||||
|
||||
const bannerCoversInstalling = computed(
|
||||
() => ctx.server.value?.status === 'installing' || ctx.isSyncingContent.value,
|
||||
() =>
|
||||
ctx.server.value?.status === 'installing' ||
|
||||
ctx.isSyncingContent.value ||
|
||||
ctx.busyReasons.value.some(
|
||||
(r) =>
|
||||
r.reason.id === 'servers.busy.installing' || r.reason.id === 'servers.busy.syncing-content',
|
||||
),
|
||||
)
|
||||
|
||||
function isBackupReason(id: string) {
|
||||
@@ -165,8 +169,7 @@ type ServerAdmonitionItem = StackedAdmonitionItem & {
|
||||
|
||||
const showInstallingBanner = computed(() => {
|
||||
if (!ctx.server.value) return false
|
||||
const installing =
|
||||
ctx.server.value.status === 'installing' || ctx.isSyncingContent.value || !!props.contentError
|
||||
const installing = bannerCoversInstalling.value || !!props.contentError
|
||||
if (!installing) return false
|
||||
if (contentErrorKey.value && dismissedContentErrorKey.value === contentErrorKey.value)
|
||||
return false
|
||||
@@ -366,15 +369,12 @@ function onContentErrorDismiss() {
|
||||
<InstallingBanner
|
||||
v-if="item.kind === 'installing'"
|
||||
:progress="syncProgress"
|
||||
:fallback-phase="isOnContentTab && !syncProgress ? 'Addons' : null"
|
||||
:content-error="contentError"
|
||||
:dismissible="dismissible && !!contentError"
|
||||
@dismiss="onContentErrorDismiss"
|
||||
@retry="emit('content-retry')"
|
||||
>
|
||||
<template #icon>
|
||||
<ServerIcon :image="serverImage" class="!h-6 !w-6" />
|
||||
</template>
|
||||
</InstallingBanner>
|
||||
/>
|
||||
<UploadAdmonition v-else-if="item.kind === 'upload'" />
|
||||
<FileOperationAdmonition
|
||||
v-else-if="item.kind === 'fs-op'"
|
||||
|
||||
@@ -25,13 +25,15 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { UploadIcon } from '@modrinth/assets'
|
||||
import { formatBytes } from '@modrinth/utils'
|
||||
import { computed } from 'vue'
|
||||
|
||||
import Admonition from '#ui/components/base/Admonition.vue'
|
||||
import ButtonStyled from '#ui/components/base/ButtonStyled.vue'
|
||||
import { useFormatBytes } from '#ui/composables'
|
||||
import { injectModrinthServerContext } from '#ui/providers'
|
||||
|
||||
const formatBytes = useFormatBytes()
|
||||
|
||||
const ctx = injectModrinthServerContext()
|
||||
|
||||
const state = computed(() => ctx.uploadState.value)
|
||||
|
||||
@@ -47,7 +47,7 @@
|
||||
<template #actions>
|
||||
<div class="flex gap-2 justify-end">
|
||||
<ButtonStyled type="outlined">
|
||||
<button class="!border !border-surface-4" @click="hideModal">
|
||||
<button @click="hideModal">
|
||||
<XIcon />
|
||||
Cancel
|
||||
</button>
|
||||
|
||||
@@ -61,7 +61,7 @@
|
||||
<template #actions>
|
||||
<div class="flex justify-end gap-2">
|
||||
<ButtonStyled type="outlined">
|
||||
<button class="!border !border-surface-4" @click="modal?.hide()">
|
||||
<button @click="modal?.hide()">
|
||||
<XIcon />
|
||||
{{ formatMessage(commonMessages.cancelButton) }}
|
||||
</button>
|
||||
|
||||
@@ -23,7 +23,7 @@
|
||||
<template #actions>
|
||||
<div class="flex gap-2 justify-end">
|
||||
<ButtonStyled type="outlined">
|
||||
<button class="!border !border-surface-4" @click="hide">
|
||||
<button @click="hide">
|
||||
<XIcon />
|
||||
Cancel
|
||||
</button>
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user