3.0.0原版
This commit is contained in:
Executable
+74
@@ -0,0 +1,74 @@
|
|||||||
|
### Maven
|
||||||
|
target/
|
||||||
|
logs/
|
||||||
|
!.mvn/wrapper/maven-wrapper.jar
|
||||||
|
|
||||||
|
### Gradle
|
||||||
|
.gradle
|
||||||
|
/build/
|
||||||
|
/out/
|
||||||
|
!gradle/wrapper/gradle-wrapper.jar
|
||||||
|
bin/
|
||||||
|
|
||||||
|
### STS ###
|
||||||
|
.apt_generated
|
||||||
|
.classpath
|
||||||
|
.factorypath
|
||||||
|
.project
|
||||||
|
.settings
|
||||||
|
.springBeans
|
||||||
|
.sts4-cache
|
||||||
|
|
||||||
|
### IntelliJ IDEA ###
|
||||||
|
.idea
|
||||||
|
*.iws
|
||||||
|
*.iml
|
||||||
|
*.ipr
|
||||||
|
log/
|
||||||
|
|
||||||
|
### NetBeans ###
|
||||||
|
nbproject/private/
|
||||||
|
build/
|
||||||
|
nbbuild/
|
||||||
|
dist/
|
||||||
|
nbdist/
|
||||||
|
.nb-gradle/
|
||||||
|
|
||||||
|
### Mac
|
||||||
|
.DS_Store
|
||||||
|
*/.DS_Store
|
||||||
|
|
||||||
|
### VS Code ###
|
||||||
|
*.project
|
||||||
|
*.factorypath
|
||||||
|
|
||||||
|
### Compiled class file
|
||||||
|
*.class
|
||||||
|
|
||||||
|
### Log file
|
||||||
|
*.log
|
||||||
|
|
||||||
|
### BlueJ files
|
||||||
|
*.ctxt
|
||||||
|
|
||||||
|
### Mobile Tools for Java (J2ME)
|
||||||
|
.mtj.tmp/
|
||||||
|
|
||||||
|
### Package Files
|
||||||
|
*.war
|
||||||
|
*.nar
|
||||||
|
*.ear
|
||||||
|
*.zip
|
||||||
|
*.tar.gz
|
||||||
|
*.rar
|
||||||
|
|
||||||
|
### Local file
|
||||||
|
application-local.yml
|
||||||
|
application-local.yaml
|
||||||
|
application-local.properties
|
||||||
|
|
||||||
|
/packages/*/node_modules/
|
||||||
|
node_modules
|
||||||
|
/workplace/
|
||||||
|
/src/main/resources/static/
|
||||||
|
/src/main/resources/console/
|
||||||
@@ -0,0 +1,674 @@
|
|||||||
|
GNU GENERAL PUBLIC LICENSE
|
||||||
|
Version 3, 29 June 2007
|
||||||
|
|
||||||
|
Copyright (C) 2007 Free Software Foundation, Inc. <http://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 <http://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
|
||||||
|
<http://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
|
||||||
|
<http://www.gnu.org/philosophy/why-not-lgpl.html>.
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
reviewers:
|
||||||
|
- ruibaby
|
||||||
|
- guqing
|
||||||
|
- JohnNiang
|
||||||
|
- wangzhen-fit2cloud
|
||||||
|
|
||||||
|
approvers:
|
||||||
|
- ruibaby
|
||||||
|
- guqing
|
||||||
|
- JohnNiang
|
||||||
@@ -0,0 +1,275 @@
|
|||||||
|
# plugin-comment-widget
|
||||||
|
|
||||||
|
Halo 2.0 的通用评论组件插件,为前台提供完整的评论解决方案。
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
## 使用方式
|
||||||
|
|
||||||
|
1. 下载,目前提供以下两个下载方式:
|
||||||
|
- GitHub Releases:访问 [Releases](https://github.com/halo-dev/plugin-comment-widget/releases) 下载 Assets 中的 JAR 文件。
|
||||||
|
- Halo 应用市场:<https://www.halo.run/store/apps/app-YXyaD>。
|
||||||
|
2. 安装,插件安装和更新方式可参考:<https://docs.halo.run/user-guide/plugins>。
|
||||||
|
|
||||||
|
> 需要注意的是,此插件需要主题进行适配,不会主动在内容页加载评论组件。
|
||||||
|
|
||||||
|
## 开发环境
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git clone git@github.com:halo-dev/plugin-comment-widget.git
|
||||||
|
|
||||||
|
# 或者当你 fork 之后
|
||||||
|
|
||||||
|
git clone git@github.com:{your_github_id}/plugin-comment-widget.git
|
||||||
|
```
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd path/to/plugin-comment-widget
|
||||||
|
```
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./gradlew pnpmInstall
|
||||||
|
|
||||||
|
# 启动一个 Docker 容器作为开发环境并自动加载此插件
|
||||||
|
./gradlew haloServer
|
||||||
|
```
|
||||||
|
|
||||||
|
Halo 插件的详细开发文档可查阅 [插件开发](https://docs.halo.run/category/%E6%8F%92%E4%BB%B6%E5%BC%80%E5%8F%91)。
|
||||||
|
|
||||||
|
## 主题适配
|
||||||
|
|
||||||
|
### 接入
|
||||||
|
|
||||||
|
接入文档可参考 [自定义标签](https://docs.halo.run/developer-guide/theme/template-tag#halocomment)。
|
||||||
|
|
||||||
|
### 自定义样式
|
||||||
|
|
||||||
|
虽然目前不能直接为评论组件编写额外的样式,但可以通过一系列的 CSS 变量来自定义部分样式,开发者可以根据需求自行在主题中添加这些 CSS 变量,让评论组件和主题更好地融合。
|
||||||
|
|
||||||
|
目前已提供的 CSS 变量:
|
||||||
|
|
||||||
|
| 变量名 | 描述 |
|
||||||
|
| ---------------------------- | ---------------------------------------- |
|
||||||
|
| `--halo-cw-primary-1-color` | 主要的主题色,用于按钮背景,输入框边框等 |
|
||||||
|
| `--halo-cw-primary-2-color` | 较浅的主题色 |
|
||||||
|
| `--halo-cw-primary-3-color` | 最浅的主题色 |
|
||||||
|
| `--halo-cw-text-1-color` | 主要文本颜色,用于标题、正文等 |
|
||||||
|
| `--halo-cw-text-2-color` | 次要文本颜色 |
|
||||||
|
| `--halo-cw-text-3-color` | 辅助文本颜色 |
|
||||||
|
| `--halo-cw-muted-1-color` | 弱化色,用于边框、分割线、背景等辅助元素 |
|
||||||
|
| `--halo-cw-muted-2-color` | 更浅的弱化色 |
|
||||||
|
| `--halo-cw-muted-3-color` | 最浅的弱化色 |
|
||||||
|
| `--halo-cw-base-rounded` | 基础圆角大小 |
|
||||||
|
| `--halo-cw-avatar-rounded` | 头像圆角大小 |
|
||||||
|
| `--halo-cw-avatar-size` | 头像尺寸 |
|
||||||
|
| `--halo-cw-base-font-size` | 基础字体大小 |
|
||||||
|
| `--halo-cw-base-font-family` | 基础字体族 |
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary>点击查看 CSS 代码模板</summary>
|
||||||
|
|
||||||
|
```css
|
||||||
|
:root {
|
||||||
|
--halo-cw-primary-1-color: ;
|
||||||
|
--halo-cw-primary-2-color: ;
|
||||||
|
--halo-cw-primary-3-color: ;
|
||||||
|
|
||||||
|
--halo-cw-text-1-color: ;
|
||||||
|
--halo-cw-text-2-color: ;
|
||||||
|
--halo-cw-text-3-color: ;
|
||||||
|
|
||||||
|
--halo-cw-muted-1-color: ;
|
||||||
|
--halo-cw-muted-2-color: ;
|
||||||
|
--halo-cw-muted-3-color: ;
|
||||||
|
|
||||||
|
--halo-cw-base-rounded: ;
|
||||||
|
--halo-cw-avatar-rounded: ;
|
||||||
|
--halo-cw-avatar-size: ;
|
||||||
|
--halo-cw-base-font-size: ;
|
||||||
|
--halo-cw-base-font-family: ;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary>3.0.0 版本之前已废弃的 CSS 变量</summary>
|
||||||
|
|
||||||
|
| 变量名 | 描述 | 备注 |
|
||||||
|
| ----------------------------------------------------------------------- | ------------------------ | -------------------------------------------------- |
|
||||||
|
| `--halo-comment-widget-base-color` | 基础文字颜色 | 已废弃,后续使用 `--halo-cw-text-1-color` 代替 |
|
||||||
|
| `--halo-comment-widget-base-info-color` | 非重要突出文字 | 已废弃,后续使用 `--halo-cw-muted-*-color` 代替 |
|
||||||
|
| `--halo-comment-widget-base-border-radius` | 基础元素的圆角 | 已废弃,后续使用 `--halo-cw-base-rounded` 代替 |
|
||||||
|
| `--halo-comment-widget-base-font-size` | 基础字体大小 | 已废弃,后续使用 `--halo-cw-base-font-size` 代替 |
|
||||||
|
| `--halo-comment-widget-base-line-height` | 基础行高 | 已废弃 |
|
||||||
|
| `--halo-comment-widget-base-font-family` | 基础字体族 | 已废弃,后续使用 `--halo-cw-base-font-family` 代替 |
|
||||||
|
| `--halo-comment-widget-component-avatar-rounded` | 头像的圆角大小 | 已废弃,后续使用 `--halo-cw-avatar-rounded` 代替 |
|
||||||
|
| `--halo-comment-widget-component-avatar-size` | 头像大小 | 已废弃,后续使用 `--halo-cw-avatar-size` 代替 |
|
||||||
|
| `--halo-comment-widget-component-form-input-bg-color` | 表单输入背景颜色 | 已废弃 |
|
||||||
|
| `--halo-comment-widget-component-form-input-color` | 表单输入文字颜色 | 已废弃 |
|
||||||
|
| `--halo-comment-widget-component-form-input-border-color` | 表单输入边框颜色 | 已废弃 |
|
||||||
|
| `--halo-comment-widget-component-form-input-border-color-focus` | 表单输入焦点时边框颜色 | 已废弃,后续使用 `--halo-cw-primary-1-color` 代替 |
|
||||||
|
| `--halo-comment-widget-component-form-input-box-shadow-focus` | 表单输入焦点时的阴影 | 已废弃,后续使用 `--halo-cw-primary-2-color` 代替 |
|
||||||
|
| `--halo-comment-widget-component-form-button-login-bg-color` | 登录按钮背景颜色 | 已废弃 |
|
||||||
|
| `--halo-comment-widget-component-form-button-login-bg-color-hover` | 登录按钮悬停背景颜色 | 已废弃 |
|
||||||
|
| `--halo-comment-widget-component-form-button-login-border-color` | 登录按钮边框颜色 | 已废弃 |
|
||||||
|
| `--halo-comment-widget-component-form-button-submit-bg-color` | 提交按钮背景颜色 | 已废弃,后续使用 `--halo-cw-primary-1-color` 代替 |
|
||||||
|
| `--halo-comment-widget-component-form-button-submit-color` | 提交按钮文字颜色 | 已废弃 |
|
||||||
|
| `--halo-comment-widget-component-form-button-submit-border-color` | 提交按钮边框颜色 | 已废弃 |
|
||||||
|
| `--halo-comment-widget-component-form-button-submit-border-color-hover` | 提交按钮悬停边框颜色 | 已废弃,后续使用 `--halo-cw-primary-3-color` 代替 |
|
||||||
|
| `--halo-comment-widget-component-form-button-emoji-color` | 表情按钮颜色 | 已废弃 |
|
||||||
|
| `--halo-comment-widget-component-comment-item-action-bg-color-hover` | 评论项操作悬停背景颜色 | 已废弃 |
|
||||||
|
| `--halo-comment-widget-component-comment-item-action-color-hover` | 评论项操作悬停颜色 | 已废弃 |
|
||||||
|
| `--halo-comment-widget-component-pagination-button-bg-color-hover` | 分页按钮悬停背景颜色 | 已废弃 |
|
||||||
|
| `--halo-comment-widget-component-pagination-button-bg-color-active` | 分页按钮活动状态背景颜色 | 已废弃 |
|
||||||
|
| `--halo-comment-widget-component-pagination-button-border-color-active` | 分页按钮活动状态边框颜色 | 已废弃 |
|
||||||
|
| `--halo-comment-widget-component-emoji-picker-rgb-color` | 表情选择器颜色 | 已废弃 |
|
||||||
|
| `--halo-comment-widget-component-emoji-picker-rgb-accent` | 表情选择器强调颜色 | 已废弃 |
|
||||||
|
| `--halo-comment-widget-component-emoji-picker-rgb-background` | 表情选择器背景颜色 | 已废弃 |
|
||||||
|
| `--halo-comment-widget-component-emoji-picker-rgb-input` | 表情选择器输入颜色 | 已废弃 |
|
||||||
|
| `--halo-comment-widget-component-emoji-picker-color-border` | 表情选择器边框颜色 | 已废弃 |
|
||||||
|
| `--halo-comment-widget-component-emoji-picker-color-border-over` | 表情选择器悬停边框颜色 | 已废弃 |
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
|
### 配色切换方案
|
||||||
|
|
||||||
|
根据上面提供的 CSS 变量,也可以通过定义 CSS 变量的方式为评论组件提供动态切换配色的功能。
|
||||||
|
|
||||||
|
以下是实现示例,你可以根据需求自行修改选择器或者媒体查询。
|
||||||
|
|
||||||
|
```css
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
.color-scheme-auto,
|
||||||
|
[data-color-scheme='auto'] {
|
||||||
|
color-scheme: dark;
|
||||||
|
|
||||||
|
--halo-cw-primary-1-color: #059669;
|
||||||
|
--halo-cw-primary-2-color: #047857;
|
||||||
|
--halo-cw-primary-3-color: #065f46;
|
||||||
|
|
||||||
|
--halo-cw-text-1-color: #f9fafb;
|
||||||
|
--halo-cw-text-2-color: #e5e7eb;
|
||||||
|
--halo-cw-text-3-color: #9ca3af;
|
||||||
|
|
||||||
|
--halo-cw-muted-1-color: #4b5563;
|
||||||
|
--halo-cw-muted-2-color: #374151;
|
||||||
|
--halo-cw-muted-3-color: #1f2937;
|
||||||
|
|
||||||
|
--halo-cw-emoji-picker-rgb-color: 222, 222, 221;
|
||||||
|
--halo-cw-emoji-picker-rgb-accent: 58, 130, 247;
|
||||||
|
--halo-cw-emoji-picker-rgb-background: 21, 22, 23;
|
||||||
|
--halo-cw-emoji-picker-rgb-input: 0, 0, 0;
|
||||||
|
--halo-cw-emoji-picker-color-border: rgba(255, 255, 255, 0.1);
|
||||||
|
--halo-cw-emoji-picker-color-border-over: rgba(255, 255, 255, 0.2);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.color-scheme-dark,
|
||||||
|
.dark,
|
||||||
|
[data-color-scheme='dark'] {
|
||||||
|
color-scheme: dark;
|
||||||
|
|
||||||
|
--halo-cw-primary-1-color: #059669;
|
||||||
|
--halo-cw-primary-2-color: #047857;
|
||||||
|
--halo-cw-primary-3-color: #065f46;
|
||||||
|
|
||||||
|
--halo-cw-text-1-color: #f9fafb;
|
||||||
|
--halo-cw-text-2-color: #e5e7eb;
|
||||||
|
--halo-cw-text-3-color: #9ca3af;
|
||||||
|
|
||||||
|
--halo-cw-muted-1-color: #4b5563;
|
||||||
|
--halo-cw-muted-2-color: #374151;
|
||||||
|
--halo-cw-muted-3-color: #1f2937;
|
||||||
|
|
||||||
|
--halo-cw-emoji-picker-rgb-color: 222, 222, 221;
|
||||||
|
--halo-cw-emoji-picker-rgb-accent: 58, 130, 247;
|
||||||
|
--halo-cw-emoji-picker-rgb-background: 21, 22, 23;
|
||||||
|
--halo-cw-emoji-picker-rgb-input: 0, 0, 0;
|
||||||
|
--halo-cw-emoji-picker-color-border: rgba(255, 255, 255, 0.1);
|
||||||
|
--halo-cw-emoji-picker-color-border-over: rgba(255, 255, 255, 0.2);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
此外,为了让主题可以更加方便的适配暗黑模式,此插件也提供了一套暗黑模式的配色方案,主题可以直接使用此方案,而不需要自己去适配暗黑模式,适配方式如下:
|
||||||
|
|
||||||
|
1. 在 html 或者 body 标签添加 class:
|
||||||
|
1. `color-scheme-auto`:自动模式,根据系统的暗黑模式自动切换。
|
||||||
|
2. `color-scheme-dark` / `dark`:强制暗黑模式。
|
||||||
|
3. `color-scheme-light` / `light`:强制明亮模式。
|
||||||
|
2. 在 html 或者 body 标签添加 `data-color-scheme` 属性:
|
||||||
|
1. `auto`:自动模式,根据系统的暗黑模式自动切换。
|
||||||
|
2. `dark`:强制暗黑模式。
|
||||||
|
3. `light`:强制明亮模式。
|
||||||
|
|
||||||
|
## 作为组件使用
|
||||||
|
|
||||||
|
此插件的 UI 部分采用 [Lit Element](https://lit.dev/) 编写,并最终编译为 Web Component,所以理论上可以在任何 JS 框架中使用。这非常适用于将 Halo 作为 Headless CMS 使用的场景。例如使用 Vue、React 等框架编写网站,并调用 Halo 的 API 来渲染网页,这个时候文章评论的解决方案就可以直接使用此组件。
|
||||||
|
|
||||||
|
安装:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm install @halo-dev/comment-widget
|
||||||
|
```
|
||||||
|
|
||||||
|
### Vue
|
||||||
|
|
||||||
|
在 Vue 组件中使用时,需要在 Vue 的编译选项中设置将此组件标记为非 Vue 组件,以下是 Vite 的配置示例:
|
||||||
|
|
||||||
|
```js
|
||||||
|
import vue from '@vitejs/plugin-vue'
|
||||||
|
|
||||||
|
export default {
|
||||||
|
plugins: [
|
||||||
|
vue({
|
||||||
|
template: {
|
||||||
|
compilerOptions: {
|
||||||
|
isCustomElement: (tag) => tag === 'comment-widget'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
然后在 SFC 中使用即可:
|
||||||
|
|
||||||
|
```vue
|
||||||
|
<script setup>
|
||||||
|
import '@halo-dev/comment-widget'
|
||||||
|
import '@halo-dev/comment-widget/var.css';
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<comment-widget
|
||||||
|
baseUrl="https://demo.halo.run"
|
||||||
|
group="content.halo.run"
|
||||||
|
kind="Post"
|
||||||
|
version="v1alpha1"
|
||||||
|
name="e0507f6f-88bb-4d3c-a90a-a88aba222035"
|
||||||
|
></comment-widget>
|
||||||
|
</template>
|
||||||
|
```
|
||||||
|
|
||||||
|
## React
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import "@halo-dev/comment-widget";
|
||||||
|
import "@halo-dev/comment-widget/var.css";
|
||||||
|
|
||||||
|
function App() {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<comment-widget
|
||||||
|
baseUrl="https://demo.halo.run"
|
||||||
|
group="content.halo.run"
|
||||||
|
kind="Post"
|
||||||
|
version="v1alpha1"
|
||||||
|
name="e0507f6f-88bb-4d3c-a90a-a88aba222035"
|
||||||
|
></comment-widget>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default App;
|
||||||
|
```
|
||||||
+37
@@ -0,0 +1,37 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://biomejs.dev/schemas/2.2.0/schema.json",
|
||||||
|
"vcs": {
|
||||||
|
"enabled": true,
|
||||||
|
"clientKind": "git",
|
||||||
|
"useIgnoreFile": true
|
||||||
|
},
|
||||||
|
"files": {
|
||||||
|
"ignoreUnknown": false,
|
||||||
|
"includes": ["**", "!**/generated"]
|
||||||
|
},
|
||||||
|
"formatter": {
|
||||||
|
"enabled": true,
|
||||||
|
"indentStyle": "space",
|
||||||
|
"indentWidth": 2
|
||||||
|
},
|
||||||
|
"linter": {
|
||||||
|
"enabled": true,
|
||||||
|
"rules": {
|
||||||
|
"recommended": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"javascript": {
|
||||||
|
"formatter": {
|
||||||
|
"quoteStyle": "single",
|
||||||
|
"trailingCommas": "es5"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"assist": {
|
||||||
|
"enabled": true,
|
||||||
|
"actions": {
|
||||||
|
"source": {
|
||||||
|
"organizeImports": "on"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
plugins {
|
||||||
|
id 'java'
|
||||||
|
id "io.freefair.lombok" version "8.14"
|
||||||
|
id "com.github.node-gradle.node" version "7.1.0"
|
||||||
|
id "run.halo.plugin.devtools" version "0.6.2"
|
||||||
|
}
|
||||||
|
|
||||||
|
group 'run.halo.comment.widget'
|
||||||
|
|
||||||
|
java {
|
||||||
|
toolchain {
|
||||||
|
languageVersion = JavaLanguageVersion.of(21)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
tasks.withType(JavaCompile).configureEach {
|
||||||
|
options.encoding = "UTF-8"
|
||||||
|
options.release = 21
|
||||||
|
}
|
||||||
|
|
||||||
|
repositories {
|
||||||
|
mavenCentral()
|
||||||
|
}
|
||||||
|
|
||||||
|
dependencies {
|
||||||
|
implementation platform('run.halo.tools.platform:plugin:2.21.0')
|
||||||
|
compileOnly 'run.halo.app:api'
|
||||||
|
|
||||||
|
testImplementation 'run.halo.app:api'
|
||||||
|
testImplementation 'org.springframework.boot:spring-boot-starter-test'
|
||||||
|
}
|
||||||
|
|
||||||
|
test {
|
||||||
|
useJUnitPlatform()
|
||||||
|
}
|
||||||
|
|
||||||
|
node {
|
||||||
|
nodeProjectDir = file("${project.projectDir}")
|
||||||
|
}
|
||||||
|
|
||||||
|
tasks.register('buildFrontend', PnpmTask) {
|
||||||
|
args = ['build']
|
||||||
|
dependsOn('installDepsForUI')
|
||||||
|
}
|
||||||
|
|
||||||
|
tasks.register('installDepsForUI', PnpmTask) {
|
||||||
|
args = ['install']
|
||||||
|
}
|
||||||
|
|
||||||
|
build {
|
||||||
|
// build frontend before build
|
||||||
|
tasks.named('compileJava').configure {
|
||||||
|
dependsOn('buildFrontend')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
halo {
|
||||||
|
version = "2.21.6"
|
||||||
|
debug = true
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
version=3.0.0
|
||||||
Vendored
BIN
Binary file not shown.
+7
@@ -0,0 +1,7 @@
|
|||||||
|
distributionBase=GRADLE_USER_HOME
|
||||||
|
distributionPath=wrapper/dists
|
||||||
|
distributionUrl=https\://services.gradle.org/distributions/gradle-9.0.0-bin.zip
|
||||||
|
networkTimeout=10000
|
||||||
|
validateDistributionUrl=true
|
||||||
|
zipStoreBase=GRADLE_USER_HOME
|
||||||
|
zipStorePath=wrapper/dists
|
||||||
@@ -0,0 +1,251 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
|
||||||
|
#
|
||||||
|
# Copyright © 2015 the original authors.
|
||||||
|
#
|
||||||
|
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
# you may not use this file except in compliance with the License.
|
||||||
|
# You may obtain a copy of the License at
|
||||||
|
#
|
||||||
|
# https://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
#
|
||||||
|
# Unless required by applicable law or agreed to in writing, software
|
||||||
|
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
# See the License for the specific language governing permissions and
|
||||||
|
# limitations under the License.
|
||||||
|
#
|
||||||
|
# SPDX-License-Identifier: Apache-2.0
|
||||||
|
#
|
||||||
|
|
||||||
|
##############################################################################
|
||||||
|
#
|
||||||
|
# Gradle start up script for POSIX generated by Gradle.
|
||||||
|
#
|
||||||
|
# Important for running:
|
||||||
|
#
|
||||||
|
# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
|
||||||
|
# noncompliant, but you have some other compliant shell such as ksh or
|
||||||
|
# bash, then to run this script, type that shell name before the whole
|
||||||
|
# command line, like:
|
||||||
|
#
|
||||||
|
# ksh Gradle
|
||||||
|
#
|
||||||
|
# Busybox and similar reduced shells will NOT work, because this script
|
||||||
|
# requires all of these POSIX shell features:
|
||||||
|
# * functions;
|
||||||
|
# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
|
||||||
|
# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
|
||||||
|
# * compound commands having a testable exit status, especially «case»;
|
||||||
|
# * various built-in commands including «command», «set», and «ulimit».
|
||||||
|
#
|
||||||
|
# Important for patching:
|
||||||
|
#
|
||||||
|
# (2) This script targets any POSIX shell, so it avoids extensions provided
|
||||||
|
# by Bash, Ksh, etc; in particular arrays are avoided.
|
||||||
|
#
|
||||||
|
# The "traditional" practice of packing multiple parameters into a
|
||||||
|
# space-separated string is a well documented source of bugs and security
|
||||||
|
# problems, so this is (mostly) avoided, by progressively accumulating
|
||||||
|
# options in "$@", and eventually passing that to Java.
|
||||||
|
#
|
||||||
|
# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
|
||||||
|
# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
|
||||||
|
# see the in-line comments for details.
|
||||||
|
#
|
||||||
|
# There are tweaks for specific operating systems such as AIX, CygWin,
|
||||||
|
# Darwin, MinGW, and NonStop.
|
||||||
|
#
|
||||||
|
# (3) This script is generated from the Groovy template
|
||||||
|
# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
|
||||||
|
# within the Gradle project.
|
||||||
|
#
|
||||||
|
# You can find Gradle at https://github.com/gradle/gradle/.
|
||||||
|
#
|
||||||
|
##############################################################################
|
||||||
|
|
||||||
|
# Attempt to set APP_HOME
|
||||||
|
|
||||||
|
# Resolve links: $0 may be a link
|
||||||
|
app_path=$0
|
||||||
|
|
||||||
|
# Need this for daisy-chained symlinks.
|
||||||
|
while
|
||||||
|
APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
|
||||||
|
[ -h "$app_path" ]
|
||||||
|
do
|
||||||
|
ls=$( ls -ld "$app_path" )
|
||||||
|
link=${ls#*' -> '}
|
||||||
|
case $link in #(
|
||||||
|
/*) app_path=$link ;; #(
|
||||||
|
*) app_path=$APP_HOME$link ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
# This is normally unused
|
||||||
|
# shellcheck disable=SC2034
|
||||||
|
APP_BASE_NAME=${0##*/}
|
||||||
|
# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
|
||||||
|
APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit
|
||||||
|
|
||||||
|
# Use the maximum available, or set MAX_FD != -1 to use that value.
|
||||||
|
MAX_FD=maximum
|
||||||
|
|
||||||
|
warn () {
|
||||||
|
echo "$*"
|
||||||
|
} >&2
|
||||||
|
|
||||||
|
die () {
|
||||||
|
echo
|
||||||
|
echo "$*"
|
||||||
|
echo
|
||||||
|
exit 1
|
||||||
|
} >&2
|
||||||
|
|
||||||
|
# OS specific support (must be 'true' or 'false').
|
||||||
|
cygwin=false
|
||||||
|
msys=false
|
||||||
|
darwin=false
|
||||||
|
nonstop=false
|
||||||
|
case "$( uname )" in #(
|
||||||
|
CYGWIN* ) cygwin=true ;; #(
|
||||||
|
Darwin* ) darwin=true ;; #(
|
||||||
|
MSYS* | MINGW* ) msys=true ;; #(
|
||||||
|
NONSTOP* ) nonstop=true ;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
CLASSPATH="\\\"\\\""
|
||||||
|
|
||||||
|
|
||||||
|
# Determine the Java command to use to start the JVM.
|
||||||
|
if [ -n "$JAVA_HOME" ] ; then
|
||||||
|
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
|
||||||
|
# IBM's JDK on AIX uses strange locations for the executables
|
||||||
|
JAVACMD=$JAVA_HOME/jre/sh/java
|
||||||
|
else
|
||||||
|
JAVACMD=$JAVA_HOME/bin/java
|
||||||
|
fi
|
||||||
|
if [ ! -x "$JAVACMD" ] ; then
|
||||||
|
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
|
||||||
|
|
||||||
|
Please set the JAVA_HOME variable in your environment to match the
|
||||||
|
location of your Java installation."
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
JAVACMD=java
|
||||||
|
if ! command -v java >/dev/null 2>&1
|
||||||
|
then
|
||||||
|
die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
|
||||||
|
|
||||||
|
Please set the JAVA_HOME variable in your environment to match the
|
||||||
|
location of your Java installation."
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Increase the maximum file descriptors if we can.
|
||||||
|
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
|
||||||
|
case $MAX_FD in #(
|
||||||
|
max*)
|
||||||
|
# In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
|
||||||
|
# shellcheck disable=SC2039,SC3045
|
||||||
|
MAX_FD=$( ulimit -H -n ) ||
|
||||||
|
warn "Could not query maximum file descriptor limit"
|
||||||
|
esac
|
||||||
|
case $MAX_FD in #(
|
||||||
|
'' | soft) :;; #(
|
||||||
|
*)
|
||||||
|
# In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
|
||||||
|
# shellcheck disable=SC2039,SC3045
|
||||||
|
ulimit -n "$MAX_FD" ||
|
||||||
|
warn "Could not set maximum file descriptor limit to $MAX_FD"
|
||||||
|
esac
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Collect all arguments for the java command, stacking in reverse order:
|
||||||
|
# * args from the command line
|
||||||
|
# * the main class name
|
||||||
|
# * -classpath
|
||||||
|
# * -D...appname settings
|
||||||
|
# * --module-path (only if needed)
|
||||||
|
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
|
||||||
|
|
||||||
|
# For Cygwin or MSYS, switch paths to Windows format before running java
|
||||||
|
if "$cygwin" || "$msys" ; then
|
||||||
|
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
|
||||||
|
CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" )
|
||||||
|
|
||||||
|
JAVACMD=$( cygpath --unix "$JAVACMD" )
|
||||||
|
|
||||||
|
# Now convert the arguments - kludge to limit ourselves to /bin/sh
|
||||||
|
for arg do
|
||||||
|
if
|
||||||
|
case $arg in #(
|
||||||
|
-*) false ;; # don't mess with options #(
|
||||||
|
/?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
|
||||||
|
[ -e "$t" ] ;; #(
|
||||||
|
*) false ;;
|
||||||
|
esac
|
||||||
|
then
|
||||||
|
arg=$( cygpath --path --ignore --mixed "$arg" )
|
||||||
|
fi
|
||||||
|
# Roll the args list around exactly as many times as the number of
|
||||||
|
# args, so each arg winds up back in the position where it started, but
|
||||||
|
# possibly modified.
|
||||||
|
#
|
||||||
|
# NB: a `for` loop captures its iteration list before it begins, so
|
||||||
|
# changing the positional parameters here affects neither the number of
|
||||||
|
# iterations, nor the values presented in `arg`.
|
||||||
|
shift # remove old arg
|
||||||
|
set -- "$@" "$arg" # push replacement arg
|
||||||
|
done
|
||||||
|
fi
|
||||||
|
|
||||||
|
|
||||||
|
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
|
||||||
|
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
|
||||||
|
|
||||||
|
# Collect all arguments for the java command:
|
||||||
|
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
|
||||||
|
# and any embedded shellness will be escaped.
|
||||||
|
# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
|
||||||
|
# treated as '${Hostname}' itself on the command line.
|
||||||
|
|
||||||
|
set -- \
|
||||||
|
"-Dorg.gradle.appname=$APP_BASE_NAME" \
|
||||||
|
-classpath "$CLASSPATH" \
|
||||||
|
-jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \
|
||||||
|
"$@"
|
||||||
|
|
||||||
|
# Stop when "xargs" is not available.
|
||||||
|
if ! command -v xargs >/dev/null 2>&1
|
||||||
|
then
|
||||||
|
die "xargs is not available"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Use "xargs" to parse quoted args.
|
||||||
|
#
|
||||||
|
# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
|
||||||
|
#
|
||||||
|
# In Bash we could simply go:
|
||||||
|
#
|
||||||
|
# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
|
||||||
|
# set -- "${ARGS[@]}" "$@"
|
||||||
|
#
|
||||||
|
# but POSIX shell has neither arrays nor command substitution, so instead we
|
||||||
|
# post-process each arg (as a line of input to sed) to backslash-escape any
|
||||||
|
# character that might be a shell metacharacter, then use eval to reverse
|
||||||
|
# that process (while maintaining the separation between arguments), and wrap
|
||||||
|
# the whole thing up as a single "set" statement.
|
||||||
|
#
|
||||||
|
# This will of course break if any of these variables contains a newline or
|
||||||
|
# an unmatched quote.
|
||||||
|
#
|
||||||
|
|
||||||
|
eval "set -- $(
|
||||||
|
printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
|
||||||
|
xargs -n1 |
|
||||||
|
sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
|
||||||
|
tr '\n' ' '
|
||||||
|
)" '"$@"'
|
||||||
|
|
||||||
|
exec "$JAVACMD" "$@"
|
||||||
Vendored
+94
@@ -0,0 +1,94 @@
|
|||||||
|
@rem
|
||||||
|
@rem Copyright 2015 the original author or authors.
|
||||||
|
@rem
|
||||||
|
@rem Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
@rem you may not use this file except in compliance with the License.
|
||||||
|
@rem You may obtain a copy of the License at
|
||||||
|
@rem
|
||||||
|
@rem https://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
@rem
|
||||||
|
@rem Unless required by applicable law or agreed to in writing, software
|
||||||
|
@rem distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
@rem See the License for the specific language governing permissions and
|
||||||
|
@rem limitations under the License.
|
||||||
|
@rem
|
||||||
|
@rem SPDX-License-Identifier: Apache-2.0
|
||||||
|
@rem
|
||||||
|
|
||||||
|
@if "%DEBUG%"=="" @echo off
|
||||||
|
@rem ##########################################################################
|
||||||
|
@rem
|
||||||
|
@rem Gradle startup script for Windows
|
||||||
|
@rem
|
||||||
|
@rem ##########################################################################
|
||||||
|
|
||||||
|
@rem Set local scope for the variables with windows NT shell
|
||||||
|
if "%OS%"=="Windows_NT" setlocal
|
||||||
|
|
||||||
|
set DIRNAME=%~dp0
|
||||||
|
if "%DIRNAME%"=="" set DIRNAME=.
|
||||||
|
@rem This is normally unused
|
||||||
|
set APP_BASE_NAME=%~n0
|
||||||
|
set APP_HOME=%DIRNAME%
|
||||||
|
|
||||||
|
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
|
||||||
|
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
|
||||||
|
|
||||||
|
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
|
||||||
|
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
|
||||||
|
|
||||||
|
@rem Find java.exe
|
||||||
|
if defined JAVA_HOME goto findJavaFromJavaHome
|
||||||
|
|
||||||
|
set JAVA_EXE=java.exe
|
||||||
|
%JAVA_EXE% -version >NUL 2>&1
|
||||||
|
if %ERRORLEVEL% equ 0 goto execute
|
||||||
|
|
||||||
|
echo. 1>&2
|
||||||
|
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
|
||||||
|
echo. 1>&2
|
||||||
|
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
|
||||||
|
echo location of your Java installation. 1>&2
|
||||||
|
|
||||||
|
goto fail
|
||||||
|
|
||||||
|
:findJavaFromJavaHome
|
||||||
|
set JAVA_HOME=%JAVA_HOME:"=%
|
||||||
|
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
|
||||||
|
|
||||||
|
if exist "%JAVA_EXE%" goto execute
|
||||||
|
|
||||||
|
echo. 1>&2
|
||||||
|
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
|
||||||
|
echo. 1>&2
|
||||||
|
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
|
||||||
|
echo location of your Java installation. 1>&2
|
||||||
|
|
||||||
|
goto fail
|
||||||
|
|
||||||
|
:execute
|
||||||
|
@rem Setup the command line
|
||||||
|
|
||||||
|
set CLASSPATH=
|
||||||
|
|
||||||
|
|
||||||
|
@rem Execute Gradle
|
||||||
|
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %*
|
||||||
|
|
||||||
|
:end
|
||||||
|
@rem End local scope for the variables with windows NT shell
|
||||||
|
if %ERRORLEVEL% equ 0 goto mainEnd
|
||||||
|
|
||||||
|
:fail
|
||||||
|
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
|
||||||
|
rem the _cmd.exe /c_ return code!
|
||||||
|
set EXIT_CODE=%ERRORLEVEL%
|
||||||
|
if %EXIT_CODE% equ 0 set EXIT_CODE=1
|
||||||
|
if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
|
||||||
|
exit /b %EXIT_CODE%
|
||||||
|
|
||||||
|
:mainEnd
|
||||||
|
if "%OS%"=="Windows_NT" endlocal
|
||||||
|
|
||||||
|
:omega
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 139 KiB |
@@ -0,0 +1,45 @@
|
|||||||
|
{
|
||||||
|
"license": "GPL-3.0",
|
||||||
|
"author": {
|
||||||
|
"name": "Halo",
|
||||||
|
"url": "https://github.com/halo-dev"
|
||||||
|
},
|
||||||
|
"contributors": [
|
||||||
|
{
|
||||||
|
"name": "Ryan Wang",
|
||||||
|
"email": "i@ryanc.cc",
|
||||||
|
"url": "https://github.com/ruibaby"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"scripts": {
|
||||||
|
"build": "pnpm -r run build",
|
||||||
|
"check": "biome check --write",
|
||||||
|
"example:dev": "pnpm -C packages/example dev",
|
||||||
|
"prepare": "husky",
|
||||||
|
"release": "pnpm -r run release"
|
||||||
|
},
|
||||||
|
"lint-staged": {
|
||||||
|
"*": [
|
||||||
|
"biome check --no-errors-on-unmatched --files-ignore-unknown=true"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@biomejs/biome": "^2.2.0",
|
||||||
|
"@tsconfig/node18": "^18.2.4",
|
||||||
|
"@types/node": "^18.19.123",
|
||||||
|
"husky": "^9.1.7",
|
||||||
|
"lint-staged": "^16.1.5",
|
||||||
|
"typescript": "~5.3.3",
|
||||||
|
"vite": "^7.1.2",
|
||||||
|
"vite-plugin-dts": "^4.5.4"
|
||||||
|
},
|
||||||
|
"packageManager": "pnpm@10.14.0",
|
||||||
|
"pnpm": {
|
||||||
|
"overrides": {
|
||||||
|
"vite": "npm:rolldown-vite@7.0.10"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"shiki": "^3.9.2"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://raw.githubusercontent.com/lit/lit/main/packages/localize-tools/config.schema.json",
|
||||||
|
"sourceLocale": "en",
|
||||||
|
"targetLocales": ["es", "zh-CN", "zh-TW"],
|
||||||
|
"tsConfig": "./tsconfig.json",
|
||||||
|
"output": {
|
||||||
|
"mode": "runtime",
|
||||||
|
"outputDir": "./src/generated/locales",
|
||||||
|
"localeCodesModule": "./src/generated/locale-codes.ts"
|
||||||
|
},
|
||||||
|
"interchange": {
|
||||||
|
"format": "xliff",
|
||||||
|
"xliffDir": "./xliff/"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
{
|
||||||
|
"name": "@halo-dev/comment-widget",
|
||||||
|
"version": "3.0.0",
|
||||||
|
"author": {
|
||||||
|
"name": "Halo",
|
||||||
|
"url": "https://github.com/halo-dev"
|
||||||
|
},
|
||||||
|
"maintainers": [
|
||||||
|
{
|
||||||
|
"name": "Ryan Wang",
|
||||||
|
"url": "https://github.com/ruibaby"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"type": "module",
|
||||||
|
"exports": {
|
||||||
|
".": {
|
||||||
|
"types": "./dist/index.d.ts",
|
||||||
|
"import": "./dist/index.js"
|
||||||
|
},
|
||||||
|
"./var.css": "./var.css"
|
||||||
|
},
|
||||||
|
"main": "./dist/index.js",
|
||||||
|
"module": "./dist/index.js",
|
||||||
|
"types": "./dist/index.d.ts",
|
||||||
|
"files": [
|
||||||
|
"dist",
|
||||||
|
"var.css"
|
||||||
|
],
|
||||||
|
"scripts": {
|
||||||
|
"build": "vite build",
|
||||||
|
"dev": "vite build --watch",
|
||||||
|
"locale:build": "lit-localize build",
|
||||||
|
"locale:extract": "lit-localize extract"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@emoji-mart/data": "^1.2.1",
|
||||||
|
"@floating-ui/dom": "^1.7.3",
|
||||||
|
"@halo-dev/api-client": "https://pkg.pr.new/@halo-dev/api-client@7679",
|
||||||
|
"@lit/context": "^1.1.6",
|
||||||
|
"@lit/localize": "^0.12.2",
|
||||||
|
"@tiptap/core": "^3.2.0",
|
||||||
|
"@tiptap/extensions": "^3.2.0",
|
||||||
|
"@tiptap/pm": "^3.2.0",
|
||||||
|
"@tiptap/starter-kit": "^3.2.0",
|
||||||
|
"dayjs": "^1.11.13",
|
||||||
|
"emoji-mart": "^5.6.0",
|
||||||
|
"es-toolkit": "^1.39.9",
|
||||||
|
"javascript-time-ago": "^2.5.11",
|
||||||
|
"lit": "^3.3.1",
|
||||||
|
"ofetch": "^1.4.1",
|
||||||
|
"sanitize-html": "^2.17.0",
|
||||||
|
"tiptap-extension-code-block-shiki": "^0.6.0",
|
||||||
|
"ua-parser-js": "^2.0.4"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@iconify/json": "^2.2.373",
|
||||||
|
"@lit/localize-tools": "^0.8.0",
|
||||||
|
"@types/sanitize-html": "^2.16.0",
|
||||||
|
"@unocss/reset": "^66.4.2",
|
||||||
|
"lit-analyzer": "^2.0.3",
|
||||||
|
"unocss": "^66.4.2"
|
||||||
|
}
|
||||||
|
}
|
||||||
Generated
+542
@@ -0,0 +1,542 @@
|
|||||||
|
lockfileVersion: '6.0'
|
||||||
|
|
||||||
|
settings:
|
||||||
|
autoInstallPeers: true
|
||||||
|
excludeLinksFromLockfile: false
|
||||||
|
|
||||||
|
dependencies:
|
||||||
|
'@emoji-mart/data':
|
||||||
|
specifier: ^1.1.2
|
||||||
|
version: 1.1.2
|
||||||
|
'@halo-dev/api-client':
|
||||||
|
specifier: ^2.12.0
|
||||||
|
version: 2.12.0
|
||||||
|
'@lit/context':
|
||||||
|
specifier: ^1.1.0
|
||||||
|
version: 1.1.0
|
||||||
|
dayjs:
|
||||||
|
specifier: ^1.11.10
|
||||||
|
version: 1.11.10
|
||||||
|
emoji-mart:
|
||||||
|
specifier: ^5.5.2
|
||||||
|
version: 5.5.2
|
||||||
|
javascript-time-ago:
|
||||||
|
specifier: ^2.5.9
|
||||||
|
version: 2.5.9
|
||||||
|
lit:
|
||||||
|
specifier: ^3.1.2
|
||||||
|
version: 3.1.2
|
||||||
|
|
||||||
|
devDependencies:
|
||||||
|
typescript:
|
||||||
|
specifier: ^5.3.3
|
||||||
|
version: 5.3.3
|
||||||
|
vite:
|
||||||
|
specifier: ^5.1.4
|
||||||
|
version: 5.1.4
|
||||||
|
|
||||||
|
packages:
|
||||||
|
|
||||||
|
/@emoji-mart/data@1.1.2:
|
||||||
|
resolution: {integrity: sha512-1HP8BxD2azjqWJvxIaWAMyTySeZY0Osr83ukYjltPVkNXeJvTz7yDrPLBtnrD5uqJ3tg4CcLuuBW09wahqL/fg==}
|
||||||
|
dev: false
|
||||||
|
|
||||||
|
/@esbuild/android-arm64@0.19.9:
|
||||||
|
resolution: {integrity: sha512-q4cR+6ZD0938R19MyEW3jEsMzbb/1rulLXiNAJQADD/XYp7pT+rOS5JGxvpRW8dFDEfjW4wLgC/3FXIw4zYglQ==}
|
||||||
|
engines: {node: '>=12'}
|
||||||
|
cpu: [arm64]
|
||||||
|
os: [android]
|
||||||
|
requiresBuild: true
|
||||||
|
dev: true
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
/@esbuild/android-arm@0.19.9:
|
||||||
|
resolution: {integrity: sha512-jkYjjq7SdsWuNI6b5quymW0oC83NN5FdRPuCbs9HZ02mfVdAP8B8eeqLSYU3gb6OJEaY5CQabtTFbqBf26H3GA==}
|
||||||
|
engines: {node: '>=12'}
|
||||||
|
cpu: [arm]
|
||||||
|
os: [android]
|
||||||
|
requiresBuild: true
|
||||||
|
dev: true
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
/@esbuild/android-x64@0.19.9:
|
||||||
|
resolution: {integrity: sha512-KOqoPntWAH6ZxDwx1D6mRntIgZh9KodzgNOy5Ebt9ghzffOk9X2c1sPwtM9P+0eXbefnDhqYfkh5PLP5ULtWFA==}
|
||||||
|
engines: {node: '>=12'}
|
||||||
|
cpu: [x64]
|
||||||
|
os: [android]
|
||||||
|
requiresBuild: true
|
||||||
|
dev: true
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
/@esbuild/darwin-arm64@0.19.9:
|
||||||
|
resolution: {integrity: sha512-KBJ9S0AFyLVx2E5D8W0vExqRW01WqRtczUZ8NRu+Pi+87opZn5tL4Y0xT0mA4FtHctd0ZgwNoN639fUUGlNIWw==}
|
||||||
|
engines: {node: '>=12'}
|
||||||
|
cpu: [arm64]
|
||||||
|
os: [darwin]
|
||||||
|
requiresBuild: true
|
||||||
|
dev: true
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
/@esbuild/darwin-x64@0.19.9:
|
||||||
|
resolution: {integrity: sha512-vE0VotmNTQaTdX0Q9dOHmMTao6ObjyPm58CHZr1UK7qpNleQyxlFlNCaHsHx6Uqv86VgPmR4o2wdNq3dP1qyDQ==}
|
||||||
|
engines: {node: '>=12'}
|
||||||
|
cpu: [x64]
|
||||||
|
os: [darwin]
|
||||||
|
requiresBuild: true
|
||||||
|
dev: true
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
/@esbuild/freebsd-arm64@0.19.9:
|
||||||
|
resolution: {integrity: sha512-uFQyd/o1IjiEk3rUHSwUKkqZwqdvuD8GevWF065eqgYfexcVkxh+IJgwTaGZVu59XczZGcN/YMh9uF1fWD8j1g==}
|
||||||
|
engines: {node: '>=12'}
|
||||||
|
cpu: [arm64]
|
||||||
|
os: [freebsd]
|
||||||
|
requiresBuild: true
|
||||||
|
dev: true
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
/@esbuild/freebsd-x64@0.19.9:
|
||||||
|
resolution: {integrity: sha512-WMLgWAtkdTbTu1AWacY7uoj/YtHthgqrqhf1OaEWnZb7PQgpt8eaA/F3LkV0E6K/Lc0cUr/uaVP/49iE4M4asA==}
|
||||||
|
engines: {node: '>=12'}
|
||||||
|
cpu: [x64]
|
||||||
|
os: [freebsd]
|
||||||
|
requiresBuild: true
|
||||||
|
dev: true
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
/@esbuild/linux-arm64@0.19.9:
|
||||||
|
resolution: {integrity: sha512-PiPblfe1BjK7WDAKR1Cr9O7VVPqVNpwFcPWgfn4xu0eMemzRp442hXyzF/fSwgrufI66FpHOEJk0yYdPInsmyQ==}
|
||||||
|
engines: {node: '>=12'}
|
||||||
|
cpu: [arm64]
|
||||||
|
os: [linux]
|
||||||
|
requiresBuild: true
|
||||||
|
dev: true
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
/@esbuild/linux-arm@0.19.9:
|
||||||
|
resolution: {integrity: sha512-C/ChPohUYoyUaqn1h17m/6yt6OB14hbXvT8EgM1ZWaiiTYz7nWZR0SYmMnB5BzQA4GXl3BgBO1l8MYqL/He3qw==}
|
||||||
|
engines: {node: '>=12'}
|
||||||
|
cpu: [arm]
|
||||||
|
os: [linux]
|
||||||
|
requiresBuild: true
|
||||||
|
dev: true
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
/@esbuild/linux-ia32@0.19.9:
|
||||||
|
resolution: {integrity: sha512-f37i/0zE0MjDxijkPSQw1CO/7C27Eojqb+r3BbHVxMLkj8GCa78TrBZzvPyA/FNLUMzP3eyHCVkAopkKVja+6Q==}
|
||||||
|
engines: {node: '>=12'}
|
||||||
|
cpu: [ia32]
|
||||||
|
os: [linux]
|
||||||
|
requiresBuild: true
|
||||||
|
dev: true
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
/@esbuild/linux-loong64@0.19.9:
|
||||||
|
resolution: {integrity: sha512-t6mN147pUIf3t6wUt3FeumoOTPfmv9Cc6DQlsVBpB7eCpLOqQDyWBP1ymXn1lDw4fNUSb/gBcKAmvTP49oIkaA==}
|
||||||
|
engines: {node: '>=12'}
|
||||||
|
cpu: [loong64]
|
||||||
|
os: [linux]
|
||||||
|
requiresBuild: true
|
||||||
|
dev: true
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
/@esbuild/linux-mips64el@0.19.9:
|
||||||
|
resolution: {integrity: sha512-jg9fujJTNTQBuDXdmAg1eeJUL4Jds7BklOTkkH80ZgQIoCTdQrDaHYgbFZyeTq8zbY+axgptncko3v9p5hLZtw==}
|
||||||
|
engines: {node: '>=12'}
|
||||||
|
cpu: [mips64el]
|
||||||
|
os: [linux]
|
||||||
|
requiresBuild: true
|
||||||
|
dev: true
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
/@esbuild/linux-ppc64@0.19.9:
|
||||||
|
resolution: {integrity: sha512-tkV0xUX0pUUgY4ha7z5BbDS85uI7ABw3V1d0RNTii7E9lbmV8Z37Pup2tsLV46SQWzjOeyDi1Q7Wx2+QM8WaCQ==}
|
||||||
|
engines: {node: '>=12'}
|
||||||
|
cpu: [ppc64]
|
||||||
|
os: [linux]
|
||||||
|
requiresBuild: true
|
||||||
|
dev: true
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
/@esbuild/linux-riscv64@0.19.9:
|
||||||
|
resolution: {integrity: sha512-DfLp8dj91cufgPZDXr9p3FoR++m3ZJ6uIXsXrIvJdOjXVREtXuQCjfMfvmc3LScAVmLjcfloyVtpn43D56JFHg==}
|
||||||
|
engines: {node: '>=12'}
|
||||||
|
cpu: [riscv64]
|
||||||
|
os: [linux]
|
||||||
|
requiresBuild: true
|
||||||
|
dev: true
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
/@esbuild/linux-s390x@0.19.9:
|
||||||
|
resolution: {integrity: sha512-zHbglfEdC88KMgCWpOl/zc6dDYJvWGLiUtmPRsr1OgCViu3z5GncvNVdf+6/56O2Ca8jUU+t1BW261V6kp8qdw==}
|
||||||
|
engines: {node: '>=12'}
|
||||||
|
cpu: [s390x]
|
||||||
|
os: [linux]
|
||||||
|
requiresBuild: true
|
||||||
|
dev: true
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
/@esbuild/linux-x64@0.19.9:
|
||||||
|
resolution: {integrity: sha512-JUjpystGFFmNrEHQnIVG8hKwvA2DN5o7RqiO1CVX8EN/F/gkCjkUMgVn6hzScpwnJtl2mPR6I9XV1oW8k9O+0A==}
|
||||||
|
engines: {node: '>=12'}
|
||||||
|
cpu: [x64]
|
||||||
|
os: [linux]
|
||||||
|
requiresBuild: true
|
||||||
|
dev: true
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
/@esbuild/netbsd-x64@0.19.9:
|
||||||
|
resolution: {integrity: sha512-GThgZPAwOBOsheA2RUlW5UeroRfESwMq/guy8uEe3wJlAOjpOXuSevLRd70NZ37ZrpO6RHGHgEHvPg1h3S1Jug==}
|
||||||
|
engines: {node: '>=12'}
|
||||||
|
cpu: [x64]
|
||||||
|
os: [netbsd]
|
||||||
|
requiresBuild: true
|
||||||
|
dev: true
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
/@esbuild/openbsd-x64@0.19.9:
|
||||||
|
resolution: {integrity: sha512-Ki6PlzppaFVbLnD8PtlVQfsYw4S9n3eQl87cqgeIw+O3sRr9IghpfSKY62mggdt1yCSZ8QWvTZ9jo9fjDSg9uw==}
|
||||||
|
engines: {node: '>=12'}
|
||||||
|
cpu: [x64]
|
||||||
|
os: [openbsd]
|
||||||
|
requiresBuild: true
|
||||||
|
dev: true
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
/@esbuild/sunos-x64@0.19.9:
|
||||||
|
resolution: {integrity: sha512-MLHj7k9hWh4y1ddkBpvRj2b9NCBhfgBt3VpWbHQnXRedVun/hC7sIyTGDGTfsGuXo4ebik2+3ShjcPbhtFwWDw==}
|
||||||
|
engines: {node: '>=12'}
|
||||||
|
cpu: [x64]
|
||||||
|
os: [sunos]
|
||||||
|
requiresBuild: true
|
||||||
|
dev: true
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
/@esbuild/win32-arm64@0.19.9:
|
||||||
|
resolution: {integrity: sha512-GQoa6OrQ8G08guMFgeXPH7yE/8Dt0IfOGWJSfSH4uafwdC7rWwrfE6P9N8AtPGIjUzdo2+7bN8Xo3qC578olhg==}
|
||||||
|
engines: {node: '>=12'}
|
||||||
|
cpu: [arm64]
|
||||||
|
os: [win32]
|
||||||
|
requiresBuild: true
|
||||||
|
dev: true
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
/@esbuild/win32-ia32@0.19.9:
|
||||||
|
resolution: {integrity: sha512-UOozV7Ntykvr5tSOlGCrqU3NBr3d8JqPes0QWN2WOXfvkWVGRajC+Ym0/Wj88fUgecUCLDdJPDF0Nna2UK3Qtg==}
|
||||||
|
engines: {node: '>=12'}
|
||||||
|
cpu: [ia32]
|
||||||
|
os: [win32]
|
||||||
|
requiresBuild: true
|
||||||
|
dev: true
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
/@esbuild/win32-x64@0.19.9:
|
||||||
|
resolution: {integrity: sha512-oxoQgglOP7RH6iasDrhY+R/3cHrfwIDvRlT4CGChflq6twk8iENeVvMJjmvBb94Ik1Z+93iGO27err7w6l54GQ==}
|
||||||
|
engines: {node: '>=12'}
|
||||||
|
cpu: [x64]
|
||||||
|
os: [win32]
|
||||||
|
requiresBuild: true
|
||||||
|
dev: true
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
/@halo-dev/api-client@2.12.0:
|
||||||
|
resolution: {integrity: sha512-W/FmwtdXvq/BSwi2VMYBPzV86SmUjjn/1RO3pzyBOp8KwZM97vVVPJwYhT7IUB+W7q9HtDBUlvcFS8M0bX0RaA==}
|
||||||
|
dev: false
|
||||||
|
|
||||||
|
/@lit-labs/ssr-dom-shim@1.1.2:
|
||||||
|
resolution: {integrity: sha512-jnOD+/+dSrfTWYfSXBXlo5l5f0q1UuJo3tkbMDCYA2lKUYq79jaxqtGEvnRoh049nt1vdo1+45RinipU6FGY2g==}
|
||||||
|
dev: false
|
||||||
|
|
||||||
|
/@lit-labs/ssr-dom-shim@1.2.0:
|
||||||
|
resolution: {integrity: sha512-yWJKmpGE6lUURKAaIltoPIE/wrbY3TEkqQt+X0m+7fQNnAv0keydnYvbiJFP1PnMhizmIWRWOG5KLhYyc/xl+g==}
|
||||||
|
dev: false
|
||||||
|
|
||||||
|
/@lit/context@1.1.0:
|
||||||
|
resolution: {integrity: sha512-fCyv4dsH05wCNm3AKbB+PdYbXGJd/XT8OOwo4hVmD4COq5wOWJlQreGAMDvmHZ7osqxuu06Y4nmP6ooXpN7ErA==}
|
||||||
|
dependencies:
|
||||||
|
'@lit/reactive-element': 2.0.1
|
||||||
|
dev: false
|
||||||
|
|
||||||
|
/@lit/reactive-element@2.0.1:
|
||||||
|
resolution: {integrity: sha512-eu50SQXHRthFwWJMp0oAFg95Rvm6MTPjxSXWuvAu7It90WVFLFpNBoIno7XOXSDvVgTrtKnUV4OLJqys2Svn4g==}
|
||||||
|
dependencies:
|
||||||
|
'@lit-labs/ssr-dom-shim': 1.1.2
|
||||||
|
dev: false
|
||||||
|
|
||||||
|
/@lit/reactive-element@2.0.4:
|
||||||
|
resolution: {integrity: sha512-GFn91inaUa2oHLak8awSIigYz0cU0Payr1rcFsrkf5OJ5eSPxElyZfKh0f2p9FsTiZWXQdWGJeXZICEfXXYSXQ==}
|
||||||
|
dependencies:
|
||||||
|
'@lit-labs/ssr-dom-shim': 1.2.0
|
||||||
|
dev: false
|
||||||
|
|
||||||
|
/@rollup/rollup-android-arm-eabi@4.8.0:
|
||||||
|
resolution: {integrity: sha512-zdTObFRoNENrdPpnTNnhOljYIcOX7aI7+7wyrSpPFFIOf/nRdedE6IYsjaBE7tjukphh1tMTojgJ7p3lKY8x6Q==}
|
||||||
|
cpu: [arm]
|
||||||
|
os: [android]
|
||||||
|
requiresBuild: true
|
||||||
|
dev: true
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
/@rollup/rollup-android-arm64@4.8.0:
|
||||||
|
resolution: {integrity: sha512-aiItwP48BiGpMFS9Znjo/xCNQVwTQVcRKkFKsO81m8exrGjHkCBDvm9PHay2kpa8RPnZzzKcD1iQ9KaLY4fPQQ==}
|
||||||
|
cpu: [arm64]
|
||||||
|
os: [android]
|
||||||
|
requiresBuild: true
|
||||||
|
dev: true
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
/@rollup/rollup-darwin-arm64@4.8.0:
|
||||||
|
resolution: {integrity: sha512-zhNIS+L4ZYkYQUjIQUR6Zl0RXhbbA0huvNIWjmPc2SL0cB1h5Djkcy+RZ3/Bwszfb6vgwUvcVJYD6e6Zkpsi8g==}
|
||||||
|
cpu: [arm64]
|
||||||
|
os: [darwin]
|
||||||
|
requiresBuild: true
|
||||||
|
dev: true
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
/@rollup/rollup-darwin-x64@4.8.0:
|
||||||
|
resolution: {integrity: sha512-A/FAHFRNQYrELrb/JHncRWzTTXB2ticiRFztP4ggIUAfa9Up1qfW8aG2w/mN9jNiZ+HB0t0u0jpJgFXG6BfRTA==}
|
||||||
|
cpu: [x64]
|
||||||
|
os: [darwin]
|
||||||
|
requiresBuild: true
|
||||||
|
dev: true
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
/@rollup/rollup-linux-arm-gnueabihf@4.8.0:
|
||||||
|
resolution: {integrity: sha512-JsidBnh3p2IJJA4/2xOF2puAYqbaczB3elZDT0qHxn362EIoIkq7hrR43Xa8RisgI6/WPfvb2umbGsuvf7E37A==}
|
||||||
|
cpu: [arm]
|
||||||
|
os: [linux]
|
||||||
|
requiresBuild: true
|
||||||
|
dev: true
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
/@rollup/rollup-linux-arm64-gnu@4.8.0:
|
||||||
|
resolution: {integrity: sha512-hBNCnqw3EVCkaPB0Oqd24bv8SklETptQWcJz06kb9OtiShn9jK1VuTgi7o4zPSt6rNGWQOTDEAccbk0OqJmS+g==}
|
||||||
|
cpu: [arm64]
|
||||||
|
os: [linux]
|
||||||
|
requiresBuild: true
|
||||||
|
dev: true
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
/@rollup/rollup-linux-arm64-musl@4.8.0:
|
||||||
|
resolution: {integrity: sha512-Fw9ChYfJPdltvi9ALJ9wzdCdxGw4wtq4t1qY028b2O7GwB5qLNSGtqMsAel1lfWTZvf4b6/+4HKp0GlSYg0ahA==}
|
||||||
|
cpu: [arm64]
|
||||||
|
os: [linux]
|
||||||
|
requiresBuild: true
|
||||||
|
dev: true
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
/@rollup/rollup-linux-riscv64-gnu@4.8.0:
|
||||||
|
resolution: {integrity: sha512-BH5xIh7tOzS9yBi8dFrCTG8Z6iNIGWGltd3IpTSKp6+pNWWO6qy8eKoRxOtwFbMrid5NZaidLYN6rHh9aB8bEw==}
|
||||||
|
cpu: [riscv64]
|
||||||
|
os: [linux]
|
||||||
|
requiresBuild: true
|
||||||
|
dev: true
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
/@rollup/rollup-linux-x64-gnu@4.8.0:
|
||||||
|
resolution: {integrity: sha512-PmvAj8k6EuWiyLbkNpd6BLv5XeYFpqWuRvRNRl80xVfpGXK/z6KYXmAgbI4ogz7uFiJxCnYcqyvZVD0dgFog7Q==}
|
||||||
|
cpu: [x64]
|
||||||
|
os: [linux]
|
||||||
|
requiresBuild: true
|
||||||
|
dev: true
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
/@rollup/rollup-linux-x64-musl@4.8.0:
|
||||||
|
resolution: {integrity: sha512-mdxnlW2QUzXwY+95TuxZ+CurrhgrPAMveDWI97EQlA9bfhR8tw3Pt7SUlc/eSlCNxlWktpmT//EAA8UfCHOyXg==}
|
||||||
|
cpu: [x64]
|
||||||
|
os: [linux]
|
||||||
|
requiresBuild: true
|
||||||
|
dev: true
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
/@rollup/rollup-win32-arm64-msvc@4.8.0:
|
||||||
|
resolution: {integrity: sha512-ge7saUz38aesM4MA7Cad8CHo0Fyd1+qTaqoIo+Jtk+ipBi4ATSrHWov9/S4u5pbEQmLjgUjB7BJt+MiKG2kzmA==}
|
||||||
|
cpu: [arm64]
|
||||||
|
os: [win32]
|
||||||
|
requiresBuild: true
|
||||||
|
dev: true
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
/@rollup/rollup-win32-ia32-msvc@4.8.0:
|
||||||
|
resolution: {integrity: sha512-p9E3PZlzurhlsN5h9g7zIP1DnqKXJe8ZUkFwAazqSvHuWfihlIISPxG9hCHCoA+dOOspL/c7ty1eeEVFTE0UTw==}
|
||||||
|
cpu: [ia32]
|
||||||
|
os: [win32]
|
||||||
|
requiresBuild: true
|
||||||
|
dev: true
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
/@rollup/rollup-win32-x64-msvc@4.8.0:
|
||||||
|
resolution: {integrity: sha512-kb4/auKXkYKqlUYTE8s40FcJIj5soOyRLHKd4ugR0dCq0G2EfcF54eYcfQiGkHzjidZ40daB4ulsFdtqNKZtBg==}
|
||||||
|
cpu: [x64]
|
||||||
|
os: [win32]
|
||||||
|
requiresBuild: true
|
||||||
|
dev: true
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
/@types/trusted-types@2.0.5:
|
||||||
|
resolution: {integrity: sha512-I3pkr8j/6tmQtKV/ZzHtuaqYSQvyjGRKH4go60Rr0IDLlFxuRT5V32uvB1mecM5G1EVAUyF/4r4QZ1GHgz+mxA==}
|
||||||
|
dev: false
|
||||||
|
|
||||||
|
/dayjs@1.11.10:
|
||||||
|
resolution: {integrity: sha512-vjAczensTgRcqDERK0SR2XMwsF/tSvnvlv6VcF2GIhg6Sx4yOIt/irsr1RDJsKiIyBzJDpCoXiWWq28MqH2cnQ==}
|
||||||
|
dev: false
|
||||||
|
|
||||||
|
/emoji-mart@5.5.2:
|
||||||
|
resolution: {integrity: sha512-Sqc/nso4cjxhOwWJsp9xkVm8OF5c+mJLZJFoFfzRuKO+yWiN7K8c96xmtughYb0d/fZ8UC6cLIQ/p4BR6Pv3/A==}
|
||||||
|
dev: false
|
||||||
|
|
||||||
|
/esbuild@0.19.9:
|
||||||
|
resolution: {integrity: sha512-U9CHtKSy+EpPsEBa+/A2gMs/h3ylBC0H0KSqIg7tpztHerLi6nrrcoUJAkNCEPumx8yJ+Byic4BVwHgRbN0TBg==}
|
||||||
|
engines: {node: '>=12'}
|
||||||
|
hasBin: true
|
||||||
|
requiresBuild: true
|
||||||
|
optionalDependencies:
|
||||||
|
'@esbuild/android-arm': 0.19.9
|
||||||
|
'@esbuild/android-arm64': 0.19.9
|
||||||
|
'@esbuild/android-x64': 0.19.9
|
||||||
|
'@esbuild/darwin-arm64': 0.19.9
|
||||||
|
'@esbuild/darwin-x64': 0.19.9
|
||||||
|
'@esbuild/freebsd-arm64': 0.19.9
|
||||||
|
'@esbuild/freebsd-x64': 0.19.9
|
||||||
|
'@esbuild/linux-arm': 0.19.9
|
||||||
|
'@esbuild/linux-arm64': 0.19.9
|
||||||
|
'@esbuild/linux-ia32': 0.19.9
|
||||||
|
'@esbuild/linux-loong64': 0.19.9
|
||||||
|
'@esbuild/linux-mips64el': 0.19.9
|
||||||
|
'@esbuild/linux-ppc64': 0.19.9
|
||||||
|
'@esbuild/linux-riscv64': 0.19.9
|
||||||
|
'@esbuild/linux-s390x': 0.19.9
|
||||||
|
'@esbuild/linux-x64': 0.19.9
|
||||||
|
'@esbuild/netbsd-x64': 0.19.9
|
||||||
|
'@esbuild/openbsd-x64': 0.19.9
|
||||||
|
'@esbuild/sunos-x64': 0.19.9
|
||||||
|
'@esbuild/win32-arm64': 0.19.9
|
||||||
|
'@esbuild/win32-ia32': 0.19.9
|
||||||
|
'@esbuild/win32-x64': 0.19.9
|
||||||
|
dev: true
|
||||||
|
|
||||||
|
/fsevents@2.3.3:
|
||||||
|
resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==}
|
||||||
|
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
|
||||||
|
os: [darwin]
|
||||||
|
requiresBuild: true
|
||||||
|
dev: true
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
/javascript-time-ago@2.5.9:
|
||||||
|
resolution: {integrity: sha512-pQ8mNco/9g9TqWXWWjP0EWl6i/lAQScOyEeXy5AB+f7MfLSdgyV9BJhiOD1zrIac/lrxPYOWNbyl/IW8CW5n0A==}
|
||||||
|
dependencies:
|
||||||
|
relative-time-format: 1.1.6
|
||||||
|
dev: false
|
||||||
|
|
||||||
|
/lit-element@4.0.4:
|
||||||
|
resolution: {integrity: sha512-98CvgulX6eCPs6TyAIQoJZBCQPo80rgXR+dVBs61cstJXqtI+USQZAbA4gFHh6L/mxBx9MrgPLHLsUgDUHAcCQ==}
|
||||||
|
dependencies:
|
||||||
|
'@lit-labs/ssr-dom-shim': 1.2.0
|
||||||
|
'@lit/reactive-element': 2.0.4
|
||||||
|
lit-html: 3.1.2
|
||||||
|
dev: false
|
||||||
|
|
||||||
|
/lit-html@3.1.2:
|
||||||
|
resolution: {integrity: sha512-3OBZSUrPnAHoKJ9AMjRL/m01YJxQMf+TMHanNtTHG68ubjnZxK0RFl102DPzsw4mWnHibfZIBJm3LWCZ/LmMvg==}
|
||||||
|
dependencies:
|
||||||
|
'@types/trusted-types': 2.0.5
|
||||||
|
dev: false
|
||||||
|
|
||||||
|
/lit@3.1.2:
|
||||||
|
resolution: {integrity: sha512-VZx5iAyMtX7CV4K8iTLdCkMaYZ7ipjJZ0JcSdJ0zIdGxxyurjIn7yuuSxNBD7QmjvcNJwr0JS4cAdAtsy7gZ6w==}
|
||||||
|
dependencies:
|
||||||
|
'@lit/reactive-element': 2.0.4
|
||||||
|
lit-element: 4.0.4
|
||||||
|
lit-html: 3.1.2
|
||||||
|
dev: false
|
||||||
|
|
||||||
|
/nanoid@3.3.7:
|
||||||
|
resolution: {integrity: sha512-eSRppjcPIatRIMC1U6UngP8XFcz8MQWGQdt1MTBQ7NaAmvXDfvNxbvWV3x2y6CdEUciCSsDHDQZbhYaB8QEo2g==}
|
||||||
|
engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
|
||||||
|
hasBin: true
|
||||||
|
dev: true
|
||||||
|
|
||||||
|
/picocolors@1.0.0:
|
||||||
|
resolution: {integrity: sha512-1fygroTLlHu66zi26VoTDv8yRgm0Fccecssto+MhsZ0D/DGW2sm8E8AjW7NU5VVTRt5GxbeZ5qBuJr+HyLYkjQ==}
|
||||||
|
dev: true
|
||||||
|
|
||||||
|
/postcss@8.4.35:
|
||||||
|
resolution: {integrity: sha512-u5U8qYpBCpN13BsiEB0CbR1Hhh4Gc0zLFuedrHJKMctHCHAGrMdG0PRM/KErzAL3CU6/eckEtmHNB3x6e3c0vA==}
|
||||||
|
engines: {node: ^10 || ^12 || >=14}
|
||||||
|
dependencies:
|
||||||
|
nanoid: 3.3.7
|
||||||
|
picocolors: 1.0.0
|
||||||
|
source-map-js: 1.0.2
|
||||||
|
dev: true
|
||||||
|
|
||||||
|
/relative-time-format@1.1.6:
|
||||||
|
resolution: {integrity: sha512-aCv3juQw4hT1/P/OrVltKWLlp15eW1GRcwP1XdxHrPdZE9MtgqFpegjnTjLhi2m2WI9MT/hQQtE+tjEWG1hgkQ==}
|
||||||
|
dev: false
|
||||||
|
|
||||||
|
/rollup@4.8.0:
|
||||||
|
resolution: {integrity: sha512-NpsklK2fach5CdI+PScmlE5R4Ao/FSWtF7LkoIrHDxPACY/xshNasPsbpG0VVHxUTbf74tJbVT4PrP8JsJ6ZDA==}
|
||||||
|
engines: {node: '>=18.0.0', npm: '>=8.0.0'}
|
||||||
|
hasBin: true
|
||||||
|
optionalDependencies:
|
||||||
|
'@rollup/rollup-android-arm-eabi': 4.8.0
|
||||||
|
'@rollup/rollup-android-arm64': 4.8.0
|
||||||
|
'@rollup/rollup-darwin-arm64': 4.8.0
|
||||||
|
'@rollup/rollup-darwin-x64': 4.8.0
|
||||||
|
'@rollup/rollup-linux-arm-gnueabihf': 4.8.0
|
||||||
|
'@rollup/rollup-linux-arm64-gnu': 4.8.0
|
||||||
|
'@rollup/rollup-linux-arm64-musl': 4.8.0
|
||||||
|
'@rollup/rollup-linux-riscv64-gnu': 4.8.0
|
||||||
|
'@rollup/rollup-linux-x64-gnu': 4.8.0
|
||||||
|
'@rollup/rollup-linux-x64-musl': 4.8.0
|
||||||
|
'@rollup/rollup-win32-arm64-msvc': 4.8.0
|
||||||
|
'@rollup/rollup-win32-ia32-msvc': 4.8.0
|
||||||
|
'@rollup/rollup-win32-x64-msvc': 4.8.0
|
||||||
|
fsevents: 2.3.3
|
||||||
|
dev: true
|
||||||
|
|
||||||
|
/source-map-js@1.0.2:
|
||||||
|
resolution: {integrity: sha512-R0XvVJ9WusLiqTCEiGCmICCMplcCkIwwR11mOSD9CR5u+IXYdiseeEuXCVAjS54zqwkLcPNnmU4OeJ6tUrWhDw==}
|
||||||
|
engines: {node: '>=0.10.0'}
|
||||||
|
dev: true
|
||||||
|
|
||||||
|
/typescript@5.3.3:
|
||||||
|
resolution: {integrity: sha512-pXWcraxM0uxAS+tN0AG/BF2TyqmHO014Z070UsJ+pFvYuRSq8KH8DmWpnbXe0pEPDHXZV3FcAbJkijJ5oNEnWw==}
|
||||||
|
engines: {node: '>=14.17'}
|
||||||
|
hasBin: true
|
||||||
|
dev: true
|
||||||
|
|
||||||
|
/vite@5.1.4:
|
||||||
|
resolution: {integrity: sha512-n+MPqzq+d9nMVTKyewqw6kSt+R3CkvF9QAKY8obiQn8g1fwTscKxyfaYnC632HtBXAQGc1Yjomphwn1dtwGAHg==}
|
||||||
|
engines: {node: ^18.0.0 || >=20.0.0}
|
||||||
|
hasBin: true
|
||||||
|
peerDependencies:
|
||||||
|
'@types/node': ^18.0.0 || >=20.0.0
|
||||||
|
less: '*'
|
||||||
|
lightningcss: ^1.21.0
|
||||||
|
sass: '*'
|
||||||
|
stylus: '*'
|
||||||
|
sugarss: '*'
|
||||||
|
terser: ^5.4.0
|
||||||
|
peerDependenciesMeta:
|
||||||
|
'@types/node':
|
||||||
|
optional: true
|
||||||
|
less:
|
||||||
|
optional: true
|
||||||
|
lightningcss:
|
||||||
|
optional: true
|
||||||
|
sass:
|
||||||
|
optional: true
|
||||||
|
stylus:
|
||||||
|
optional: true
|
||||||
|
sugarss:
|
||||||
|
optional: true
|
||||||
|
terser:
|
||||||
|
optional: true
|
||||||
|
dependencies:
|
||||||
|
esbuild: 0.19.9
|
||||||
|
postcss: 8.4.35
|
||||||
|
rollup: 4.8.0
|
||||||
|
optionalDependencies:
|
||||||
|
fsevents: 2.3.3
|
||||||
|
dev: true
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
import type { CommentVo, ReplyVo } from '@halo-dev/api-client';
|
||||||
|
import { getAvatarProvider } from './providers';
|
||||||
|
|
||||||
|
abstract class AvatarPolicy {
|
||||||
|
abstract applyCommentPolicy(
|
||||||
|
comment: CommentVo | undefined
|
||||||
|
): string | undefined;
|
||||||
|
abstract applyReplyPolicy(reply: ReplyVo | undefined): string | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
let policyInstance: AvatarPolicy | undefined;
|
||||||
|
const emailKind = 'Email';
|
||||||
|
const emailHash = 'email-hash';
|
||||||
|
|
||||||
|
class AnonymousUserPolicy extends AvatarPolicy {
|
||||||
|
applyCommentPolicy(comment: CommentVo | undefined): string | undefined {
|
||||||
|
const avatarProvider = getAvatarProvider();
|
||||||
|
const isAnonymous = comment?.owner.kind === emailKind;
|
||||||
|
if (isAnonymous) {
|
||||||
|
return avatarProvider?.getAvatarSrc(
|
||||||
|
comment?.spec.owner.annotations?.[emailHash]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return comment?.owner.avatar;
|
||||||
|
}
|
||||||
|
applyReplyPolicy(reply: ReplyVo | undefined): string | undefined {
|
||||||
|
const avatarProvider = getAvatarProvider();
|
||||||
|
const isAnonymous = reply?.owner.kind === emailKind;
|
||||||
|
if (isAnonymous) {
|
||||||
|
return avatarProvider?.getAvatarSrc(
|
||||||
|
reply?.spec.owner.annotations?.[emailHash]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return reply?.owner.avatar;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class AllUserPolicy extends AvatarPolicy {
|
||||||
|
applyCommentPolicy(comment: CommentVo | undefined): string | undefined {
|
||||||
|
const avatarProvider = getAvatarProvider();
|
||||||
|
return avatarProvider?.getAvatarSrc(
|
||||||
|
comment?.spec.owner.annotations?.[emailHash]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
applyReplyPolicy(reply: ReplyVo | undefined): string | undefined {
|
||||||
|
const avatarProvider = getAvatarProvider();
|
||||||
|
return avatarProvider?.getAvatarSrc(
|
||||||
|
reply?.spec.owner.annotations?.[emailHash]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class NoAvatarUserPolicy extends AvatarPolicy {
|
||||||
|
applyCommentPolicy(comment: CommentVo | undefined): string | undefined {
|
||||||
|
const avatarProvider = getAvatarProvider();
|
||||||
|
const isAnonymous = comment?.owner.kind === emailKind;
|
||||||
|
const avatar = comment?.owner.avatar;
|
||||||
|
if (isAnonymous || !avatar) {
|
||||||
|
return avatarProvider?.getAvatarSrc(
|
||||||
|
comment?.spec.owner.annotations?.[emailHash]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return avatar;
|
||||||
|
}
|
||||||
|
applyReplyPolicy(reply: ReplyVo | undefined): string | undefined {
|
||||||
|
const avatarProvider = getAvatarProvider();
|
||||||
|
const isAnonymous = reply?.owner.kind === emailKind;
|
||||||
|
const avatar = reply?.owner.avatar;
|
||||||
|
if (isAnonymous || !avatar) {
|
||||||
|
return avatarProvider?.getAvatarSrc(
|
||||||
|
reply?.spec.owner.annotations?.[emailHash]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return avatar;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
enum AvatarPolicyEnum {
|
||||||
|
ANONYMOUS_USER_POLICY = 'anonymousUser',
|
||||||
|
ALL_USER_POLICY = 'allUser',
|
||||||
|
NO_AVATAR_USER_POLICY = 'noAvatarUser',
|
||||||
|
}
|
||||||
|
|
||||||
|
function setPolicyInstance(nPolicyInstance: AvatarPolicy | undefined) {
|
||||||
|
policyInstance = nPolicyInstance;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getPolicyInstance(): AvatarPolicy | undefined {
|
||||||
|
return policyInstance;
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
AnonymousUserPolicy,
|
||||||
|
AllUserPolicy,
|
||||||
|
NoAvatarUserPolicy,
|
||||||
|
AvatarPolicyEnum,
|
||||||
|
setPolicyInstance,
|
||||||
|
getPolicyInstance,
|
||||||
|
};
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
export default abstract class AvatarProvider {
|
||||||
|
private readonly _name: string;
|
||||||
|
private _url: string;
|
||||||
|
|
||||||
|
constructor(name: string, url: string) {
|
||||||
|
this._name = name;
|
||||||
|
this._url = url;
|
||||||
|
}
|
||||||
|
|
||||||
|
get url(): string {
|
||||||
|
return this._url;
|
||||||
|
}
|
||||||
|
|
||||||
|
set url(value: string) {
|
||||||
|
this._url = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
get name(): string {
|
||||||
|
return this._name;
|
||||||
|
}
|
||||||
|
|
||||||
|
abstract getAvatarSrc(emailHash: string | undefined): string;
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
import AvatarProvider from './avatar-provider';
|
||||||
|
|
||||||
|
class Gravatar extends AvatarProvider {
|
||||||
|
override getAvatarSrc(emailHash: string | undefined): string {
|
||||||
|
return `${this.url}/avatar/${emailHash}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default new Gravatar('Gravatar', 'https://gravatar.com');
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import type AvatarProvider from './avatar-provider';
|
||||||
|
import Gravatar from './gravatar';
|
||||||
|
|
||||||
|
let avatarProvider: AvatarProvider | undefined;
|
||||||
|
|
||||||
|
enum AvatarProviderEnum {
|
||||||
|
GRAVATAR = 'gravatar',
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setAvatarProvider(provider: string, mirrorUrl?: string) {
|
||||||
|
switch (provider) {
|
||||||
|
case AvatarProviderEnum.GRAVATAR:
|
||||||
|
if (mirrorUrl) {
|
||||||
|
Gravatar.url = mirrorUrl;
|
||||||
|
}
|
||||||
|
avatarProvider = Gravatar;
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getAvatarProvider() {
|
||||||
|
return avatarProvider;
|
||||||
|
}
|
||||||
@@ -0,0 +1,134 @@
|
|||||||
|
import './user-avatar';
|
||||||
|
import { msg } from '@lit/localize';
|
||||||
|
import { css, html, LitElement } from 'lit';
|
||||||
|
import { property, state } from 'lit/decorators.js';
|
||||||
|
import baseStyles from './styles/base';
|
||||||
|
import { formatDate, timeAgo } from './utils/date';
|
||||||
|
import './commenter-ua-bar';
|
||||||
|
import { consume } from '@lit/context';
|
||||||
|
import { configMapDataContext } from './context';
|
||||||
|
import type { ConfigMapData } from './types';
|
||||||
|
import './comment-content';
|
||||||
|
import { ifDefined } from 'lit/directives/if-defined.js';
|
||||||
|
import { when } from 'lit/directives/when.js';
|
||||||
|
|
||||||
|
export class BaseCommentItem extends LitElement {
|
||||||
|
@property({ type: String })
|
||||||
|
userAvatar: string | undefined;
|
||||||
|
|
||||||
|
@property({ type: String })
|
||||||
|
userDisplayName: string | undefined;
|
||||||
|
|
||||||
|
@property({ type: String })
|
||||||
|
userWebsite: string | undefined;
|
||||||
|
|
||||||
|
@property({ type: String })
|
||||||
|
creationTime: string | undefined;
|
||||||
|
|
||||||
|
@property({ type: Boolean })
|
||||||
|
approved: boolean | undefined;
|
||||||
|
|
||||||
|
@property({ type: Boolean })
|
||||||
|
breath: boolean | undefined;
|
||||||
|
|
||||||
|
@property({ type: String })
|
||||||
|
content = '';
|
||||||
|
|
||||||
|
@property({ type: String })
|
||||||
|
ua: string | undefined;
|
||||||
|
|
||||||
|
@property({ type: Boolean })
|
||||||
|
private: boolean | undefined;
|
||||||
|
|
||||||
|
@consume({ context: configMapDataContext })
|
||||||
|
@state()
|
||||||
|
configMapData: ConfigMapData | undefined;
|
||||||
|
|
||||||
|
override render() {
|
||||||
|
return html`<div class="item flex gap-3 py-4 ${this.breath ? 'animate-breath' : ''}">
|
||||||
|
<div class="item-avatar flex-none">
|
||||||
|
<user-avatar
|
||||||
|
src="${this.userAvatar || ''}"
|
||||||
|
alt="${this.userDisplayName || ''}"
|
||||||
|
href=${ifDefined(this.userWebsite)}
|
||||||
|
></user-avatar>
|
||||||
|
</div>
|
||||||
|
<div class="item-main flex-[1_1_auto] min-w-0 w-full">
|
||||||
|
<div class="item-meta flex items-center gap-2 flex-wrap">
|
||||||
|
${when(
|
||||||
|
this.userWebsite,
|
||||||
|
() => html`
|
||||||
|
<a
|
||||||
|
class="item-author font-medium text-sm text-text-1 hover:underline"
|
||||||
|
target="_blank"
|
||||||
|
href=${ifDefined(this.userWebsite)}
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
>
|
||||||
|
${this.userDisplayName}
|
||||||
|
</a>
|
||||||
|
`,
|
||||||
|
() => html`
|
||||||
|
<span class="item-author font-medium text-sm text-text-1">${this.userDisplayName}</span>
|
||||||
|
`
|
||||||
|
)}
|
||||||
|
|
||||||
|
${when(
|
||||||
|
this.private && this.configMapData?.basic.showPrivateCommentBadge,
|
||||||
|
() => html`<div class="inline-flex items-center gap-1 bg-muted-3 rounded-base px-1.5 py-1">
|
||||||
|
<i class="i-ri-git-repository-private-line opacity-90 size-3" aria-hidden="true"></i>
|
||||||
|
<span class="text-xs text-text-2">${msg('Private')}</span>
|
||||||
|
</div>`
|
||||||
|
)}
|
||||||
|
|
||||||
|
${when(this.ua && this.configMapData?.basic.showCommenterDevice, () => html`<commenter-ua-bar .ua=${this.ua}></commenter-ua-bar>`)}
|
||||||
|
|
||||||
|
<time class="item-meta-info text-xs text-text-3" title=${formatDate(this.creationTime)}>
|
||||||
|
${timeAgo(this.creationTime)}
|
||||||
|
</time>
|
||||||
|
|
||||||
|
${when(!this.approved, () => html`<div class="item-meta-info text-xs text-text-3">${msg('Reviewing')}</div>`)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="item-content mt-2.5 space-y-2.5"><slot name="pre-content"></slot><comment-content .content=${this.content}></comment-content></div>
|
||||||
|
|
||||||
|
<div class="item-actions mt-2 flex items-center gap-3">
|
||||||
|
<slot name="action"></slot>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<slot name="footer"></slot>
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
static override styles = [
|
||||||
|
...baseStyles,
|
||||||
|
css`
|
||||||
|
.animate-breath {
|
||||||
|
animation: breath 1s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes breath {
|
||||||
|
0% {
|
||||||
|
transform: scale(1);
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
transform: scale(1.02);
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
transform: scale(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@unocss-placeholder;
|
||||||
|
`,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
customElements.get('base-comment-item') ||
|
||||||
|
customElements.define('base-comment-item', BaseCommentItem);
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
interface HTMLElementTagNameMap {
|
||||||
|
'base-comment-item': BaseCommentItem;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,364 @@
|
|||||||
|
import type { User } from '@halo-dev/api-client';
|
||||||
|
import { consume } from '@lit/context';
|
||||||
|
import { debounce } from 'es-toolkit';
|
||||||
|
import { css, html, LitElement } from 'lit';
|
||||||
|
import { property, state } from 'lit/decorators.js';
|
||||||
|
import { createRef, type Ref, ref } from 'lit/directives/ref.js';
|
||||||
|
import {
|
||||||
|
allowAnonymousCommentsContext,
|
||||||
|
baseUrlContext,
|
||||||
|
configMapDataContext,
|
||||||
|
currentUserContext,
|
||||||
|
groupContext,
|
||||||
|
kindContext,
|
||||||
|
nameContext,
|
||||||
|
toastContext,
|
||||||
|
} from './context';
|
||||||
|
import './icons/icon-loading';
|
||||||
|
import { msg } from '@lit/localize';
|
||||||
|
import type { ToastManager } from './lit-toast';
|
||||||
|
import baseStyles from './styles/base';
|
||||||
|
import type { ConfigMapData } from './types';
|
||||||
|
import './comment-editor';
|
||||||
|
import { when } from 'lit/directives/when.js';
|
||||||
|
import { ofetch } from 'ofetch';
|
||||||
|
import type { CommentEditor } from './comment-editor';
|
||||||
|
import { cleanHtml } from './utils/html';
|
||||||
|
import './base-tooltip';
|
||||||
|
|
||||||
|
export class BaseForm extends LitElement {
|
||||||
|
@consume({ context: baseUrlContext })
|
||||||
|
@state()
|
||||||
|
baseUrl = '';
|
||||||
|
|
||||||
|
@consume({ context: currentUserContext, subscribe: true })
|
||||||
|
@state()
|
||||||
|
currentUser: User | undefined;
|
||||||
|
|
||||||
|
@consume({ context: configMapDataContext })
|
||||||
|
@state()
|
||||||
|
configMapData: ConfigMapData | undefined;
|
||||||
|
|
||||||
|
@consume({ context: allowAnonymousCommentsContext, subscribe: true })
|
||||||
|
@state()
|
||||||
|
allowAnonymousComments = false;
|
||||||
|
|
||||||
|
@consume({ context: groupContext })
|
||||||
|
@state()
|
||||||
|
group = '';
|
||||||
|
|
||||||
|
@consume({ context: kindContext })
|
||||||
|
@state()
|
||||||
|
kind = '';
|
||||||
|
|
||||||
|
@consume({ context: nameContext })
|
||||||
|
@state()
|
||||||
|
name = '';
|
||||||
|
|
||||||
|
@property({ type: String })
|
||||||
|
@state()
|
||||||
|
captcha = '';
|
||||||
|
|
||||||
|
@property({ type: Boolean })
|
||||||
|
submitting = false;
|
||||||
|
|
||||||
|
@consume({ context: toastContext, subscribe: true })
|
||||||
|
@state()
|
||||||
|
toastManager: ToastManager | undefined;
|
||||||
|
|
||||||
|
@property({ type: Boolean })
|
||||||
|
hidePrivateCheckbox = false;
|
||||||
|
|
||||||
|
textareaRef: Ref<HTMLTextAreaElement> = createRef<HTMLTextAreaElement>();
|
||||||
|
|
||||||
|
editorRef: Ref<CommentEditor> = createRef<CommentEditor>();
|
||||||
|
|
||||||
|
get customAccount() {
|
||||||
|
return JSON.parse(
|
||||||
|
localStorage.getItem('halo-comment-custom-account') || '{}'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
get parentDomId() {
|
||||||
|
return `#comment-${[this.group?.replaceAll('.', '-'), this.kind, this.name]
|
||||||
|
.join('-')
|
||||||
|
.replaceAll(/-+/g, '-')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
get loginUrl() {
|
||||||
|
return `/login?redirect_uri=${encodeURIComponent(
|
||||||
|
window.location.pathname + this.parentDomId
|
||||||
|
)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
get showCaptcha() {
|
||||||
|
return (
|
||||||
|
this.configMapData?.security.captcha.anonymousCommentCaptcha &&
|
||||||
|
!this.currentUser &&
|
||||||
|
this.allowAnonymousComments
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
override updated(changedProperties: Map<string, unknown>) {
|
||||||
|
if (
|
||||||
|
changedProperties.has('configMapData') ||
|
||||||
|
changedProperties.has('currentUser') ||
|
||||||
|
changedProperties.has('allowAnonymousComments')
|
||||||
|
) {
|
||||||
|
if (this.showCaptcha) {
|
||||||
|
this.handleFetchCaptcha();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async handleFetchCaptcha() {
|
||||||
|
if (!this.showCaptcha) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const data = await ofetch(
|
||||||
|
`/apis/api.commentwidget.halo.run/v1alpha1/captcha/-/generate`,
|
||||||
|
{
|
||||||
|
parseResponse: (txt) => txt,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
this.captcha = data;
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
this.toastManager?.error(msg('Failed to obtain verification code'));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
handleOpenLoginPage() {
|
||||||
|
window.location.href = this.loginUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
async handleLogout() {
|
||||||
|
if (
|
||||||
|
window.confirm(
|
||||||
|
msg(
|
||||||
|
'Click OK to jump to the logout page, Please make sure the content being edited has been saved.'
|
||||||
|
)
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
window.location.href = `/logout?redirect_uri=${encodeURIComponent(
|
||||||
|
window.location.pathname + this.parentDomId
|
||||||
|
)}`;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to logout', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
renderAccountInfo() {
|
||||||
|
return html`<div class="form-account flex items-center gap-2">
|
||||||
|
${when(
|
||||||
|
this.currentUser?.spec.avatar,
|
||||||
|
() => html`<div class="form-account-avatar avatar"><img src=${this.currentUser?.spec.avatar || ''} class="size-full object-cover" /></div>
|
||||||
|
`
|
||||||
|
)}
|
||||||
|
<span class="form-account-name text-base text-text-1 font-semibold">
|
||||||
|
${this.currentUser?.spec.displayName || this.currentUser?.metadata.name}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
@click=${this.handleLogout}
|
||||||
|
type="button"
|
||||||
|
class="form-logout text-xs text-text-3 hover:text-text-1 px-3 transition-all py-2 rounded-base border border-muted-3 opacity-100 hover:border-muted-4 hover:opacity-70 border-solid"
|
||||||
|
tabindex="-1"
|
||||||
|
>
|
||||||
|
${msg('Logout')}
|
||||||
|
</button>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
onKeydown(e: KeyboardEvent) {
|
||||||
|
if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) {
|
||||||
|
const form = this.shadowRoot?.querySelector('form');
|
||||||
|
e.preventDefault();
|
||||||
|
form?.requestSubmit();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override connectedCallback(): void {
|
||||||
|
super.connectedCallback();
|
||||||
|
this.addEventListener('keydown', this.onKeydown);
|
||||||
|
}
|
||||||
|
|
||||||
|
override disconnectedCallback(): void {
|
||||||
|
super.disconnectedCallback();
|
||||||
|
this.removeEventListener('keydown', this.onKeydown);
|
||||||
|
}
|
||||||
|
|
||||||
|
override render() {
|
||||||
|
return html`
|
||||||
|
<form class="form w-full flex flex-col gap-4" @submit="${this.onSubmit}">
|
||||||
|
<comment-editor ${ref(this.editorRef)} .placeholder=${this.configMapData?.editor?.placeholder}></comment-editor>
|
||||||
|
|
||||||
|
${when(
|
||||||
|
!this.currentUser && this.allowAnonymousComments,
|
||||||
|
() => html`
|
||||||
|
<div class="form-inputs grid grid-cols-1 md:grid-cols-4 gap-2 items-center">
|
||||||
|
<input
|
||||||
|
name="displayName"
|
||||||
|
value=${this.customAccount.displayName}
|
||||||
|
type="text"
|
||||||
|
placeholder=${msg('Nicename')}
|
||||||
|
required
|
||||||
|
class="input"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
name="email"
|
||||||
|
value=${this.customAccount.email}
|
||||||
|
type="email"
|
||||||
|
placeholder=${msg('Email')}
|
||||||
|
required
|
||||||
|
class="input"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
name="website"
|
||||||
|
value=${this.customAccount.website}
|
||||||
|
type="url"
|
||||||
|
placeholder=${msg('Website')}
|
||||||
|
class="input"
|
||||||
|
/>
|
||||||
|
<a tabindex="-1" href=${this.loginUrl} rel="nofollow" class="form-login-link text-text-3 hover:text-text-1 text-xs transition-all select-none">${msg('(Or login)')}</a>
|
||||||
|
</div>
|
||||||
|
`
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div class="form__footer">
|
||||||
|
${this.currentUser ? this.renderAccountInfo() : ''}
|
||||||
|
${when(
|
||||||
|
!this.currentUser && !this.allowAnonymousComments,
|
||||||
|
() => html`
|
||||||
|
<button
|
||||||
|
@click=${this.handleOpenLoginPage}
|
||||||
|
class="form-login text-xs text-text-3 hover:text-text-1 px-3 transition-all py-2 rounded-base border border-muted-3 opacity-100 hover:border-muted-4 hover:opacity-70 border-solid"
|
||||||
|
type="button"
|
||||||
|
tabindex="-1"
|
||||||
|
>
|
||||||
|
${msg('Login')}
|
||||||
|
</button>
|
||||||
|
`
|
||||||
|
)}
|
||||||
|
<div class="form-actions justify-end flex gap-3 flex-wrap items-center">
|
||||||
|
${when(
|
||||||
|
!this.hidePrivateCheckbox &&
|
||||||
|
this.configMapData?.basic.enablePrivateComment,
|
||||||
|
() => html`<div class="flex items-center gap-2">
|
||||||
|
<input id="hidden" name="hidden" type="checkbox" />
|
||||||
|
<label for="hidden" class="text-xs select-none text-text-3 hover:text-text-1 transition-all">${msg('Private')}</label>
|
||||||
|
<base-tooltip content=${this.currentUser ? msg('Currently logged in. After selecting the private option, comments will only be visible to yourself and the site administrator.') : msg('You are currently anonymous. After selecting the private option, the comment will only be visible to the site administrator.')}>
|
||||||
|
<i class="i-mingcute:information-line size-3.5 text-text-3 block"></i>
|
||||||
|
</base-tooltip>
|
||||||
|
</div>`
|
||||||
|
)}
|
||||||
|
|
||||||
|
${when(
|
||||||
|
this.showCaptcha && this.captcha,
|
||||||
|
() => html`
|
||||||
|
<div class="form-captcha gap-2 flex items-center">
|
||||||
|
<img
|
||||||
|
@click=${this.handleFetchCaptcha}
|
||||||
|
src="${this.captcha}"
|
||||||
|
alt="captcha"
|
||||||
|
class="h-10 rounded-base border border-gray-100 border-solid"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
name="captchaCode"
|
||||||
|
type="text"
|
||||||
|
placeholder=${msg('Please enter the verification code')}
|
||||||
|
class="input "
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
`
|
||||||
|
)}
|
||||||
|
|
||||||
|
<button
|
||||||
|
.disabled=${this.submitting}
|
||||||
|
type="submit"
|
||||||
|
class="form-submit outline-none focus:shadow-input h-12 text-sm inline-flex border border-primary-1 border-solid items-center justify-center gap-2 bg-primary-1 text-white px-3 rounded-base hover:opacity-80 transition-all"
|
||||||
|
>
|
||||||
|
${when(
|
||||||
|
this.submitting,
|
||||||
|
() => html`<icon-loading></icon-loading>`,
|
||||||
|
() =>
|
||||||
|
html`<i class="i-mingcute-send-line size-5" aria-hidden="true"></i>`
|
||||||
|
)}
|
||||||
|
${msg('Submit')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private debouncedSubmit = debounce((data: Record<string, unknown>) => {
|
||||||
|
const content = cleanHtml(this.editorRef.value?.editor?.getHTML());
|
||||||
|
const characterCount =
|
||||||
|
this.editorRef.value?.editor?.storage.characterCount.characters();
|
||||||
|
|
||||||
|
if (!characterCount) {
|
||||||
|
this.toastManager?.warn(msg('Please enter content'));
|
||||||
|
this.editorRef.value?.setFocus();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const event = new CustomEvent('submit', {
|
||||||
|
detail: {
|
||||||
|
...data,
|
||||||
|
content,
|
||||||
|
hidden: data.hidden === 'on',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
this.dispatchEvent(event);
|
||||||
|
}, 300);
|
||||||
|
|
||||||
|
onSubmit(e: Event) {
|
||||||
|
e.preventDefault();
|
||||||
|
const form = e.target as HTMLFormElement;
|
||||||
|
const formData = new FormData(form);
|
||||||
|
const data = Object.fromEntries(formData.entries());
|
||||||
|
|
||||||
|
// store account info
|
||||||
|
localStorage.setItem(
|
||||||
|
'halo-comment-custom-account',
|
||||||
|
JSON.stringify({
|
||||||
|
displayName: data.displayName,
|
||||||
|
email: data.email,
|
||||||
|
website: data.website,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
this.debouncedSubmit(data);
|
||||||
|
}
|
||||||
|
|
||||||
|
resetForm() {
|
||||||
|
const form = this.shadowRoot?.querySelector('form');
|
||||||
|
form?.reset();
|
||||||
|
this.editorRef.value?.reset();
|
||||||
|
}
|
||||||
|
|
||||||
|
setFocus() {
|
||||||
|
this.textareaRef.value?.focus();
|
||||||
|
this.editorRef.value?.setFocus();
|
||||||
|
}
|
||||||
|
|
||||||
|
static override styles = [
|
||||||
|
...baseStyles,
|
||||||
|
css`
|
||||||
|
@unocss-placeholder;
|
||||||
|
`,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
customElements.get('base-form') || customElements.define('base-form', BaseForm);
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
interface HTMLElementTagNameMap {
|
||||||
|
'base-form': BaseForm;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,161 @@
|
|||||||
|
import {
|
||||||
|
autoUpdate,
|
||||||
|
computePosition,
|
||||||
|
flip,
|
||||||
|
offset,
|
||||||
|
shift,
|
||||||
|
} from '@floating-ui/dom';
|
||||||
|
import type { TemplateResult } from 'lit';
|
||||||
|
import { css, html, LitElement } from 'lit';
|
||||||
|
import { property } from 'lit/decorators.js';
|
||||||
|
import baseStyles from './styles/base';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A simple tooltip component based on Lit and floating-ui.
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```html
|
||||||
|
* <base-tooltip content="This is a tooltip">
|
||||||
|
* <button>Hover me</button>
|
||||||
|
* </base-tooltip>
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
export class BaseTooltip extends LitElement {
|
||||||
|
/**
|
||||||
|
* The content to display in the tooltip.
|
||||||
|
*/
|
||||||
|
@property({ type: String })
|
||||||
|
content: string | TemplateResult = '';
|
||||||
|
|
||||||
|
private tooltipEl?: HTMLElement;
|
||||||
|
private cleanupFn?: () => void;
|
||||||
|
|
||||||
|
override connectedCallback(): void {
|
||||||
|
super.connectedCallback();
|
||||||
|
this.addEventListener('mouseenter', this._showTooltip);
|
||||||
|
this.addEventListener('mouseleave', this._hideTooltip);
|
||||||
|
}
|
||||||
|
|
||||||
|
override disconnectedCallback(): void {
|
||||||
|
super.disconnectedCallback();
|
||||||
|
this.removeEventListener('mouseenter', this._showTooltip);
|
||||||
|
this.removeEventListener('mouseleave', this._hideTooltip);
|
||||||
|
|
||||||
|
// Clean up positioning if needed
|
||||||
|
if (this.cleanupFn) {
|
||||||
|
this.cleanupFn();
|
||||||
|
this.cleanupFn = undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override firstUpdated(): void {
|
||||||
|
// Get tooltip element after the component is rendered
|
||||||
|
this.tooltipEl = this.shadowRoot?.querySelector('.tooltip') as HTMLElement;
|
||||||
|
}
|
||||||
|
|
||||||
|
private _showTooltip = (): void => {
|
||||||
|
if (!this.tooltipEl) return;
|
||||||
|
|
||||||
|
this.tooltipEl.classList.add('show');
|
||||||
|
this._updatePosition();
|
||||||
|
};
|
||||||
|
|
||||||
|
private _hideTooltip = (): void => {
|
||||||
|
if (!this.tooltipEl) return;
|
||||||
|
|
||||||
|
this.tooltipEl.classList.remove('show');
|
||||||
|
|
||||||
|
// Clean up positioning
|
||||||
|
if (this.cleanupFn) {
|
||||||
|
this.cleanupFn();
|
||||||
|
this.cleanupFn = undefined;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
private _updatePosition(): void {
|
||||||
|
if (!this.tooltipEl) return;
|
||||||
|
|
||||||
|
const floating = this.tooltipEl;
|
||||||
|
|
||||||
|
// Clean up previous positioning if any
|
||||||
|
if (this.cleanupFn) {
|
||||||
|
this.cleanupFn();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use autoUpdate to reposition on scroll/resize
|
||||||
|
this.cleanupFn = autoUpdate(this, floating, async () => {
|
||||||
|
// Calculate the position
|
||||||
|
const middlewares = [
|
||||||
|
offset(8), // 8px distance from trigger
|
||||||
|
flip(), // flip to other side if needed
|
||||||
|
shift({ padding: 5 }), // shift to keep in viewport
|
||||||
|
];
|
||||||
|
|
||||||
|
// Calculate position
|
||||||
|
const { x, y } = await computePosition(this, floating, {
|
||||||
|
placement: 'top', // Default placement is top
|
||||||
|
middleware: middlewares,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Position the tooltip
|
||||||
|
Object.assign(floating.style, {
|
||||||
|
left: `${x}px`,
|
||||||
|
top: `${y}px`,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
override render() {
|
||||||
|
return html`
|
||||||
|
<slot></slot>
|
||||||
|
<div class="tooltip">
|
||||||
|
${this.content}
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
static override styles = [
|
||||||
|
...baseStyles,
|
||||||
|
css`
|
||||||
|
:host {
|
||||||
|
display: inline-block;
|
||||||
|
}
|
||||||
|
|
||||||
|
::slotted(*) {
|
||||||
|
display: inline-block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tooltip {
|
||||||
|
position: absolute;
|
||||||
|
background: rgba(0, 0, 0, 0.8);
|
||||||
|
color: white;
|
||||||
|
padding: 5px 10px;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 0.875em;
|
||||||
|
max-width: 300px;
|
||||||
|
width: auto;
|
||||||
|
z-index: 10;
|
||||||
|
opacity: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
transition: opacity 0.2s;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
white-space: normal;
|
||||||
|
word-wrap: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tooltip.show {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
`,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
customElements.get('base-tooltip') ||
|
||||||
|
customElements.define('base-tooltip', BaseTooltip);
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
interface HTMLElementTagNameMap {
|
||||||
|
'base-tooltip': BaseTooltip;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
import { css, html, LitElement, type PropertyValues, unsafeCSS } from 'lit';
|
||||||
|
import { property } from 'lit/decorators.js';
|
||||||
|
import { unsafeHTML } from 'lit/directives/unsafe-html.js';
|
||||||
|
import baseStyles from './styles/base';
|
||||||
|
import contentStyles from './styles/content.css?inline';
|
||||||
|
import { cleanHtml } from './utils/html';
|
||||||
|
|
||||||
|
export class CommentContent extends LitElement {
|
||||||
|
@property({ type: String })
|
||||||
|
content: string = '';
|
||||||
|
|
||||||
|
protected override firstUpdated(_changedProperties: PropertyValues) {
|
||||||
|
super.firstUpdated(_changedProperties);
|
||||||
|
const codeElements = this.shadowRoot?.querySelectorAll('pre>code');
|
||||||
|
if (!codeElements?.length) return;
|
||||||
|
|
||||||
|
Promise.all(
|
||||||
|
Array.from(codeElements).map(async (codeblock) => {
|
||||||
|
const lang =
|
||||||
|
this.extractLanguageFromCodeElement(codeblock) || 'plaintext';
|
||||||
|
const content = codeblock.textContent || '';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { codeToHtml } = await import('shiki/bundle/full');
|
||||||
|
|
||||||
|
const html = await codeToHtml(content, {
|
||||||
|
lang,
|
||||||
|
theme: 'github-dark',
|
||||||
|
});
|
||||||
|
|
||||||
|
if (codeblock.parentElement) {
|
||||||
|
codeblock.parentElement.outerHTML = html;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to highlight code:', error);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private extractLanguageFromCodeElement(codeElement: Element): string | null {
|
||||||
|
const supportedPrefixes = ['language-', 'lang-'];
|
||||||
|
|
||||||
|
const langClass = Array.from(codeElement.classList).find((className) =>
|
||||||
|
supportedPrefixes.some((prefix) => className.startsWith(prefix))
|
||||||
|
);
|
||||||
|
|
||||||
|
if (langClass) {
|
||||||
|
const prefix = supportedPrefixes.find((p) => langClass.startsWith(p));
|
||||||
|
return prefix ? langClass.substring(prefix.length) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override render() {
|
||||||
|
return html`
|
||||||
|
<div class="content">${unsafeHTML(cleanHtml(this.content))}</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
static override styles = [
|
||||||
|
...baseStyles,
|
||||||
|
unsafeCSS(contentStyles),
|
||||||
|
css`
|
||||||
|
:host {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
@unocss-placeholder;
|
||||||
|
`,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
customElements.get('comment-content') ||
|
||||||
|
customElements.define('comment-content', CommentContent);
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
interface HTMLElementTagNameMap {
|
||||||
|
'comment-content': CommentContent;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import { css, html, LitElement } from 'lit';
|
||||||
|
import { repeat } from 'lit/directives/repeat.js';
|
||||||
|
|
||||||
|
export class CommentEditorSkeleton extends LitElement {
|
||||||
|
protected override render() {
|
||||||
|
return html`<div
|
||||||
|
class="border rounded-base border-solid border-muted-1"
|
||||||
|
>
|
||||||
|
<div class="animate-pulse p-4">
|
||||||
|
<div class="h-4 mt-1 mb-8.75px w-20 bg-muted-1 rounded"></div>
|
||||||
|
</div>
|
||||||
|
<div class="py-2.5 px-3 flex gap-1 m-0 items-center">
|
||||||
|
${repeat(
|
||||||
|
Array(8),
|
||||||
|
() => html`
|
||||||
|
<div
|
||||||
|
role="button"
|
||||||
|
class="size-7 flex items-center justify-center cursor-pointer"
|
||||||
|
>
|
||||||
|
<div class="size-5 animate-pulse bg-muted-1 rounded-base"></div>
|
||||||
|
</div>
|
||||||
|
`
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
static override styles = [
|
||||||
|
css`
|
||||||
|
:host {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
@unocss-placeholder;
|
||||||
|
`,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
customElements.get('comment-editor-skeleton') ||
|
||||||
|
customElements.define('comment-editor-skeleton', CommentEditorSkeleton);
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
interface HTMLElementTagNameMap {
|
||||||
|
'comment-editor-skeleton': CommentEditorSkeleton;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,268 @@
|
|||||||
|
import { msg } from '@lit/localize';
|
||||||
|
import type { Editor } from '@tiptap/core';
|
||||||
|
import { css, html, LitElement, type PropertyValues, unsafeCSS } from 'lit';
|
||||||
|
import { state } from 'lit/decorators.js';
|
||||||
|
import { repeat } from 'lit/directives/repeat.js';
|
||||||
|
import './emoji-button';
|
||||||
|
import contentStyles from './styles/content.css?inline';
|
||||||
|
import './comment-editor-skeleton';
|
||||||
|
import { property } from 'lit/decorators.js';
|
||||||
|
import { ifDefined } from 'lit/directives/if-defined.js';
|
||||||
|
import { when } from 'lit/directives/when.js';
|
||||||
|
import baseStyles from './styles/base';
|
||||||
|
import { cleanHtml } from './utils/html';
|
||||||
|
|
||||||
|
interface ActionItem {
|
||||||
|
name?: string;
|
||||||
|
displayName?: () => string;
|
||||||
|
type: 'action' | 'separator';
|
||||||
|
icon?: string;
|
||||||
|
run?: (editor?: Editor) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const actionItems: ActionItem[] = [
|
||||||
|
{
|
||||||
|
name: 'bold',
|
||||||
|
displayName: () => msg('Bold'),
|
||||||
|
type: 'action',
|
||||||
|
icon: 'i-mingcute-bold-line',
|
||||||
|
run: (editor?: Editor) => editor?.chain().focus().toggleBold().run(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'italic',
|
||||||
|
displayName: () => msg('Italic'),
|
||||||
|
type: 'action',
|
||||||
|
icon: 'i-mingcute-italic-line',
|
||||||
|
run: (editor?: Editor) => editor?.chain().focus().toggleItalic().run(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'underline',
|
||||||
|
displayName: () => msg('Underline'),
|
||||||
|
type: 'action',
|
||||||
|
icon: 'i-mingcute-underline-line',
|
||||||
|
run: (editor?: Editor) => editor?.chain().focus().toggleUnderline().run(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'strike',
|
||||||
|
displayName: () => msg('Strike'),
|
||||||
|
type: 'action',
|
||||||
|
icon: 'i-mingcute-strikethrough-line',
|
||||||
|
run: (editor?: Editor) => editor?.chain().focus().toggleStrike().run(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'code',
|
||||||
|
displayName: () => msg('Code'),
|
||||||
|
type: 'action',
|
||||||
|
icon: 'i-mingcute-braces-line',
|
||||||
|
run: (editor?: Editor) => editor?.chain().focus().toggleCode().run(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'separator',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'blockquote',
|
||||||
|
displayName: () => msg('Blockquote'),
|
||||||
|
type: 'action',
|
||||||
|
icon: 'i-mingcute-quote-left-line',
|
||||||
|
run: (editor?: Editor) => editor?.chain().focus().toggleBlockquote().run(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'codeBlock',
|
||||||
|
displayName: () => msg('Code Block'),
|
||||||
|
type: 'action',
|
||||||
|
icon: 'i-mingcute-code-line',
|
||||||
|
run: (editor?: Editor) => editor?.chain().focus().toggleCodeBlock().run(),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export class CommentEditor extends LitElement {
|
||||||
|
@property({ type: String })
|
||||||
|
placeholder: string | undefined;
|
||||||
|
|
||||||
|
@property({ type: Boolean, attribute: 'keep-alive' })
|
||||||
|
keepAlive = false;
|
||||||
|
|
||||||
|
@state()
|
||||||
|
editor: Editor | undefined;
|
||||||
|
|
||||||
|
@state()
|
||||||
|
loading = true;
|
||||||
|
|
||||||
|
protected override firstUpdated(_changedProperties: PropertyValues): void {
|
||||||
|
super.firstUpdated(_changedProperties);
|
||||||
|
this.createEditor();
|
||||||
|
}
|
||||||
|
|
||||||
|
async createEditor() {
|
||||||
|
const { Editor } = await import('@tiptap/core');
|
||||||
|
const { Placeholder } = await import('@tiptap/extensions');
|
||||||
|
const { StarterKit } = await import('@tiptap/starter-kit');
|
||||||
|
const { CodeBlockShiki } = await import(
|
||||||
|
'tiptap-extension-code-block-shiki'
|
||||||
|
);
|
||||||
|
const { CharacterCount } = await import('@tiptap/extensions');
|
||||||
|
|
||||||
|
this.loading = false;
|
||||||
|
|
||||||
|
this.editor = new Editor({
|
||||||
|
element: this.shadowRoot?.getElementById('editor-container'),
|
||||||
|
extensions: [
|
||||||
|
StarterKit.configure({
|
||||||
|
heading: false,
|
||||||
|
link: {
|
||||||
|
openOnClick: false,
|
||||||
|
},
|
||||||
|
codeBlock: false,
|
||||||
|
}),
|
||||||
|
|
||||||
|
Placeholder.configure({
|
||||||
|
placeholder: this.placeholder || msg('Write a comment'),
|
||||||
|
}),
|
||||||
|
|
||||||
|
CodeBlockShiki.configure({
|
||||||
|
defaultTheme: 'github-dark',
|
||||||
|
}),
|
||||||
|
|
||||||
|
CharacterCount,
|
||||||
|
],
|
||||||
|
onUpdate: () => {
|
||||||
|
this.requestUpdate();
|
||||||
|
},
|
||||||
|
onSelectionUpdate: () => {
|
||||||
|
this.requestUpdate();
|
||||||
|
},
|
||||||
|
onTransaction: () => {
|
||||||
|
this.requestUpdate();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
this.editor.on('update', () => {
|
||||||
|
this.dispatchEvent(
|
||||||
|
new CustomEvent('update', {
|
||||||
|
detail: {
|
||||||
|
content: cleanHtml(this.editor?.getHTML()),
|
||||||
|
characterCount: this.editor?.storage.characterCount.characters(),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
override disconnectedCallback(): void {
|
||||||
|
if (!this.keepAlive) {
|
||||||
|
this.editor?.destroy();
|
||||||
|
this.editor = undefined;
|
||||||
|
}
|
||||||
|
super.disconnectedCallback();
|
||||||
|
}
|
||||||
|
|
||||||
|
setFocus() {
|
||||||
|
setTimeout(() => {
|
||||||
|
this.editor?.chain().focus().run();
|
||||||
|
}, 100);
|
||||||
|
}
|
||||||
|
|
||||||
|
reset() {
|
||||||
|
this.editor?.commands.setContent('');
|
||||||
|
}
|
||||||
|
|
||||||
|
onEmojiSelect(e: CustomEvent) {
|
||||||
|
this.editor?.chain().focus().insertContent(e.detail.native).run();
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override render() {
|
||||||
|
return html`
|
||||||
|
${when(this.loading, () => html`<comment-editor-skeleton></comment-editor-skeleton>`)}
|
||||||
|
<div
|
||||||
|
class="border rounded-base border-solid border-muted-1 focus-within:border-primary-1 focus-within:shadow-input transition-all"
|
||||||
|
?hidden=${this.loading}
|
||||||
|
@click=${this.setFocus}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
id="editor-container"
|
||||||
|
class="p-4 content !bg-transparent"
|
||||||
|
></div>
|
||||||
|
<ul class="list-none p-2.5 flex gap-1 m-0 items-center overflow-x-auto">
|
||||||
|
${repeat(actionItems, (item) =>
|
||||||
|
this.renderActionItem(item, this.editor)
|
||||||
|
)}
|
||||||
|
${this.renderActionItem({ type: 'separator' })}
|
||||||
|
<li class="flex items-center">
|
||||||
|
<emoji-button @emoji-select=${this.onEmojiSelect}></emoji-button>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private renderActionItem(item: ActionItem, editor?: Editor) {
|
||||||
|
if (item.type === 'separator') {
|
||||||
|
return html`<li class="flex items-center" aria-hidden="true">
|
||||||
|
<div class="w-1px bg-muted-1 rounded-full h-3"></div>
|
||||||
|
</li>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (item.type === 'action') {
|
||||||
|
const isActive = item.name ? editor?.isActive(item.name) : false;
|
||||||
|
return html`
|
||||||
|
<li>
|
||||||
|
<div
|
||||||
|
aria-label=${ifDefined(item.displayName?.())}
|
||||||
|
title=${ifDefined(item.displayName?.())}
|
||||||
|
@click=${() => item.run?.(editor)}
|
||||||
|
role="button"
|
||||||
|
class="size-7 hover:bg-muted-3 active:bg-muted-2 ${isActive ? 'bg-muted-3 text-text-1' : 'text-text-3 hover:text-text-1'} rounded-base flex items-center justify-center cursor-pointer transition-all"
|
||||||
|
>
|
||||||
|
<i class="size-5 ${item.icon}" aria-hidden="true"></i>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
static override styles = [
|
||||||
|
...baseStyles,
|
||||||
|
unsafeCSS(contentStyles),
|
||||||
|
css`
|
||||||
|
|
||||||
|
:host {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tiptap {
|
||||||
|
outline: none;
|
||||||
|
border: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tiptap p {
|
||||||
|
padding: 0;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tiptap p.is-editor-empty:first-child::before {
|
||||||
|
content: attr(data-placeholder);
|
||||||
|
float: left;
|
||||||
|
height: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
color: var(--halo-cw-text-3-color, #475569);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tiptap code br {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
@unocss-placeholder;
|
||||||
|
`,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
customElements.get('comment-editor') ||
|
||||||
|
customElements.define('comment-editor', CommentEditor);
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
interface HTMLElementTagNameMap {
|
||||||
|
'comment-editor': CommentEditor;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,183 @@
|
|||||||
|
import type { Comment, CommentRequest, User } from '@halo-dev/api-client';
|
||||||
|
import { consume } from '@lit/context';
|
||||||
|
import { html, LitElement } from 'lit';
|
||||||
|
import { state } from 'lit/decorators.js';
|
||||||
|
import { createRef, type Ref, ref } from 'lit/directives/ref.js';
|
||||||
|
import './base-form';
|
||||||
|
import { msg } from '@lit/localize';
|
||||||
|
import { FetchError, type FetchResponse, ofetch } from 'ofetch';
|
||||||
|
import type { BaseForm } from './base-form';
|
||||||
|
import {
|
||||||
|
allowAnonymousCommentsContext,
|
||||||
|
baseUrlContext,
|
||||||
|
currentUserContext,
|
||||||
|
groupContext,
|
||||||
|
kindContext,
|
||||||
|
nameContext,
|
||||||
|
toastContext,
|
||||||
|
versionContext,
|
||||||
|
} from './context';
|
||||||
|
import type { ToastManager } from './lit-toast';
|
||||||
|
import type { ProblemDetail } from './types';
|
||||||
|
import {
|
||||||
|
type CaptchaRequiredResponse,
|
||||||
|
getCaptchaCodeHeader,
|
||||||
|
isRequireCaptcha,
|
||||||
|
} from './utils/captcha';
|
||||||
|
|
||||||
|
export class CommentForm extends LitElement {
|
||||||
|
@consume({ context: baseUrlContext })
|
||||||
|
@state()
|
||||||
|
baseUrl = '';
|
||||||
|
|
||||||
|
@consume({ context: currentUserContext, subscribe: true })
|
||||||
|
@state()
|
||||||
|
currentUser: User | undefined;
|
||||||
|
|
||||||
|
@consume({ context: groupContext })
|
||||||
|
@state()
|
||||||
|
group = '';
|
||||||
|
|
||||||
|
@consume({ context: kindContext })
|
||||||
|
@state()
|
||||||
|
kind = '';
|
||||||
|
|
||||||
|
@consume({ context: nameContext })
|
||||||
|
@state()
|
||||||
|
name = '';
|
||||||
|
|
||||||
|
@consume({ context: versionContext })
|
||||||
|
@state()
|
||||||
|
version = 'v1alpha1';
|
||||||
|
|
||||||
|
@consume({ context: allowAnonymousCommentsContext, subscribe: true })
|
||||||
|
@state()
|
||||||
|
allowAnonymousComments = false;
|
||||||
|
|
||||||
|
@consume({ context: toastContext, subscribe: true })
|
||||||
|
@state()
|
||||||
|
toastManager: ToastManager | undefined;
|
||||||
|
|
||||||
|
@state()
|
||||||
|
submitting = false;
|
||||||
|
|
||||||
|
@state()
|
||||||
|
captcha = '';
|
||||||
|
|
||||||
|
baseFormRef: Ref<BaseForm> = createRef<BaseForm>();
|
||||||
|
|
||||||
|
override render() {
|
||||||
|
return html` <base-form
|
||||||
|
.submitting=${this.submitting}
|
||||||
|
.captcha=${this.captcha}
|
||||||
|
${ref(this.baseFormRef)}
|
||||||
|
@submit="${this.onSubmit}"
|
||||||
|
></base-form>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async onSubmit(e: CustomEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
this.submitting = true;
|
||||||
|
|
||||||
|
const data = e.detail;
|
||||||
|
|
||||||
|
const { displayName, email, website, content, hidden } = data || {};
|
||||||
|
|
||||||
|
const commentRequest: CommentRequest = {
|
||||||
|
raw: content,
|
||||||
|
content: content,
|
||||||
|
// TODO: support user input
|
||||||
|
allowNotification: true,
|
||||||
|
hidden: hidden || false,
|
||||||
|
subjectRef: {
|
||||||
|
group: this.group,
|
||||||
|
kind: this.kind,
|
||||||
|
name: this.name,
|
||||||
|
version: this.version,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!this.currentUser && !this.allowAnonymousComments) {
|
||||||
|
this.toastManager?.warn(msg('Please login first'));
|
||||||
|
this.submitting = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!this.currentUser && this.allowAnonymousComments) {
|
||||||
|
if (!displayName || !email) {
|
||||||
|
this.toastManager?.warn(
|
||||||
|
msg('Please log in or complete the information first')
|
||||||
|
);
|
||||||
|
this.submitting = false;
|
||||||
|
return;
|
||||||
|
} else {
|
||||||
|
commentRequest.owner = {
|
||||||
|
displayName: displayName,
|
||||||
|
email: email,
|
||||||
|
website: website,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const newComment = await ofetch<Comment>(
|
||||||
|
`${this.baseUrl}/apis/api.halo.run/v1alpha1/comments`,
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
...getCaptchaCodeHeader(data.captchaCode),
|
||||||
|
},
|
||||||
|
body: commentRequest,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
this.baseFormRef.value?.handleFetchCaptcha();
|
||||||
|
|
||||||
|
if (newComment.spec.approved) {
|
||||||
|
this.toastManager?.success(msg('Comment submitted successfully'));
|
||||||
|
} else {
|
||||||
|
this.toastManager?.success(
|
||||||
|
msg('Comment submitted successfully, pending review')
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
window.dispatchEvent(new CustomEvent('halo:comment:created'));
|
||||||
|
|
||||||
|
this.baseFormRef.value?.resetForm();
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof FetchError) {
|
||||||
|
if (
|
||||||
|
isRequireCaptcha(
|
||||||
|
error.response as FetchResponse<CaptchaRequiredResponse>
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
const { captcha, detail } =
|
||||||
|
error.data as unknown as CaptchaRequiredResponse;
|
||||||
|
this.captcha = captcha;
|
||||||
|
this.toastManager?.warn(detail);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const problemDetail = error.data as unknown as ProblemDetail;
|
||||||
|
this.toastManager?.error(
|
||||||
|
[problemDetail?.title, problemDetail?.detail].join(' - ') ||
|
||||||
|
msg('Comment failed, please try again later')
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.toastManager?.error(msg('Comment failed, please try again later'));
|
||||||
|
} finally {
|
||||||
|
this.submitting = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
customElements.get('comment-form') ||
|
||||||
|
customElements.define('comment-form', CommentForm);
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
interface HTMLElementTagNameMap {
|
||||||
|
'comment-form': CommentForm;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,213 @@
|
|||||||
|
import type { CommentVo } from '@halo-dev/api-client';
|
||||||
|
import { css, html, LitElement } from 'lit';
|
||||||
|
import { property, state } from 'lit/decorators.js';
|
||||||
|
import baseStyles from './styles/base';
|
||||||
|
import './comment-replies';
|
||||||
|
import './user-avatar';
|
||||||
|
import './base-comment-item';
|
||||||
|
import { consume } from '@lit/context';
|
||||||
|
import { msg } from '@lit/localize';
|
||||||
|
import { createRef, type Ref, ref } from 'lit/directives/ref.js';
|
||||||
|
import { when } from 'lit/directives/when.js';
|
||||||
|
import { ofetch } from 'ofetch';
|
||||||
|
import { getPolicyInstance } from './avatar/avatar-policy';
|
||||||
|
import type { CommentReplies } from './comment-replies';
|
||||||
|
import { LS_UPVOTED_COMMENTS_KEY } from './constant';
|
||||||
|
import { baseUrlContext, configMapDataContext } from './context';
|
||||||
|
import type { ConfigMapData } from './types';
|
||||||
|
|
||||||
|
export class CommentItem extends LitElement {
|
||||||
|
@consume({ context: baseUrlContext })
|
||||||
|
@state()
|
||||||
|
baseUrl = '';
|
||||||
|
|
||||||
|
@property({ type: Object })
|
||||||
|
comment: CommentVo | undefined;
|
||||||
|
|
||||||
|
@consume({ context: configMapDataContext })
|
||||||
|
@state()
|
||||||
|
configMapData: ConfigMapData | undefined;
|
||||||
|
|
||||||
|
@state()
|
||||||
|
showReplies = false;
|
||||||
|
|
||||||
|
@state()
|
||||||
|
showReplyForm = false;
|
||||||
|
|
||||||
|
@state()
|
||||||
|
upvoted = false;
|
||||||
|
|
||||||
|
@state()
|
||||||
|
upvoteCount = 0;
|
||||||
|
|
||||||
|
commentRepliesRef: Ref<CommentReplies> = createRef<CommentReplies>();
|
||||||
|
|
||||||
|
override connectedCallback(): void {
|
||||||
|
super.connectedCallback();
|
||||||
|
this.checkUpvotedStatus();
|
||||||
|
|
||||||
|
if (this.configMapData?.basic.withReplies) {
|
||||||
|
this.showReplies = true;
|
||||||
|
this.showReplyForm = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.upvoteCount = this.comment?.stats.upvote || 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
checkUpvotedStatus() {
|
||||||
|
const upvotedComments = JSON.parse(
|
||||||
|
localStorage.getItem(LS_UPVOTED_COMMENTS_KEY) || '[]'
|
||||||
|
);
|
||||||
|
|
||||||
|
if (upvotedComments.includes(this.comment?.metadata.name)) {
|
||||||
|
this.upvoted = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async handleUpvote() {
|
||||||
|
const upvotedComments = JSON.parse(
|
||||||
|
localStorage.getItem(LS_UPVOTED_COMMENTS_KEY) || '[]'
|
||||||
|
);
|
||||||
|
|
||||||
|
if (upvotedComments.includes(this.comment?.metadata.name)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const voteRequest = {
|
||||||
|
name: this.comment?.metadata.name,
|
||||||
|
plural: 'comments',
|
||||||
|
group: 'content.halo.run',
|
||||||
|
};
|
||||||
|
|
||||||
|
await ofetch(`${this.baseUrl}/apis/api.halo.run/v1alpha1/trackers/upvote`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: voteRequest,
|
||||||
|
});
|
||||||
|
|
||||||
|
upvotedComments.push(this.comment?.metadata.name);
|
||||||
|
localStorage.setItem(
|
||||||
|
LS_UPVOTED_COMMENTS_KEY,
|
||||||
|
JSON.stringify(upvotedComments)
|
||||||
|
);
|
||||||
|
|
||||||
|
this.upvoteCount += 1;
|
||||||
|
this.upvoted = true;
|
||||||
|
this.checkUpvotedStatus();
|
||||||
|
}
|
||||||
|
|
||||||
|
handleShowReplies() {
|
||||||
|
this.showReplies = !this.showReplies;
|
||||||
|
|
||||||
|
if (!this.configMapData?.basic.withReplies) {
|
||||||
|
this.showReplyForm = !this.showReplyForm;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onReplyCreated() {
|
||||||
|
this.commentRepliesRef.value?.fetchReplies();
|
||||||
|
this.showReplies = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
handleToggleReplyForm() {
|
||||||
|
this.showReplyForm = !this.showReplyForm;
|
||||||
|
}
|
||||||
|
|
||||||
|
override render() {
|
||||||
|
return html`<base-comment-item
|
||||||
|
.userAvatar="${handleCommentAvatar(this.comment)}"
|
||||||
|
.userDisplayName="${this.comment?.owner.displayName}"
|
||||||
|
.content="${this.comment?.spec.content || ''}"
|
||||||
|
.creationTime="${this.comment?.spec.creationTime}"
|
||||||
|
.approved=${this.comment?.spec.approved}
|
||||||
|
.userWebsite=${this.comment?.spec.owner.annotations?.website}
|
||||||
|
.ua=${this.comment?.spec.userAgent}
|
||||||
|
.private=${this.comment?.spec.hidden}
|
||||||
|
>
|
||||||
|
<button slot="action" class="icon-button group -ml-2" type="button" @click="${this.handleUpvote}" aria-label=${msg('Upvote')}>
|
||||||
|
<div class="icon-button-icon">
|
||||||
|
${when(
|
||||||
|
this.upvoted,
|
||||||
|
() =>
|
||||||
|
html`<i slot="icon" class="i-mingcute-heart-fill size-4 text-red-500" aria-hidden="true"></i>`,
|
||||||
|
() =>
|
||||||
|
html`<i slot="icon" class="i-mingcute-heart-line size-4" aria-hidden="true"></i>`
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<span class="icon-button-text">${`${this.upvoteCount || 0}`}</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
${
|
||||||
|
this.configMapData?.basic.withReplies &&
|
||||||
|
this.comment?.status?.visibleReplyCount === 0
|
||||||
|
? ''
|
||||||
|
: html`
|
||||||
|
<button slot="action" class="icon-button group" type="button" @click="${this.handleShowReplies}" aria-label=${msg('Show replies')}>
|
||||||
|
<div class="icon-button-icon ">
|
||||||
|
<i slot="icon" class="i-tabler:message-circle size-4" aria-hidden="true"></i>
|
||||||
|
</div>
|
||||||
|
<span class="icon-button-text">${this.comment?.status?.visibleReplyCount || 0}</span>
|
||||||
|
</button>`
|
||||||
|
}
|
||||||
|
|
||||||
|
${when(
|
||||||
|
this.configMapData?.basic.withReplies,
|
||||||
|
() => html`
|
||||||
|
<button slot="action" class="icon-button group" type="button" @click="${this.handleToggleReplyForm}" aria-label=${this.showReplyForm ? msg('Cancel reply') : msg('Add reply')}>
|
||||||
|
<div class="icon-button-icon ">
|
||||||
|
<i slot="icon" class="i-tabler:message-circle-plus size-4" aria-hidden="true"></i>
|
||||||
|
</div>
|
||||||
|
<span class="icon-button-text">${this.showReplyForm ? msg('Cancel reply') : msg('Add reply')}</span>
|
||||||
|
</button>
|
||||||
|
`
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div slot="footer">
|
||||||
|
${when(
|
||||||
|
this.showReplyForm,
|
||||||
|
() => html`
|
||||||
|
<div class="mt-2">
|
||||||
|
<reply-form
|
||||||
|
@reload=${this.onReplyCreated}
|
||||||
|
.comment=${this.comment}
|
||||||
|
></reply-form>
|
||||||
|
</div>
|
||||||
|
`
|
||||||
|
)}
|
||||||
|
${when(
|
||||||
|
this.showReplies,
|
||||||
|
() => html`<comment-replies
|
||||||
|
${ref(this.commentRepliesRef)}
|
||||||
|
.comment="${this.comment}"
|
||||||
|
.showReplyForm=${this.showReplyForm}
|
||||||
|
></comment-replies>`
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</base-comment-item>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
static override styles = [
|
||||||
|
...baseStyles,
|
||||||
|
css`
|
||||||
|
@unocss-placeholder;
|
||||||
|
`,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
customElements.get('comment-item') ||
|
||||||
|
customElements.define('comment-item', CommentItem);
|
||||||
|
|
||||||
|
function handleCommentAvatar(
|
||||||
|
comment: CommentVo | undefined
|
||||||
|
): string | undefined {
|
||||||
|
const avatarPolicy = getPolicyInstance();
|
||||||
|
if (avatarPolicy === undefined) {
|
||||||
|
return comment?.owner.avatar;
|
||||||
|
}
|
||||||
|
return avatarPolicy.applyCommentPolicy(comment);
|
||||||
|
}
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
interface HTMLElementTagNameMap {
|
||||||
|
'comment-item': CommentItem;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,192 @@
|
|||||||
|
import { css, html, LitElement } from 'lit';
|
||||||
|
import './icons/icon-loading';
|
||||||
|
import type { CommentVoList } from '@halo-dev/api-client';
|
||||||
|
import { consume } from '@lit/context';
|
||||||
|
import { msg } from '@lit/localize';
|
||||||
|
import { state } from 'lit/decorators.js';
|
||||||
|
import { repeat } from 'lit/directives/repeat.js';
|
||||||
|
import {
|
||||||
|
baseUrlContext,
|
||||||
|
configMapDataContext,
|
||||||
|
groupContext,
|
||||||
|
kindContext,
|
||||||
|
nameContext,
|
||||||
|
toastContext,
|
||||||
|
versionContext,
|
||||||
|
} from './context';
|
||||||
|
import type { ToastManager } from './lit-toast';
|
||||||
|
import type { ConfigMapData } from './types';
|
||||||
|
import './comment-pagination';
|
||||||
|
import './comment-item';
|
||||||
|
import './loading-block';
|
||||||
|
import { when } from 'lit/directives/when.js';
|
||||||
|
import { ofetch } from 'ofetch';
|
||||||
|
import baseStyles from './styles/base';
|
||||||
|
|
||||||
|
export class CommentList extends LitElement {
|
||||||
|
@consume({ context: baseUrlContext })
|
||||||
|
@state()
|
||||||
|
baseUrl = '';
|
||||||
|
|
||||||
|
@consume({ context: groupContext })
|
||||||
|
@state()
|
||||||
|
group = '';
|
||||||
|
|
||||||
|
@consume({ context: kindContext })
|
||||||
|
@state()
|
||||||
|
kind = '';
|
||||||
|
|
||||||
|
@consume({ context: nameContext })
|
||||||
|
@state()
|
||||||
|
name = '';
|
||||||
|
|
||||||
|
@consume({ context: versionContext })
|
||||||
|
@state()
|
||||||
|
version = '';
|
||||||
|
|
||||||
|
@consume({ context: configMapDataContext })
|
||||||
|
@state()
|
||||||
|
configMapData: ConfigMapData | undefined;
|
||||||
|
|
||||||
|
@consume({ context: toastContext, subscribe: true })
|
||||||
|
@state()
|
||||||
|
toastManager: ToastManager | undefined;
|
||||||
|
|
||||||
|
@state()
|
||||||
|
comments: CommentVoList = {
|
||||||
|
page: 1,
|
||||||
|
size: 20,
|
||||||
|
total: 0,
|
||||||
|
items: [],
|
||||||
|
first: true,
|
||||||
|
last: false,
|
||||||
|
hasNext: false,
|
||||||
|
hasPrevious: false,
|
||||||
|
totalPages: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
@state()
|
||||||
|
loading = false;
|
||||||
|
|
||||||
|
get shouldDisplayPagination() {
|
||||||
|
if (this.loading) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.comments.hasNext || this.comments.hasPrevious;
|
||||||
|
}
|
||||||
|
|
||||||
|
async onPageChange(e: CustomEvent) {
|
||||||
|
const data = e.detail;
|
||||||
|
this.comments.page = data.page;
|
||||||
|
await this.fetchComments({ scrollIntoView: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
override connectedCallback(): void {
|
||||||
|
super.connectedCallback();
|
||||||
|
this.fetchComments();
|
||||||
|
|
||||||
|
// Handle halo:comment:created event, then reload the comment list
|
||||||
|
window.addEventListener('halo:comment:created', () => {
|
||||||
|
this.fetchComments({
|
||||||
|
page: 1,
|
||||||
|
scrollIntoView: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async fetchComments(options?: { page?: number; scrollIntoView?: boolean }) {
|
||||||
|
const { page, scrollIntoView } = options || {};
|
||||||
|
try {
|
||||||
|
if (this.comments.items.length === 0) {
|
||||||
|
this.loading = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (page) {
|
||||||
|
this.comments.page = page;
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await ofetch<CommentVoList>(
|
||||||
|
`${this.baseUrl}/apis/api.halo.run/v1alpha1/comments`,
|
||||||
|
{
|
||||||
|
query: {
|
||||||
|
group: this.group,
|
||||||
|
kind: this.kind,
|
||||||
|
name: this.name,
|
||||||
|
page: this.comments.page,
|
||||||
|
size: this.configMapData?.basic.size || 20,
|
||||||
|
version: this.version,
|
||||||
|
withReplies: this.configMapData?.basic.withReplies || false,
|
||||||
|
replySize: this.configMapData?.basic.replySize || 10,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
this.comments = data;
|
||||||
|
} catch (_error) {
|
||||||
|
console.error(_error);
|
||||||
|
this.toastManager?.error(
|
||||||
|
msg('Failed to load comment list, please try again later')
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
this.loading = false;
|
||||||
|
|
||||||
|
if (scrollIntoView) {
|
||||||
|
this.scrollIntoView({
|
||||||
|
block: 'start',
|
||||||
|
inline: 'start',
|
||||||
|
behavior: 'smooth',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override render() {
|
||||||
|
return html`${
|
||||||
|
this.loading
|
||||||
|
? html` <loading-block></loading-block>`
|
||||||
|
: html`
|
||||||
|
<div class="comment-list-main mt-5">
|
||||||
|
<div class="comment-stats font-medium my-3 text-sm text-text-1">
|
||||||
|
<span>${msg(html`${this.comments.total} Comments`)}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="comment-list">
|
||||||
|
${repeat(
|
||||||
|
this.comments.items,
|
||||||
|
(item) => item.metadata.name,
|
||||||
|
(item) => html` <comment-item .comment=${item}></comment-item>`
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`
|
||||||
|
}
|
||||||
|
${when(
|
||||||
|
this.shouldDisplayPagination,
|
||||||
|
() => html`<comment-pagination
|
||||||
|
.total=${this.comments.total}
|
||||||
|
.page=${this.comments.page}
|
||||||
|
.size=${this.comments.size}
|
||||||
|
@page-change=${this.onPageChange}
|
||||||
|
></comment-pagination>
|
||||||
|
`
|
||||||
|
)}
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
static override styles = [
|
||||||
|
...baseStyles,
|
||||||
|
css`
|
||||||
|
@unocss-placeholder;
|
||||||
|
`,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
customElements.get('comment-list') ||
|
||||||
|
customElements.define('comment-list', CommentList);
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
interface HTMLElementTagNameMap {
|
||||||
|
'comment-list': CommentList;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
import { msg } from '@lit/localize';
|
||||||
|
import { css, html, LitElement } from 'lit';
|
||||||
|
import { property } from 'lit/decorators.js';
|
||||||
|
import baseStyles from './styles/base';
|
||||||
|
|
||||||
|
export class CommentPagination extends LitElement {
|
||||||
|
@property({ type: Number })
|
||||||
|
total = 0;
|
||||||
|
@property({ type: Number })
|
||||||
|
page = 1;
|
||||||
|
@property({ type: Number })
|
||||||
|
size = 10;
|
||||||
|
|
||||||
|
get totalPages() {
|
||||||
|
return Math.ceil(this.total / this.size);
|
||||||
|
}
|
||||||
|
|
||||||
|
gotoPage(page: number | string) {
|
||||||
|
if (page !== this.page) {
|
||||||
|
this.dispatchEvent(
|
||||||
|
new CustomEvent('page-change', {
|
||||||
|
detail: { page },
|
||||||
|
bubbles: true,
|
||||||
|
composed: true,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override render() {
|
||||||
|
return html`
|
||||||
|
<ul class="pagination flex items-center gap-4" role="navigation">
|
||||||
|
<li>
|
||||||
|
<button
|
||||||
|
rel="prev"
|
||||||
|
type="button"
|
||||||
|
aria-label=${msg('Previous')}
|
||||||
|
@click=${() => this.gotoPage(this.page - 1)} ?disabled=${this.page === 1}
|
||||||
|
class="pagination-button"
|
||||||
|
>
|
||||||
|
<i class="i-tabler:chevron-left size-4" aria-hidden="true"></i>
|
||||||
|
${msg('Previous')}
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
<li class="inline-flex items-center gap-1 text-sm text-text-3 hover:text-text-1 transition-all">
|
||||||
|
<select name="pagination-value" id="pagination-value" class="pagination-select appearance-none outline-none bg-transparent" @change=${(e: Event) => this.gotoPage((e.target as HTMLSelectElement).value)}>
|
||||||
|
${Array.from({ length: this.totalPages }, (_, i) => i + 1).map((page) => html`<option .selected=${page === this.page} value=${page}>${page} / ${this.totalPages}</option>`)}
|
||||||
|
</select>
|
||||||
|
<i class="i-tabler:chevron-down size-4" aria-hidden="true"></i>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<button
|
||||||
|
rel="next"
|
||||||
|
type="button"
|
||||||
|
aria-label=${msg('Next')}
|
||||||
|
@click=${() => this.gotoPage(this.page + 1)}
|
||||||
|
?disabled=${this.page === this.totalPages}
|
||||||
|
class="pagination-button"
|
||||||
|
>
|
||||||
|
${msg('Next')}
|
||||||
|
<i class="i-tabler:chevron-right size-4" aria-hidden="true"></i>
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
static override styles = [
|
||||||
|
...baseStyles,
|
||||||
|
css`
|
||||||
|
:host {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
@unocss-placeholder;
|
||||||
|
`,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
customElements.get('comment-pagination') ||
|
||||||
|
customElements.define('comment-pagination', CommentPagination);
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
interface HTMLElementTagNameMap {
|
||||||
|
'comment-pagination': CommentPagination;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,170 @@
|
|||||||
|
import type { CommentVo, ReplyVo, ReplyVoList } from '@halo-dev/api-client';
|
||||||
|
import { consume } from '@lit/context';
|
||||||
|
import { css, html, LitElement } from 'lit';
|
||||||
|
import { property, state } from 'lit/decorators.js';
|
||||||
|
import { repeat } from 'lit/directives/repeat.js';
|
||||||
|
import { baseUrlContext, configMapDataContext, toastContext } from './context';
|
||||||
|
import './reply-item';
|
||||||
|
import './loading-block';
|
||||||
|
import './reply-form';
|
||||||
|
import { msg } from '@lit/localize';
|
||||||
|
import { when } from 'lit/directives/when.js';
|
||||||
|
import { ofetch } from 'ofetch';
|
||||||
|
import type { ToastManager } from './lit-toast';
|
||||||
|
import baseStyles from './styles/base';
|
||||||
|
import type { ConfigMapData } from './types';
|
||||||
|
|
||||||
|
export class CommentReplies extends LitElement {
|
||||||
|
@consume({ context: baseUrlContext })
|
||||||
|
@state()
|
||||||
|
baseUrl = '';
|
||||||
|
|
||||||
|
@consume({ context: configMapDataContext })
|
||||||
|
@state()
|
||||||
|
configMapData: ConfigMapData | undefined;
|
||||||
|
|
||||||
|
@property({ type: Object })
|
||||||
|
comment: CommentVo | undefined;
|
||||||
|
|
||||||
|
@property({ type: Boolean })
|
||||||
|
showReplyForm = false;
|
||||||
|
|
||||||
|
@state()
|
||||||
|
replies: ReplyVo[] = [];
|
||||||
|
|
||||||
|
@state()
|
||||||
|
page = 1;
|
||||||
|
|
||||||
|
@state()
|
||||||
|
hasNext = false;
|
||||||
|
|
||||||
|
@state()
|
||||||
|
loading = false;
|
||||||
|
|
||||||
|
@state()
|
||||||
|
activeQuoteReply: ReplyVo | undefined = undefined;
|
||||||
|
|
||||||
|
@consume({ context: toastContext, subscribe: true })
|
||||||
|
@state()
|
||||||
|
toastManager: ToastManager | undefined;
|
||||||
|
|
||||||
|
override render() {
|
||||||
|
return html` <div class="replies-main">
|
||||||
|
${when(
|
||||||
|
this.replies.length,
|
||||||
|
() => html`<div class="replies-list mt-3">
|
||||||
|
${repeat(
|
||||||
|
this.replies,
|
||||||
|
(item) => item.metadata.name,
|
||||||
|
(item) =>
|
||||||
|
html`<reply-item
|
||||||
|
.comment=${this.comment}
|
||||||
|
.reply="${item}"
|
||||||
|
.replies=${this.replies}
|
||||||
|
.activeQuoteReply=${this.activeQuoteReply}
|
||||||
|
@set-active-quote-reply=${this.onSetActiveQuoteReply}
|
||||||
|
@reload=${this.fetchReplies}
|
||||||
|
></reply-item>`
|
||||||
|
)}
|
||||||
|
</div>`
|
||||||
|
)}
|
||||||
|
${when(this.loading, () => html` <loading-block></loading-block>`)}
|
||||||
|
${when(
|
||||||
|
this.hasNext && !this.loading,
|
||||||
|
() => html`<div class="replies-next flex justify-center my-2">
|
||||||
|
<button class="replies-next-button pagination-button" @click=${this.fetchNext}>${msg('Load more')}</button>
|
||||||
|
</div>`
|
||||||
|
)}
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
onSetActiveQuoteReply(event: CustomEvent) {
|
||||||
|
this.activeQuoteReply = event.detail.quoteReply;
|
||||||
|
}
|
||||||
|
|
||||||
|
async fetchReplies(options?: { append: boolean }) {
|
||||||
|
try {
|
||||||
|
this.loading = true;
|
||||||
|
|
||||||
|
// Reload replies list
|
||||||
|
if (!options?.append) {
|
||||||
|
this.page = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await ofetch<ReplyVoList>(
|
||||||
|
`${this.baseUrl}/apis/api.halo.run/v1alpha1/comments/${
|
||||||
|
this.comment?.metadata.name
|
||||||
|
}/reply`,
|
||||||
|
{
|
||||||
|
query: {
|
||||||
|
page: this.page || 1,
|
||||||
|
size: this.configMapData?.basic.replySize || 10,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
if (options?.append) {
|
||||||
|
this.replies = this.replies.concat(data.items);
|
||||||
|
} else {
|
||||||
|
this.replies = data.items;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.hasNext = data.hasNext;
|
||||||
|
this.page = data.page;
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
this.toastManager?.error(
|
||||||
|
msg('Failed to load reply list, please try again later')
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
this.loading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fetchNext() {
|
||||||
|
if (this.configMapData?.basic.withReplies) {
|
||||||
|
// if withReplies is true, we need to reload the replies list
|
||||||
|
await this.fetchReplies({ append: !(this.page === 1) });
|
||||||
|
this.page++;
|
||||||
|
} else {
|
||||||
|
this.page++;
|
||||||
|
await this.fetchReplies({ append: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override connectedCallback(): void {
|
||||||
|
super.connectedCallback();
|
||||||
|
|
||||||
|
if (this.configMapData?.basic.withReplies) {
|
||||||
|
// TODO: Fix ts error
|
||||||
|
// Needs @halo-dev/api-client@2.14.0
|
||||||
|
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||||
|
// @ts-expect-error
|
||||||
|
this.replies = this.comment?.replies.items;
|
||||||
|
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||||
|
// @ts-expect-error
|
||||||
|
this.page = this.comment?.replies.page;
|
||||||
|
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||||
|
// @ts-expect-error
|
||||||
|
this.hasNext = this.comment?.replies.hasNext;
|
||||||
|
} else {
|
||||||
|
this.fetchReplies();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static override styles = [
|
||||||
|
...baseStyles,
|
||||||
|
css`
|
||||||
|
@unocss-placeholder;
|
||||||
|
`,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
customElements.get('comment-replies') ||
|
||||||
|
customElements.define('comment-replies', CommentReplies);
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
interface HTMLElementTagNameMap {
|
||||||
|
'comment-replies': CommentReplies;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,180 @@
|
|||||||
|
import type { DetailedUser, User } from '@halo-dev/api-client';
|
||||||
|
import { provide } from '@lit/context';
|
||||||
|
import { css, html, LitElement } from 'lit';
|
||||||
|
import { property, state } from 'lit/decorators.js';
|
||||||
|
import {
|
||||||
|
AllUserPolicy,
|
||||||
|
AnonymousUserPolicy,
|
||||||
|
AvatarPolicyEnum,
|
||||||
|
NoAvatarUserPolicy,
|
||||||
|
setPolicyInstance,
|
||||||
|
} from './avatar/avatar-policy';
|
||||||
|
import { setAvatarProvider } from './avatar/providers';
|
||||||
|
import './comment-form';
|
||||||
|
import './comment-item';
|
||||||
|
import './comment-pagination';
|
||||||
|
import {
|
||||||
|
allowAnonymousCommentsContext,
|
||||||
|
baseUrlContext,
|
||||||
|
configMapDataContext,
|
||||||
|
currentUserContext,
|
||||||
|
groupContext,
|
||||||
|
kindContext,
|
||||||
|
nameContext,
|
||||||
|
toastContext,
|
||||||
|
versionContext,
|
||||||
|
} from './context';
|
||||||
|
import { ToastManager } from './lit-toast';
|
||||||
|
import baseStyles from './styles/base';
|
||||||
|
import type { ConfigMapData } from './types';
|
||||||
|
import './comment-list';
|
||||||
|
import { ofetch } from 'ofetch';
|
||||||
|
import './comment-editor-skeleton';
|
||||||
|
|
||||||
|
export class CommentWidget extends LitElement {
|
||||||
|
@provide({ context: baseUrlContext })
|
||||||
|
@property({ type: String, attribute: 'base-url' })
|
||||||
|
baseUrl = '';
|
||||||
|
|
||||||
|
@provide({ context: kindContext })
|
||||||
|
@property({ type: String })
|
||||||
|
kind = '';
|
||||||
|
|
||||||
|
@provide({ context: groupContext })
|
||||||
|
@property({ type: String })
|
||||||
|
group = '';
|
||||||
|
|
||||||
|
@provide({ context: versionContext })
|
||||||
|
@property({ type: String })
|
||||||
|
version = '';
|
||||||
|
|
||||||
|
@provide({ context: nameContext })
|
||||||
|
@property({ type: String })
|
||||||
|
name = '';
|
||||||
|
|
||||||
|
@provide({ context: currentUserContext })
|
||||||
|
@state()
|
||||||
|
currentUser: User | undefined;
|
||||||
|
|
||||||
|
@provide({ context: allowAnonymousCommentsContext })
|
||||||
|
@state()
|
||||||
|
allowAnonymousComments = false;
|
||||||
|
|
||||||
|
@provide({ context: configMapDataContext })
|
||||||
|
@state()
|
||||||
|
configMapData: ConfigMapData | undefined;
|
||||||
|
|
||||||
|
@provide({ context: toastContext })
|
||||||
|
@state()
|
||||||
|
toastManager: ToastManager | undefined;
|
||||||
|
|
||||||
|
@state()
|
||||||
|
isInitialized = false;
|
||||||
|
|
||||||
|
override render() {
|
||||||
|
return html` <div class="comment-widget w-full">
|
||||||
|
${
|
||||||
|
!this.isInitialized
|
||||||
|
? html`<comment-editor-skeleton></comment-editor-skeleton>`
|
||||||
|
: html`
|
||||||
|
<comment-form></comment-form>
|
||||||
|
<comment-list></comment-list>
|
||||||
|
`
|
||||||
|
}
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async fetchGlobalInfo() {
|
||||||
|
const data = await ofetch(`${this.baseUrl}/actuator/globalinfo`);
|
||||||
|
this.allowAnonymousComments = data.allowAnonymousComments;
|
||||||
|
}
|
||||||
|
|
||||||
|
async fetchConfigMapData() {
|
||||||
|
const data = await ofetch<ConfigMapData>(
|
||||||
|
`${this.baseUrl}/apis/api.commentwidget.halo.run/v1alpha1/config`
|
||||||
|
);
|
||||||
|
this.configMapData = data;
|
||||||
|
}
|
||||||
|
|
||||||
|
async fetchCurrentUser() {
|
||||||
|
const data = await ofetch<DetailedUser>(
|
||||||
|
`${this.baseUrl}/apis/api.console.halo.run/v1alpha1/users/-`
|
||||||
|
);
|
||||||
|
this.currentUser =
|
||||||
|
data.user.metadata.name === 'anonymousUser' ? undefined : data.user;
|
||||||
|
}
|
||||||
|
|
||||||
|
initAvatarProvider() {
|
||||||
|
if (!this.configMapData?.avatar.enable) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setAvatarProvider(
|
||||||
|
this.configMapData.avatar.provider,
|
||||||
|
this.configMapData.avatar.providerMirror
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
initAvatarPolicy() {
|
||||||
|
if (!this.configMapData?.avatar.enable) {
|
||||||
|
setPolicyInstance(undefined);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
switch (this.configMapData.avatar.policy) {
|
||||||
|
case AvatarPolicyEnum.ALL_USER_POLICY: {
|
||||||
|
setPolicyInstance(new AllUserPolicy());
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case AvatarPolicyEnum.NO_AVATAR_USER_POLICY: {
|
||||||
|
setPolicyInstance(new NoAvatarUserPolicy());
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
setPolicyInstance(new AnonymousUserPolicy());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override connectedCallback(): void {
|
||||||
|
super.connectedCallback();
|
||||||
|
this.init();
|
||||||
|
}
|
||||||
|
|
||||||
|
async init() {
|
||||||
|
this.toastManager = new ToastManager();
|
||||||
|
try {
|
||||||
|
await this.fetchGlobalInfo();
|
||||||
|
await this.fetchConfigMapData();
|
||||||
|
await this.fetchCurrentUser();
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof Error) {
|
||||||
|
this.toastManager?.show(error.message, 'error');
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
} finally {
|
||||||
|
this.isInitialized = true;
|
||||||
|
}
|
||||||
|
this.initAvatarProvider();
|
||||||
|
this.initAvatarPolicy();
|
||||||
|
}
|
||||||
|
|
||||||
|
static override styles = [
|
||||||
|
...baseStyles,
|
||||||
|
css`
|
||||||
|
:host {
|
||||||
|
width: 100%;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
@unocss-placeholder;
|
||||||
|
`,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
customElements.get('comment-widget') ||
|
||||||
|
customElements.define('comment-widget', CommentWidget);
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
interface HTMLElementTagNameMap {
|
||||||
|
'comment-widget': CommentWidget;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,134 @@
|
|||||||
|
import { css, html, LitElement } from 'lit';
|
||||||
|
import { property, state } from 'lit/decorators.js';
|
||||||
|
import { when } from 'lit/directives/when.js';
|
||||||
|
import type { UAParser } from 'ua-parser-js';
|
||||||
|
import baseStyles from './styles/base';
|
||||||
|
|
||||||
|
const OS_ICON_MAP = {
|
||||||
|
Windows: 'i-logos:microsoft-windows-icon',
|
||||||
|
macOS: 'i-logos:apple',
|
||||||
|
Linux: 'i-logos:linux-tux',
|
||||||
|
Android: 'i-logos:android-icon',
|
||||||
|
iOS: 'i-logos:apple',
|
||||||
|
'Chrome OS': 'i-logos:chrome',
|
||||||
|
Arch: 'i-logos:archlinux',
|
||||||
|
Manjaro: 'i-logos:manjaro',
|
||||||
|
Ubuntu: 'i-logos:ubuntu',
|
||||||
|
Fedora: 'i-logos:fedora',
|
||||||
|
HarmonyOS: 'i-simple-icons:harmonyos',
|
||||||
|
};
|
||||||
|
|
||||||
|
const BROWSER_ICON_MAP = {
|
||||||
|
Chrome: 'i-logos:chrome',
|
||||||
|
'Mobile Chrome': 'i-logos:chrome',
|
||||||
|
'Chrome WebView': 'i-logos:chrome',
|
||||||
|
Firefox: 'i-logos:firefox',
|
||||||
|
'Mobile Firefox': 'i-logos:firefox',
|
||||||
|
Safari: 'i-logos:safari',
|
||||||
|
'Mobile Safari': 'i-logos:safari',
|
||||||
|
Edge: 'i-logos:microsoft-edge',
|
||||||
|
'Edge WebView': 'i-logos:microsoft-edge',
|
||||||
|
'Edge WebView2': 'i-logos:microsoft-edge',
|
||||||
|
Opera: 'i-logos:opera',
|
||||||
|
'Huawei Browser': 'i-simple-icons:huawei text-[#D6000B]',
|
||||||
|
'MIUI Browser': 'i-simple-icons:xiaomi text-[#FF6901]',
|
||||||
|
};
|
||||||
|
|
||||||
|
export class CommenterUABar extends LitElement {
|
||||||
|
@property({ type: String })
|
||||||
|
ua: string = '';
|
||||||
|
|
||||||
|
@state()
|
||||||
|
parser: UAParser | undefined;
|
||||||
|
|
||||||
|
@state()
|
||||||
|
isInitialized = false;
|
||||||
|
|
||||||
|
getOSIcon(os?: string) {
|
||||||
|
return OS_ICON_MAP[os as keyof typeof OS_ICON_MAP];
|
||||||
|
}
|
||||||
|
|
||||||
|
getBrowserIcon(browser?: string) {
|
||||||
|
return BROWSER_ICON_MAP[browser as keyof typeof BROWSER_ICON_MAP];
|
||||||
|
}
|
||||||
|
|
||||||
|
override connectedCallback(): void {
|
||||||
|
super.connectedCallback();
|
||||||
|
this.init();
|
||||||
|
}
|
||||||
|
|
||||||
|
async init() {
|
||||||
|
const { UAParser } = await import('ua-parser-js');
|
||||||
|
this.parser = new UAParser(this.ua);
|
||||||
|
this.isInitialized = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override render() {
|
||||||
|
if (!this.parser) {
|
||||||
|
return this.renderSkeleton();
|
||||||
|
}
|
||||||
|
|
||||||
|
const os = this.parser.getOS();
|
||||||
|
const browser = this.parser.getBrowser();
|
||||||
|
|
||||||
|
if (!os.name && !browser.name) {
|
||||||
|
return html`
|
||||||
|
<div class="inline-flex items-center gap-1 bg-muted-3 rounded-base px-1.5 py-1">
|
||||||
|
<span class="text-xs text-text-3">${this.ua}</span>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const osIcon = this.getOSIcon(os?.name);
|
||||||
|
const browserIcon = this.getBrowserIcon(browser?.name);
|
||||||
|
|
||||||
|
return html`
|
||||||
|
<div class="inline-flex gap-2 items-center">
|
||||||
|
${when(
|
||||||
|
os,
|
||||||
|
() => html`<div class="inline-flex items-center gap-1 bg-muted-3 rounded-base px-1.5 py-1">
|
||||||
|
${when(osIcon, () => html`<i class="${osIcon} opacity-90 size-3" aria-hidden="true"></i>`)}
|
||||||
|
<span class="text-xs text-text-2">${os.name}</span>
|
||||||
|
</div>`
|
||||||
|
)}
|
||||||
|
${when(
|
||||||
|
browser,
|
||||||
|
() => html`<div class="inline-flex items-center gap-1 bg-muted-3 rounded-base px-1.5 py-1">
|
||||||
|
${when(browserIcon, () => html`<i class="${browserIcon} opacity-90 size-3" aria-hidden="true"></i>`)}
|
||||||
|
<span class="text-xs text-text-2">${[browser.name, browser.major].filter(Boolean).join(' ')}</span>
|
||||||
|
</div>`
|
||||||
|
)}
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private renderSkeleton() {
|
||||||
|
return html`
|
||||||
|
<div class="inline-flex gap-2 items-center">
|
||||||
|
<div class="inline-flex items-center gap-1 bg-muted-3 rounded-base px-1.5 py-1">
|
||||||
|
<div class="size-3 animate-pulse bg-muted-1 rounded-base"></div>
|
||||||
|
<span class="w-10 h-3 animate-pulse bg-muted-1 rounded-base"></span>
|
||||||
|
</div>
|
||||||
|
<div class="inline-flex items-center gap-1 bg-muted-3 rounded-base px-1.5 py-1">
|
||||||
|
<div class="size-3 animate-pulse bg-muted-1 rounded-base"></div>
|
||||||
|
<span class="w-10 h-3 animate-pulse bg-muted-1 rounded-base"></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
static override styles = [
|
||||||
|
...baseStyles,
|
||||||
|
css`
|
||||||
|
@unocss-placeholder;
|
||||||
|
`,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
customElements.get('commenter-ua-bar') ||
|
||||||
|
customElements.define('commenter-ua-bar', CommenterUABar);
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
interface HTMLElementTagNameMap {
|
||||||
|
'commenter-ua-bar': CommenterUABar;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
/**
|
||||||
|
* Upvoted comments and replies local storage keys
|
||||||
|
*/
|
||||||
|
export const LS_UPVOTED_COMMENTS_KEY = 'halo.upvoted.comments';
|
||||||
|
export const LS_UPVOTED_REPLIES_KEY = 'halo.upvoted.replies';
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import type { User } from '@halo-dev/api-client';
|
||||||
|
import { createContext } from '@lit/context';
|
||||||
|
import type { ToastManager } from '../lit-toast';
|
||||||
|
import type { ConfigMapData } from '../types';
|
||||||
|
|
||||||
|
export const baseUrlContext = createContext<string>(Symbol('baseUrl'));
|
||||||
|
export const kindContext = createContext<string>(Symbol('kind'));
|
||||||
|
export const groupContext = createContext<string>(Symbol('group'));
|
||||||
|
export const nameContext = createContext<string>(Symbol('name'));
|
||||||
|
export const versionContext = createContext<string>(Symbol('version'));
|
||||||
|
|
||||||
|
export const allowAnonymousCommentsContext = createContext<boolean>(
|
||||||
|
Symbol('allowAnonymousComments')
|
||||||
|
);
|
||||||
|
|
||||||
|
export const currentUserContext = createContext<User | undefined>(
|
||||||
|
Symbol('currentUser')
|
||||||
|
);
|
||||||
|
|
||||||
|
export const toastContext = createContext<ToastManager | undefined>(
|
||||||
|
Symbol('toastContext')
|
||||||
|
);
|
||||||
|
|
||||||
|
export const configMapDataContext = createContext<ConfigMapData | undefined>(
|
||||||
|
Symbol('configMapData')
|
||||||
|
);
|
||||||
@@ -0,0 +1,287 @@
|
|||||||
|
import './icons/icon-loading';
|
||||||
|
import en from '@emoji-mart/data/i18n/en.json';
|
||||||
|
import es from '@emoji-mart/data/i18n/es.json';
|
||||||
|
import zh from '@emoji-mart/data/i18n/zh.json';
|
||||||
|
import {
|
||||||
|
autoUpdate,
|
||||||
|
computePosition,
|
||||||
|
flip,
|
||||||
|
offset,
|
||||||
|
type Placement,
|
||||||
|
shift,
|
||||||
|
} from '@floating-ui/dom';
|
||||||
|
import { msg } from '@lit/localize';
|
||||||
|
import type { Picker } from 'emoji-mart';
|
||||||
|
import { css, html, LitElement } from 'lit';
|
||||||
|
import { state } from 'lit/decorators.js';
|
||||||
|
import { createRef, type Ref, ref } from 'lit/directives/ref.js';
|
||||||
|
import { getLocale } from './locale';
|
||||||
|
import baseStyles from './styles/base';
|
||||||
|
|
||||||
|
const localeMap = {
|
||||||
|
'zh-CN': zh,
|
||||||
|
'zh-TW': zh,
|
||||||
|
en: en,
|
||||||
|
es: es,
|
||||||
|
};
|
||||||
|
|
||||||
|
const sharedEmojiPanel = {
|
||||||
|
wrapper: null as HTMLDivElement | null,
|
||||||
|
picker: null as Picker | null,
|
||||||
|
activeButton: null as EmojiButton | null,
|
||||||
|
|
||||||
|
init() {
|
||||||
|
if (this.wrapper) return this.wrapper;
|
||||||
|
|
||||||
|
const styleElement = document.createElement('style');
|
||||||
|
styleElement.textContent = `
|
||||||
|
.form__emoji-panel {
|
||||||
|
box-shadow: 0 0.5em 1em rgba(0, 0, 0, 0.1);
|
||||||
|
border-radius: 0.875em;
|
||||||
|
overflow: hidden;
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form__emoji-panel.visible {
|
||||||
|
display: block;
|
||||||
|
animation: fadeInUp 0.3s both;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes fadeInUp {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translate3d(0, 5%, 0);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translate3d(0, 0, 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
em-emoji-picker {
|
||||||
|
--rgb-color: var(--halo-cw-emoji-picker-rgb-color, 34, 36, 39);
|
||||||
|
--rgb-accent: var(--halo-cw-emoji-picker-rgb-accent, 34, 102, 237);
|
||||||
|
--rgb-background: var(--halo-cw-emoji-picker-rgb-background, 255, 255, 255);
|
||||||
|
--rgb-input: var(--halo-cw-emoji-picker-rgb-input, 255, 255, 255);
|
||||||
|
--color-border: var(--halo-cw-emoji-picker-color-border, rgba(0, 0, 0, .05));
|
||||||
|
--color-border-over: var(--halo-cw-emoji-picker-color-border-over, rgba(0, 0, 0, .1));
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
document.head.appendChild(styleElement);
|
||||||
|
|
||||||
|
this.wrapper = document.createElement('div');
|
||||||
|
this.wrapper.className = 'form__emoji-panel';
|
||||||
|
this.wrapper.style.position = 'absolute';
|
||||||
|
this.wrapper.style.zIndex = '9999';
|
||||||
|
document.body.appendChild(this.wrapper);
|
||||||
|
|
||||||
|
return this.wrapper;
|
||||||
|
},
|
||||||
|
|
||||||
|
show() {
|
||||||
|
if (this.wrapper) {
|
||||||
|
this.wrapper.classList.add('visible');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
hide() {
|
||||||
|
if (this.wrapper) {
|
||||||
|
this.wrapper.classList.remove('visible');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export class EmojiButton extends LitElement {
|
||||||
|
@state()
|
||||||
|
emojiPickerVisible = false;
|
||||||
|
|
||||||
|
@state()
|
||||||
|
emojiLoading = false;
|
||||||
|
|
||||||
|
buttonRef: Ref<HTMLDivElement> = createRef<HTMLDivElement>();
|
||||||
|
|
||||||
|
cleanupAutoUpdate: (() => void) | null = null;
|
||||||
|
|
||||||
|
placement: Placement = 'bottom';
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
super();
|
||||||
|
this.emojiPickerVisible = false;
|
||||||
|
this.handleClickOutside = this.handleClickOutside.bind(this);
|
||||||
|
}
|
||||||
|
|
||||||
|
override connectedCallback() {
|
||||||
|
super.connectedCallback();
|
||||||
|
document.addEventListener('click', this.handleClickOutside, true);
|
||||||
|
sharedEmojiPanel.init();
|
||||||
|
}
|
||||||
|
|
||||||
|
override disconnectedCallback() {
|
||||||
|
document.removeEventListener('click', this.handleClickOutside, true);
|
||||||
|
this.cleanupFloating();
|
||||||
|
if (sharedEmojiPanel.activeButton === this) {
|
||||||
|
sharedEmojiPanel.activeButton = null;
|
||||||
|
sharedEmojiPanel.hide();
|
||||||
|
}
|
||||||
|
super.disconnectedCallback();
|
||||||
|
}
|
||||||
|
|
||||||
|
cleanupFloating() {
|
||||||
|
if (this.cleanupAutoUpdate) {
|
||||||
|
this.cleanupAutoUpdate();
|
||||||
|
this.cleanupAutoUpdate = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
handleClickOutside(event: Event) {
|
||||||
|
if (
|
||||||
|
this.emojiPickerVisible &&
|
||||||
|
!event.composedPath().includes(this) &&
|
||||||
|
sharedEmojiPanel.wrapper &&
|
||||||
|
!event.composedPath().includes(sharedEmojiPanel.wrapper)
|
||||||
|
) {
|
||||||
|
this.closeEmojiPicker();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
closeEmojiPicker() {
|
||||||
|
this.emojiPickerVisible = false;
|
||||||
|
this.cleanupFloating();
|
||||||
|
sharedEmojiPanel.hide();
|
||||||
|
if (sharedEmojiPanel.activeButton === this) {
|
||||||
|
sharedEmojiPanel.activeButton = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async handleOpenEmojiPicker(e: Event) {
|
||||||
|
e.stopPropagation();
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
if (this.emojiPickerVisible) {
|
||||||
|
this.closeEmojiPicker();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
sharedEmojiPanel.activeButton &&
|
||||||
|
sharedEmojiPanel.activeButton !== this
|
||||||
|
) {
|
||||||
|
sharedEmojiPanel.activeButton.closeEmojiPicker();
|
||||||
|
}
|
||||||
|
|
||||||
|
sharedEmojiPanel.activeButton = this;
|
||||||
|
|
||||||
|
if (sharedEmojiPanel.wrapper?.children.length) {
|
||||||
|
this.emojiPickerVisible = true;
|
||||||
|
sharedEmojiPanel.show();
|
||||||
|
this.setupFloating();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.emojiLoading = true;
|
||||||
|
|
||||||
|
const { Picker } = await import('emoji-mart');
|
||||||
|
const { default: data } = await import('@emoji-mart/data');
|
||||||
|
|
||||||
|
sharedEmojiPanel.picker = new Picker({
|
||||||
|
data,
|
||||||
|
onEmojiSelect: ({ native }: { native: string }) => {
|
||||||
|
const activeButton = sharedEmojiPanel.activeButton;
|
||||||
|
if (activeButton) {
|
||||||
|
activeButton.dispatchEvent(
|
||||||
|
new CustomEvent('emoji-select', { detail: { native } })
|
||||||
|
);
|
||||||
|
activeButton.closeEmojiPicker();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
i18n: localeMap[getLocale()],
|
||||||
|
});
|
||||||
|
|
||||||
|
if (sharedEmojiPanel.wrapper) {
|
||||||
|
sharedEmojiPanel.wrapper.appendChild(
|
||||||
|
sharedEmojiPanel.picker as unknown as Node
|
||||||
|
);
|
||||||
|
sharedEmojiPanel.show();
|
||||||
|
}
|
||||||
|
|
||||||
|
this.emojiPickerVisible = true;
|
||||||
|
this.emojiLoading = false;
|
||||||
|
this.setupFloating();
|
||||||
|
}
|
||||||
|
|
||||||
|
setupFloating() {
|
||||||
|
if (!this.buttonRef.value || !sharedEmojiPanel.wrapper) return;
|
||||||
|
|
||||||
|
this.cleanupFloating();
|
||||||
|
|
||||||
|
this.cleanupAutoUpdate = autoUpdate(
|
||||||
|
this.buttonRef.value,
|
||||||
|
sharedEmojiPanel.wrapper,
|
||||||
|
() => this.updatePosition()
|
||||||
|
);
|
||||||
|
|
||||||
|
this.updatePosition();
|
||||||
|
}
|
||||||
|
|
||||||
|
async updatePosition() {
|
||||||
|
const reference = this.buttonRef.value;
|
||||||
|
const floating = sharedEmojiPanel.wrapper;
|
||||||
|
|
||||||
|
if (!reference || !floating) return;
|
||||||
|
|
||||||
|
const { x, y } = await computePosition(reference, floating, {
|
||||||
|
placement: this.placement,
|
||||||
|
middleware: [
|
||||||
|
offset({
|
||||||
|
mainAxis: 8,
|
||||||
|
crossAxis: 0,
|
||||||
|
alignmentAxis: 0,
|
||||||
|
}),
|
||||||
|
flip(),
|
||||||
|
shift({ padding: 8 }),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
Object.assign(floating.style, {
|
||||||
|
left: `${x}px`,
|
||||||
|
top: `${y}px`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
override render() {
|
||||||
|
return html`<div
|
||||||
|
role="button"
|
||||||
|
class="relative size-7 flex items-center justify-center cursor-pointer"
|
||||||
|
aria-label=${msg('Select emoticon')}
|
||||||
|
${ref(this.buttonRef)}
|
||||||
|
>
|
||||||
|
${
|
||||||
|
this.emojiLoading
|
||||||
|
? html`<icon-loading></icon-loading>`
|
||||||
|
: html`<div
|
||||||
|
class="i-mdi-sticker-emoji size-5 text-text-3 hover:text-text-1"
|
||||||
|
@click=${this.handleOpenEmojiPicker}
|
||||||
|
></div>`
|
||||||
|
}
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
static override styles = [
|
||||||
|
...baseStyles,
|
||||||
|
css`
|
||||||
|
:host {
|
||||||
|
display: inline-flex;
|
||||||
|
}
|
||||||
|
@unocss-placeholder;
|
||||||
|
`,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
customElements.get('emoji-button') ||
|
||||||
|
customElements.define('emoji-button', EmojiButton);
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
interface HTMLElementTagNameMap {
|
||||||
|
'emoji-button': EmojiButton;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
// Do not modify this file by hand!
|
||||||
|
// Re-generate this file by running lit-localize.
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The locale code that templates in this source code are written in.
|
||||||
|
*/
|
||||||
|
export const sourceLocale = `en`;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The other locale codes that this application is localized into. Sorted
|
||||||
|
* lexicographically.
|
||||||
|
*/
|
||||||
|
export const targetLocales = [
|
||||||
|
`es`,
|
||||||
|
`zh-CN`,
|
||||||
|
`zh-TW`,
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* All valid project locale codes. Sorted lexicographically.
|
||||||
|
*/
|
||||||
|
export const allLocales = [
|
||||||
|
`en`,
|
||||||
|
`es`,
|
||||||
|
`zh-CN`,
|
||||||
|
`zh-TW`,
|
||||||
|
] as const;
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
|
||||||
|
// Do not modify this file by hand!
|
||||||
|
// Re-generate this file by running lit-localize
|
||||||
|
|
||||||
|
import {html} from 'lit';
|
||||||
|
|
||||||
|
|
||||||
|
/* eslint-disable no-irregular-whitespace */
|
||||||
|
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||||
|
|
||||||
|
export const templates = {
|
||||||
|
'h6a83761bb66bbcc5': html`${0} Comentarios`,
|
||||||
|
's053945ad43e9d1d6': `Por favor, ingrese el código de verificación`,
|
||||||
|
's09c7e0ff5f4e18cb': `Cancelar respuesta`,
|
||||||
|
's0b7ae9543c001867': `Responder`,
|
||||||
|
's0fbf6dc6a1966408': `Siguiente`,
|
||||||
|
's1c6fefb092506753': `Error al cargar la lista de comentarios, por favor intente más tarde`,
|
||||||
|
's1e3e30a26025484c': `Error al cargar la lista de respuestas, por favor intente más tarde`,
|
||||||
|
's26e4d65f2801ac9c': `Por favor, ingrese el contenido`,
|
||||||
|
's299b10f3a58a09fd': `Haga clic en OK para ir a la página de cierre de sesión, Por favor, asegúrese de que el contenido editado haya sido guardado.`,
|
||||||
|
's35c3125c7750681e': `Cita`,
|
||||||
|
's3643189d1abbb7f4': `Código`,
|
||||||
|
's3fb33d17bad61aa9': `Comentario enviado con éxito, pendiente de revisión`,
|
||||||
|
's4c0e15f9073382e6': `Error al obtener el código de verificación`,
|
||||||
|
's5184a3f3e2f7b603': `Actualmente anónimo. Después de seleccionar la opción privada, el comentario solo será visible para el administrador del sitio.`,
|
||||||
|
's523eb9043213ff0d': `Itálica`,
|
||||||
|
's58a3c1ecd4dd06cf': `Tachado`,
|
||||||
|
's67749057edb2586b': `Cerrar sesión`,
|
||||||
|
's6cb61eeccda272d5': `Bloque de código`,
|
||||||
|
's7437373e541a8037': `Apodo`,
|
||||||
|
's7584ded3d749c75e': `Cargar más`,
|
||||||
|
's82665b2ffabc9c0a': `Sitio web`,
|
||||||
|
's838e512973be01d4': `Actualmente conectado. Después de seleccionar la opción privada, los comentarios solo serán visibles para usted y el administrador del sitio.`,
|
||||||
|
's84b033b2f7360187': `Por favor, inicie sesión o complete la información primero`,
|
||||||
|
's98a5f7789c49dd3f': `En revisión`,
|
||||||
|
's9f2ed66340f019c6': `Escribir un comentario`,
|
||||||
|
'sa10fbe3fcd6ae148': `Agregar respuesta`,
|
||||||
|
'sa82fbb7f361c6f44': `Mostrar respuestas`,
|
||||||
|
'sa8dddacbaa66f8e0': `Por favor, inicie sesión primero`,
|
||||||
|
'sb206d700d26b14ff': `Subrayado`,
|
||||||
|
'sb3d4f79d9d8b71e5': `Enviar comentario`,
|
||||||
|
'sb5a28aa35006ff08': `Comentario enviado con éxito`,
|
||||||
|
'sc8da3cc71de63832': `Iniciar sesión`,
|
||||||
|
'sd1f44f1a8bc20e67': `Correo electrónico`,
|
||||||
|
'sd5e242ab9574958a': `Error al comentar, por favor intente más tarde`,
|
||||||
|
'se7bee6e9a9b5394c': `Íntimo`,
|
||||||
|
'sea7e567ed89dc0d7': `Seleccionar emoticono`,
|
||||||
|
'sf3ff78cc329d3528': `Anterior`,
|
||||||
|
'sf77128b082955d42': `(O iniciar sesión)`,
|
||||||
|
'sf7c0eba7c822e3d6': `Negrita`,
|
||||||
|
'sf9709983d4da2c54': `Me gusta`,
|
||||||
|
};
|
||||||
|
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
|
||||||
|
// Do not modify this file by hand!
|
||||||
|
// Re-generate this file by running lit-localize
|
||||||
|
|
||||||
|
import {html} from 'lit';
|
||||||
|
|
||||||
|
|
||||||
|
/* eslint-disable no-irregular-whitespace */
|
||||||
|
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||||
|
|
||||||
|
export const templates = {
|
||||||
|
'h6a83761bb66bbcc5': html`${0} 条评论`,
|
||||||
|
's053945ad43e9d1d6': `请输入验证码`,
|
||||||
|
's09c7e0ff5f4e18cb': `取消回复`,
|
||||||
|
's0b7ae9543c001867': `回复`,
|
||||||
|
's0fbf6dc6a1966408': `下一页`,
|
||||||
|
's1c6fefb092506753': `加载评论列表失败,请稍后重试`,
|
||||||
|
's1e3e30a26025484c': `加载回复列表失败,请稍后重试`,
|
||||||
|
's26e4d65f2801ac9c': `请输入内容`,
|
||||||
|
's299b10f3a58a09fd': `点击确定将跳转至退出登录页面,请确保正在编辑的内容已保存。`,
|
||||||
|
's35c3125c7750681e': `引用`,
|
||||||
|
's3643189d1abbb7f4': `代码`,
|
||||||
|
's3fb33d17bad61aa9': `评论成功,请等待审核`,
|
||||||
|
's4c0e15f9073382e6': `获取验证码失败`,
|
||||||
|
's5184a3f3e2f7b603': `当前是匿名状态,选择私密选项后,评论将仅对网站管理员可见。`,
|
||||||
|
's523eb9043213ff0d': `斜体`,
|
||||||
|
's58a3c1ecd4dd06cf': `删除线`,
|
||||||
|
's67749057edb2586b': `退出登录`,
|
||||||
|
's6cb61eeccda272d5': `代码块`,
|
||||||
|
's7437373e541a8037': `昵称`,
|
||||||
|
's7584ded3d749c75e': `加载更多`,
|
||||||
|
's82665b2ffabc9c0a': `网站`,
|
||||||
|
's838e512973be01d4': `当前已登录,选择私密选项后,评论将仅对您和网站管理员可见。`,
|
||||||
|
's84b033b2f7360187': `请先登录或者完善信息`,
|
||||||
|
's98a5f7789c49dd3f': `审核中`,
|
||||||
|
's9f2ed66340f019c6': `编写评论`,
|
||||||
|
'sa10fbe3fcd6ae148': `加入回复`,
|
||||||
|
'sa82fbb7f361c6f44': `显示回复`,
|
||||||
|
'sa8dddacbaa66f8e0': `请先登录`,
|
||||||
|
'sb206d700d26b14ff': `下划线`,
|
||||||
|
'sb3d4f79d9d8b71e5': `提交评论`,
|
||||||
|
'sb5a28aa35006ff08': `评论成功`,
|
||||||
|
'sc8da3cc71de63832': `登录`,
|
||||||
|
'sd1f44f1a8bc20e67': `电子邮件`,
|
||||||
|
'sd5e242ab9574958a': `评论失败,请稍后重试`,
|
||||||
|
'se7bee6e9a9b5394c': `私密`,
|
||||||
|
'sea7e567ed89dc0d7': `选择表情`,
|
||||||
|
'sf3ff78cc329d3528': `上一页`,
|
||||||
|
'sf77128b082955d42': `(或登录账号)`,
|
||||||
|
'sf7c0eba7c822e3d6': `粗体`,
|
||||||
|
'sf9709983d4da2c54': `点赞`,
|
||||||
|
};
|
||||||
|
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
|
||||||
|
// Do not modify this file by hand!
|
||||||
|
// Re-generate this file by running lit-localize
|
||||||
|
|
||||||
|
import {html} from 'lit';
|
||||||
|
|
||||||
|
|
||||||
|
/* eslint-disable no-irregular-whitespace */
|
||||||
|
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||||
|
|
||||||
|
export const templates = {
|
||||||
|
'h6a83761bb66bbcc5': html`${0} 則評論`,
|
||||||
|
's053945ad43e9d1d6': `請輸入驗證碼`,
|
||||||
|
's09c7e0ff5f4e18cb': `取消回覆`,
|
||||||
|
's0b7ae9543c001867': `回覆`,
|
||||||
|
's0fbf6dc6a1966408': `下一頁`,
|
||||||
|
's1c6fefb092506753': `載入評論列表失敗,請稍後重試`,
|
||||||
|
's1e3e30a26025484c': `載入回覆列表失敗,請稍後重試`,
|
||||||
|
's26e4d65f2801ac9c': `請輸入內容`,
|
||||||
|
's299b10f3a58a09fd': `点击确定将跳转至退出登录页面,请确保正在编辑的内容已保存。`,
|
||||||
|
's35c3125c7750681e': `引用`,
|
||||||
|
's3643189d1abbb7f4': `程式碼`,
|
||||||
|
's3fb33d17bad61aa9': `評論成功,請等待審核`,
|
||||||
|
's4c0e15f9073382e6': `獲取驗證碼失敗`,
|
||||||
|
's5184a3f3e2f7b603': `目前是匿名狀態,選擇私密選項後,評論將僅對網站管理員可見。`,
|
||||||
|
's523eb9043213ff0d': `斜體`,
|
||||||
|
's58a3c1ecd4dd06cf': `刪除線`,
|
||||||
|
's67749057edb2586b': `登出`,
|
||||||
|
's6cb61eeccda272d5': `程式碼區塊`,
|
||||||
|
's7437373e541a8037': `暱稱`,
|
||||||
|
's7584ded3d749c75e': `載入更多`,
|
||||||
|
's82665b2ffabc9c0a': `網站`,
|
||||||
|
's838e512973be01d4': `目前已登入,選擇私密選項後,評論將僅對您和網站管理員可見。`,
|
||||||
|
's84b033b2f7360187': `請先登入或者完善資訊`,
|
||||||
|
's98a5f7789c49dd3f': `審核中`,
|
||||||
|
's9f2ed66340f019c6': `撰寫評論`,
|
||||||
|
'sa10fbe3fcd6ae148': `新增回覆`,
|
||||||
|
'sa82fbb7f361c6f44': `顯示回覆`,
|
||||||
|
'sa8dddacbaa66f8e0': `請先登入`,
|
||||||
|
'sb206d700d26b14ff': `底線`,
|
||||||
|
'sb3d4f79d9d8b71e5': `提交評論`,
|
||||||
|
'sb5a28aa35006ff08': `評論成功`,
|
||||||
|
'sc8da3cc71de63832': `登入`,
|
||||||
|
'sd1f44f1a8bc20e67': `電子郵件`,
|
||||||
|
'sd5e242ab9574958a': `評論失敗,請稍後重試`,
|
||||||
|
'se7bee6e9a9b5394c': `私密`,
|
||||||
|
'sea7e567ed89dc0d7': `選擇表情`,
|
||||||
|
'sf3ff78cc329d3528': `上一頁`,
|
||||||
|
'sf77128b082955d42': `(或登入帳號)`,
|
||||||
|
'sf7c0eba7c822e3d6': `粗體`,
|
||||||
|
'sf9709983d4da2c54': `讚`,
|
||||||
|
};
|
||||||
|
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import { css, html, LitElement } from 'lit';
|
||||||
|
import baseStyles from '../styles/base';
|
||||||
|
|
||||||
|
export class IconLoading extends LitElement {
|
||||||
|
override render() {
|
||||||
|
return html`<svg class="size-5 animate-spin" fill="none" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<circle
|
||||||
|
style="opacity: 0.25;"
|
||||||
|
cx="12"
|
||||||
|
cy="12"
|
||||||
|
r="10"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="4"
|
||||||
|
></circle>
|
||||||
|
<path
|
||||||
|
style="opacity: 0.75;"
|
||||||
|
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
||||||
|
fill="currentColor"
|
||||||
|
></path>
|
||||||
|
</svg>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
static override styles = [
|
||||||
|
...baseStyles,
|
||||||
|
css`
|
||||||
|
:host {
|
||||||
|
display: inline-flex;
|
||||||
|
}
|
||||||
|
@unocss-placeholder;
|
||||||
|
`,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
customElements.get('icon-loading') ||
|
||||||
|
customElements.define('icon-loading', IconLoading);
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
interface HTMLElementTagNameMap {
|
||||||
|
'icon-loading': IconLoading;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import { BaseCommentItem } from './base-comment-item';
|
||||||
|
import { BaseForm } from './base-form';
|
||||||
|
import { CommentContent } from './comment-content';
|
||||||
|
import { CommentEditor } from './comment-editor';
|
||||||
|
import { CommentItem } from './comment-item';
|
||||||
|
import { CommentList } from './comment-list';
|
||||||
|
import { CommentPagination } from './comment-pagination';
|
||||||
|
import { CommentReplies } from './comment-replies';
|
||||||
|
import { CommentWidget } from './comment-widget';
|
||||||
|
import { LitToast } from './lit-toast';
|
||||||
|
import { LoadingBlock } from './loading-block';
|
||||||
|
import { ReplyForm } from './reply-form';
|
||||||
|
import { ReplyItem } from './reply-item';
|
||||||
|
import { UserAvatar } from './user-avatar';
|
||||||
|
|
||||||
|
import './locale';
|
||||||
|
export {
|
||||||
|
CommentWidget,
|
||||||
|
BaseForm,
|
||||||
|
BaseCommentItem,
|
||||||
|
CommentItem,
|
||||||
|
CommentPagination,
|
||||||
|
CommentReplies,
|
||||||
|
LoadingBlock,
|
||||||
|
ReplyForm,
|
||||||
|
ReplyItem,
|
||||||
|
UserAvatar,
|
||||||
|
CommentList,
|
||||||
|
LitToast,
|
||||||
|
CommentEditor,
|
||||||
|
CommentContent,
|
||||||
|
};
|
||||||
@@ -0,0 +1,172 @@
|
|||||||
|
import { css, html, LitElement } from 'lit';
|
||||||
|
import { property } from 'lit/decorators.js';
|
||||||
|
import './icons/icon-loading';
|
||||||
|
import { classMap } from 'lit/directives/class-map.js';
|
||||||
|
import baseStyles from './styles/base';
|
||||||
|
|
||||||
|
type ToastType = 'success' | 'error' | 'warn';
|
||||||
|
|
||||||
|
export class LitToast extends LitElement {
|
||||||
|
@property({ type: String })
|
||||||
|
message = '';
|
||||||
|
|
||||||
|
@property({ type: String })
|
||||||
|
type = 'success' as ToastType;
|
||||||
|
|
||||||
|
override render() {
|
||||||
|
return html`<div
|
||||||
|
class="toast ${classMap({
|
||||||
|
'toast--error': this.type === 'error',
|
||||||
|
'toast--success': this.type === 'success',
|
||||||
|
'toast--warn': this.type === 'warn',
|
||||||
|
})}"
|
||||||
|
>
|
||||||
|
${
|
||||||
|
this.type === 'success'
|
||||||
|
? html`<i class="i-tabler:circle-check size-4 text-white" aria-hidden="true"></i>`
|
||||||
|
: html`<i class="i-tabler:alert-circle size-4 text-white" aria-hidden="true"></i>`
|
||||||
|
} <span>${this.message}</span>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
static override styles = [
|
||||||
|
...baseStyles,
|
||||||
|
css`
|
||||||
|
.toast {
|
||||||
|
border-radius: var(--halo-cw-base-rounded, 0.5em);
|
||||||
|
font-size: 0.875em;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 0.5em 0.625em;
|
||||||
|
justify-content: space-between;
|
||||||
|
overflow: hidden;
|
||||||
|
color: #fff;
|
||||||
|
gap: 0.5em;
|
||||||
|
box-shadow:
|
||||||
|
0 0 #0000,
|
||||||
|
0 0 #0000,
|
||||||
|
0 1px 3px 0 rgb(0 0 0 / 0.1),
|
||||||
|
0 1px 2px -1px rgb(0 0 0 / 0.1);
|
||||||
|
|
||||||
|
animation: slideInDown 0.3s ease-out forwards;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toast--exit {
|
||||||
|
animation: slideOutUp 0.3s ease-in forwards;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toast--error {
|
||||||
|
background-color: #d71d1d;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toast--success {
|
||||||
|
background-color: #4ccba0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toast--warn {
|
||||||
|
background-color: #f5a623;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes slideInDown {
|
||||||
|
from {
|
||||||
|
transform: translateY(0);
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
transform: translateY(100%);
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes slideOutUp {
|
||||||
|
from {
|
||||||
|
transform: translateY(100%);
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
transform: translateY(0);
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@unocss-placeholder;
|
||||||
|
`,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
export class LitToastContainer extends LitElement {
|
||||||
|
override render() {
|
||||||
|
return html`<slot></slot>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
static override styles = [
|
||||||
|
...baseStyles,
|
||||||
|
css`
|
||||||
|
:host {
|
||||||
|
position: fixed;
|
||||||
|
top: 1em;
|
||||||
|
z-index: 1000;
|
||||||
|
display: flex;
|
||||||
|
width: 100%;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.5em;
|
||||||
|
}
|
||||||
|
`,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
customElements.get('lit-toast') || customElements.define('lit-toast', LitToast);
|
||||||
|
customElements.get('lit-toast-container') ||
|
||||||
|
customElements.define('lit-toast-container', LitToastContainer);
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
interface HTMLElementTagNameMap {
|
||||||
|
'lit-toast': LitToast;
|
||||||
|
'lit-toast-container': LitToastContainer;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ToastManager {
|
||||||
|
private body: HTMLBodyElement = document.body as HTMLBodyElement;
|
||||||
|
private toastContainer: LitToastContainer;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
const container = this.body.querySelector('lit-toast-container');
|
||||||
|
|
||||||
|
if (!container) {
|
||||||
|
this.toastContainer = new LitToastContainer();
|
||||||
|
this.body.appendChild(this.toastContainer);
|
||||||
|
} else {
|
||||||
|
this.toastContainer = container as LitToastContainer;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
show(message: string, type: ToastType) {
|
||||||
|
const toast = new LitToast();
|
||||||
|
toast.message = message;
|
||||||
|
toast.type = type;
|
||||||
|
this.toastContainer.appendChild(toast);
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
toast.classList.add('toast--exit');
|
||||||
|
setTimeout(() => {
|
||||||
|
this.toastContainer?.removeChild(toast);
|
||||||
|
}, 300);
|
||||||
|
}, 3000);
|
||||||
|
}
|
||||||
|
|
||||||
|
success(message: string) {
|
||||||
|
this.show(message, 'success');
|
||||||
|
}
|
||||||
|
|
||||||
|
error(message: string) {
|
||||||
|
this.show(message, 'error');
|
||||||
|
}
|
||||||
|
|
||||||
|
warn(message: string) {
|
||||||
|
this.show(message, 'warn');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import { css, html, LitElement } from 'lit';
|
||||||
|
import './icons/icon-loading';
|
||||||
|
|
||||||
|
export class LoadingBlock extends LitElement {
|
||||||
|
override render() {
|
||||||
|
return html` <div class="loading-block flex items-center justify-center p-4">
|
||||||
|
<icon-loading></icon-loading>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
static override styles = css`
|
||||||
|
@unocss-placeholder;
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
customElements.get('loading-block') ||
|
||||||
|
customElements.define('loading-block', LoadingBlock);
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
interface HTMLElementTagNameMap {
|
||||||
|
'loading-block': LoadingBlock;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import { configureLocalization, type LocaleModule } from '@lit/localize';
|
||||||
|
import {
|
||||||
|
allLocales,
|
||||||
|
sourceLocale,
|
||||||
|
targetLocales,
|
||||||
|
} from '../generated/locale-codes';
|
||||||
|
import * as templates_es from '../generated/locales/es';
|
||||||
|
import * as templates_zh_CN from '../generated/locales/zh-CN';
|
||||||
|
import * as templates_zh_TW from '../generated/locales/zh-TW';
|
||||||
|
|
||||||
|
const localizedTemplates = new Map([
|
||||||
|
['es', templates_es],
|
||||||
|
['zh-CN', templates_zh_CN],
|
||||||
|
['zh-TW', templates_zh_TW],
|
||||||
|
]);
|
||||||
|
|
||||||
|
const { setLocale } = configureLocalization({
|
||||||
|
sourceLocale,
|
||||||
|
targetLocales,
|
||||||
|
loadLocale: async (locale) => localizedTemplates.get(locale) as LocaleModule,
|
||||||
|
});
|
||||||
|
|
||||||
|
setLocale(getLocale());
|
||||||
|
|
||||||
|
function getLanguageFromCookie() {
|
||||||
|
const match = document.cookie.match(/(^| )language=([^;]+)/);
|
||||||
|
const matchedLanguage = allLocales.find((locale) => locale === match?.[2]);
|
||||||
|
return matchedLanguage;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getLanguageFromBrowser() {
|
||||||
|
const language = allLocales.find((locale) => locale === navigator.language);
|
||||||
|
return language;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getLocale() {
|
||||||
|
return getLanguageFromCookie() || getLanguageFromBrowser() || sourceLocale;
|
||||||
|
}
|
||||||
@@ -0,0 +1,188 @@
|
|||||||
|
import type {
|
||||||
|
CommentVo,
|
||||||
|
Reply,
|
||||||
|
ReplyRequest,
|
||||||
|
ReplyVo,
|
||||||
|
User,
|
||||||
|
} from '@halo-dev/api-client';
|
||||||
|
import { consume } from '@lit/context';
|
||||||
|
import { html, LitElement } from 'lit';
|
||||||
|
import { property, state } from 'lit/decorators.js';
|
||||||
|
import { createRef, type Ref, ref } from 'lit/directives/ref.js';
|
||||||
|
import './base-form';
|
||||||
|
import { msg } from '@lit/localize';
|
||||||
|
import { FetchError, type FetchResponse, ofetch } from 'ofetch';
|
||||||
|
import type { BaseForm } from './base-form';
|
||||||
|
import {
|
||||||
|
allowAnonymousCommentsContext,
|
||||||
|
baseUrlContext,
|
||||||
|
currentUserContext,
|
||||||
|
toastContext,
|
||||||
|
} from './context';
|
||||||
|
import type { ToastManager } from './lit-toast';
|
||||||
|
import type { ProblemDetail } from './types';
|
||||||
|
import {
|
||||||
|
type CaptchaRequiredResponse,
|
||||||
|
getCaptchaCodeHeader,
|
||||||
|
isRequireCaptcha,
|
||||||
|
} from './utils/captcha';
|
||||||
|
|
||||||
|
export class ReplyForm extends LitElement {
|
||||||
|
@consume({ context: baseUrlContext })
|
||||||
|
@state()
|
||||||
|
baseUrl = '';
|
||||||
|
|
||||||
|
@consume({ context: currentUserContext, subscribe: true })
|
||||||
|
@state()
|
||||||
|
currentUser: User | undefined;
|
||||||
|
|
||||||
|
@property({ type: Object })
|
||||||
|
comment: CommentVo | undefined;
|
||||||
|
|
||||||
|
@property({ type: Object })
|
||||||
|
quoteReply: ReplyVo | undefined;
|
||||||
|
|
||||||
|
@consume({ context: allowAnonymousCommentsContext, subscribe: true })
|
||||||
|
@state()
|
||||||
|
allowAnonymousComments = false;
|
||||||
|
|
||||||
|
@consume({ context: toastContext, subscribe: true })
|
||||||
|
@state()
|
||||||
|
toastManager: ToastManager | undefined;
|
||||||
|
|
||||||
|
@state()
|
||||||
|
submitting = false;
|
||||||
|
|
||||||
|
@state()
|
||||||
|
captcha = '';
|
||||||
|
|
||||||
|
baseFormRef: Ref<BaseForm> = createRef<BaseForm>();
|
||||||
|
|
||||||
|
override connectedCallback(): void {
|
||||||
|
super.connectedCallback();
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
this.scrollIntoView({
|
||||||
|
block: 'center',
|
||||||
|
inline: 'start',
|
||||||
|
behavior: 'smooth',
|
||||||
|
});
|
||||||
|
this.baseFormRef.value?.setFocus();
|
||||||
|
}, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
override render() {
|
||||||
|
return html` <base-form
|
||||||
|
.submitting=${this.submitting}
|
||||||
|
.captcha=${this.captcha}
|
||||||
|
.hidePrivateCheckbox=${true}
|
||||||
|
${ref(this.baseFormRef)}
|
||||||
|
@submit="${this.onSubmit}"
|
||||||
|
></base-form>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async onSubmit(e: CustomEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
this.submitting = true;
|
||||||
|
|
||||||
|
const data = e.detail;
|
||||||
|
|
||||||
|
const { displayName, email, website, content } = data || {};
|
||||||
|
|
||||||
|
const replyRequest: ReplyRequest = {
|
||||||
|
raw: content,
|
||||||
|
content: content,
|
||||||
|
// TODO: support user input
|
||||||
|
allowNotification: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (this.quoteReply) {
|
||||||
|
replyRequest.quoteReply = this.quoteReply.metadata.name;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!this.currentUser && !this.allowAnonymousComments) {
|
||||||
|
this.toastManager?.warn(msg('Please login first'));
|
||||||
|
this.submitting = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!this.currentUser && this.allowAnonymousComments) {
|
||||||
|
if (!displayName || !email) {
|
||||||
|
this.toastManager?.warn(
|
||||||
|
msg('Please log in or complete the information first')
|
||||||
|
);
|
||||||
|
this.submitting = false;
|
||||||
|
return;
|
||||||
|
} else {
|
||||||
|
replyRequest.owner = {
|
||||||
|
displayName: displayName,
|
||||||
|
email: email,
|
||||||
|
website: website,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const newReply = await ofetch<Reply>(
|
||||||
|
`${this.baseUrl}/apis/api.halo.run/v1alpha1/comments/${this.comment?.metadata.name}/reply`,
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
...getCaptchaCodeHeader(data.captchaCode),
|
||||||
|
},
|
||||||
|
body: replyRequest,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
this.baseFormRef.value?.handleFetchCaptcha();
|
||||||
|
|
||||||
|
if (newReply.spec.approved) {
|
||||||
|
this.toastManager?.success(msg('Comment submitted successfully'));
|
||||||
|
} else {
|
||||||
|
this.toastManager?.success(
|
||||||
|
msg('Comment submitted successfully, pending review')
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.dispatchEvent(new CustomEvent('reload'));
|
||||||
|
window.dispatchEvent(new CustomEvent('halo:comment-reply:created'));
|
||||||
|
|
||||||
|
this.baseFormRef.value?.resetForm();
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof FetchError) {
|
||||||
|
if (
|
||||||
|
isRequireCaptcha(
|
||||||
|
error.response as FetchResponse<CaptchaRequiredResponse>
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
const { captcha, detail } =
|
||||||
|
error.data as unknown as CaptchaRequiredResponse;
|
||||||
|
this.captcha = captcha;
|
||||||
|
this.toastManager?.warn(detail);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const problemDetail = error.data as unknown as ProblemDetail;
|
||||||
|
this.toastManager?.error(
|
||||||
|
[problemDetail?.title, problemDetail?.detail].join(' - ') ||
|
||||||
|
msg('Comment failed, please try again later')
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.toastManager?.error(msg('Comment failed, please try again later'));
|
||||||
|
} finally {
|
||||||
|
this.submitting = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
customElements.get('reply-form') ||
|
||||||
|
customElements.define('reply-form', ReplyForm);
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
interface HTMLElementTagNameMap {
|
||||||
|
'reply-form': ReplyForm;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,195 @@
|
|||||||
|
import type { CommentVo, ReplyVo } from '@halo-dev/api-client';
|
||||||
|
import { css, html, LitElement } from 'lit';
|
||||||
|
import { property, state } from 'lit/decorators.js';
|
||||||
|
import baseStyles from './styles/base';
|
||||||
|
import './user-avatar';
|
||||||
|
import './base-comment-item';
|
||||||
|
import './reply-form';
|
||||||
|
import { consume } from '@lit/context';
|
||||||
|
import { msg } from '@lit/localize';
|
||||||
|
import { when } from 'lit/directives/when.js';
|
||||||
|
import { ofetch } from 'ofetch';
|
||||||
|
import { getPolicyInstance } from './avatar/avatar-policy';
|
||||||
|
import { LS_UPVOTED_REPLIES_KEY } from './constant';
|
||||||
|
import { baseUrlContext } from './context';
|
||||||
|
|
||||||
|
export class ReplyItem extends LitElement {
|
||||||
|
@consume({ context: baseUrlContext })
|
||||||
|
@state()
|
||||||
|
baseUrl = '';
|
||||||
|
|
||||||
|
@property({ type: Object })
|
||||||
|
comment: CommentVo | undefined;
|
||||||
|
|
||||||
|
@property({ type: Object })
|
||||||
|
reply: ReplyVo | undefined;
|
||||||
|
|
||||||
|
@property({ type: Object })
|
||||||
|
activeQuoteReply: ReplyVo | undefined = undefined;
|
||||||
|
|
||||||
|
@property({ type: Array })
|
||||||
|
replies: ReplyVo[] = [];
|
||||||
|
|
||||||
|
@state()
|
||||||
|
showReplyForm = false;
|
||||||
|
|
||||||
|
@state()
|
||||||
|
upvoted = false;
|
||||||
|
|
||||||
|
@state()
|
||||||
|
upvoteCount = 0;
|
||||||
|
|
||||||
|
get quoteReply() {
|
||||||
|
const { quoteReply: quoteReplyName } = this.reply?.spec || {};
|
||||||
|
if (!quoteReplyName) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
return this.replies.find((reply) => reply.metadata.name === quoteReplyName);
|
||||||
|
}
|
||||||
|
|
||||||
|
get isQuoteReplyHovered() {
|
||||||
|
return this.activeQuoteReply?.metadata.name === this.reply?.metadata.name;
|
||||||
|
}
|
||||||
|
|
||||||
|
handleSetActiveQuoteReply(quoteReply?: ReplyVo) {
|
||||||
|
this.dispatchEvent(
|
||||||
|
new CustomEvent('set-active-quote-reply', {
|
||||||
|
detail: { quoteReply },
|
||||||
|
bubbles: true,
|
||||||
|
composed: true,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
override connectedCallback(): void {
|
||||||
|
super.connectedCallback();
|
||||||
|
this.checkUpvotedStatus();
|
||||||
|
|
||||||
|
this.upvoteCount = this.reply?.stats.upvote || 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
checkUpvotedStatus() {
|
||||||
|
const upvotedReplies = JSON.parse(
|
||||||
|
localStorage.getItem(LS_UPVOTED_REPLIES_KEY) || '[]'
|
||||||
|
);
|
||||||
|
|
||||||
|
if (upvotedReplies.includes(this.reply?.metadata.name)) {
|
||||||
|
this.upvoted = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
handleToggleReplyForm() {
|
||||||
|
this.showReplyForm = !this.showReplyForm;
|
||||||
|
}
|
||||||
|
|
||||||
|
async handleUpvote() {
|
||||||
|
const upvotedReplies = JSON.parse(
|
||||||
|
localStorage.getItem(LS_UPVOTED_REPLIES_KEY) || '[]'
|
||||||
|
);
|
||||||
|
|
||||||
|
if (upvotedReplies.includes(this.reply?.metadata.name)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const voteRequest = {
|
||||||
|
name: this.reply?.metadata.name,
|
||||||
|
plural: 'replies',
|
||||||
|
group: 'content.halo.run',
|
||||||
|
};
|
||||||
|
|
||||||
|
await ofetch(`${this.baseUrl}/apis/api.halo.run/v1alpha1/trackers/upvote`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: voteRequest,
|
||||||
|
});
|
||||||
|
|
||||||
|
upvotedReplies.push(this.reply?.metadata.name);
|
||||||
|
localStorage.setItem(
|
||||||
|
LS_UPVOTED_REPLIES_KEY,
|
||||||
|
JSON.stringify(upvotedReplies)
|
||||||
|
);
|
||||||
|
|
||||||
|
this.upvoteCount += 1;
|
||||||
|
this.upvoted = true;
|
||||||
|
this.checkUpvotedStatus();
|
||||||
|
}
|
||||||
|
|
||||||
|
override render() {
|
||||||
|
return html`
|
||||||
|
<base-comment-item
|
||||||
|
.userAvatar="${handleReplyAvatar(this.reply)}"
|
||||||
|
.userDisplayName="${this.reply?.owner.displayName}"
|
||||||
|
.content="${this.reply?.spec.content || ''}"
|
||||||
|
.creationTime="${this.reply?.metadata.creationTimestamp ?? undefined}"
|
||||||
|
.approved=${this.reply?.spec.approved}
|
||||||
|
.breath=${this.isQuoteReplyHovered}
|
||||||
|
.userWebsite=${this.reply?.spec.owner.annotations?.website}
|
||||||
|
.ua=${this.reply?.spec.userAgent}
|
||||||
|
.private=${this.comment?.spec.hidden || this.reply?.spec.hidden}
|
||||||
|
>
|
||||||
|
<button slot="action" class="icon-button group -ml-2" type="button" @click="${this.handleUpvote}" aria-label=${msg('Upvote')}>
|
||||||
|
<div class="icon-button-icon ">
|
||||||
|
${when(
|
||||||
|
this.upvoted,
|
||||||
|
() =>
|
||||||
|
html`<i slot="icon" class="i-mingcute-heart-fill size-4 text-red-500" aria-hidden="true"></i>`,
|
||||||
|
() =>
|
||||||
|
html`<i slot="icon" class="i-mingcute-heart-line size-4" aria-hidden="true"></i>`
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<span class="icon-button-text">${`${this.upvoteCount || 0}`}</span>
|
||||||
|
</button>
|
||||||
|
<button slot="action" class="icon-button group" type="button" @click="${this.handleToggleReplyForm}" aria-label=${this.showReplyForm ? msg('Cancel reply') : msg('Reply')}>
|
||||||
|
<div class="icon-button-icon ">
|
||||||
|
<i slot="icon" class="i-tabler:message-circle-plus size-4" aria-hidden="true"></i>
|
||||||
|
</div>
|
||||||
|
<span class="icon-button-text">${this.showReplyForm ? msg('Cancel reply') : msg('Reply')}</span>
|
||||||
|
</button>
|
||||||
|
${when(
|
||||||
|
this.showReplyForm,
|
||||||
|
() => html`<div class="reply-form mt-2" slot="footer">
|
||||||
|
<reply-form
|
||||||
|
.comment=${this.comment}
|
||||||
|
.quoteReply=${this.reply}
|
||||||
|
@reload=${() => this.dispatchEvent(new CustomEvent('reload'))}
|
||||||
|
></reply-form>
|
||||||
|
</div>`
|
||||||
|
)}
|
||||||
|
${when(
|
||||||
|
this.quoteReply,
|
||||||
|
() => html`<span
|
||||||
|
slot="pre-content"
|
||||||
|
@mouseenter=${() => this.handleSetActiveQuoteReply(this.quoteReply)}
|
||||||
|
@mouseleave=${() => this.handleSetActiveQuoteReply()}
|
||||||
|
class="quote-badge cursor-pointer inline-flex items-center gap-1 px-2 py-1.5 rounded-base bg-muted-3 text-text-2 hover:-translate-y-0.5 hover:text-text-1 hover:bg-muted-2 transition-all text-sm font-medium"
|
||||||
|
><i class="i-ic:round-reply" aria-hidden="true"></i><span>${this.quoteReply?.owner.displayName}</span>
|
||||||
|
</span>
|
||||||
|
<br slot="pre-content" />`
|
||||||
|
)}
|
||||||
|
</base-comment-item>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
static override styles = [
|
||||||
|
...baseStyles,
|
||||||
|
css`
|
||||||
|
@unocss-placeholder;
|
||||||
|
`,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
customElements.get('reply-item') ||
|
||||||
|
customElements.define('reply-item', ReplyItem);
|
||||||
|
|
||||||
|
function handleReplyAvatar(reply: ReplyVo | undefined): string | undefined {
|
||||||
|
const avatarPolicy = getPolicyInstance();
|
||||||
|
if (avatarPolicy === undefined) {
|
||||||
|
return reply?.owner.avatar;
|
||||||
|
}
|
||||||
|
return avatarPolicy.applyReplyPolicy(reply);
|
||||||
|
}
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
interface HTMLElementTagNameMap {
|
||||||
|
'reply-item': ReplyItem;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import resetStyles from '@unocss/reset/tailwind.css?inline';
|
||||||
|
import { css, unsafeCSS } from 'lit';
|
||||||
|
|
||||||
|
const baseStyles = [
|
||||||
|
unsafeCSS(resetStyles),
|
||||||
|
css`
|
||||||
|
:host {
|
||||||
|
font-size: var(--halo-cw-base-font-size, 1rem);
|
||||||
|
font-family: var(
|
||||||
|
--halo-cw-base-font-family,
|
||||||
|
ui-sans-serif,
|
||||||
|
system-ui,
|
||||||
|
-apple-system,
|
||||||
|
BlinkMacSystemFont,
|
||||||
|
Segoe UI,
|
||||||
|
Roboto,
|
||||||
|
Helvetica Neue,
|
||||||
|
Arial,
|
||||||
|
Noto Sans,
|
||||||
|
sans-serif,
|
||||||
|
'Apple Color Emoji',
|
||||||
|
'Segoe UI Emoji',
|
||||||
|
Segoe UI Symbol,
|
||||||
|
'Noto Color Emoji'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
`,
|
||||||
|
];
|
||||||
|
|
||||||
|
export default baseStyles;
|
||||||
@@ -0,0 +1,302 @@
|
|||||||
|
.content {
|
||||||
|
word-wrap: break-word;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
@apply text-text-1 text-sm leading-relaxed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content details,
|
||||||
|
.content figcaption,
|
||||||
|
.content figure {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content summary {
|
||||||
|
display: list-item;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content a {
|
||||||
|
background-color: transparent;
|
||||||
|
text-decoration: none;
|
||||||
|
@apply text-primary-1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content b,
|
||||||
|
.content strong {
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content mark {
|
||||||
|
background-color: #fff8c5;
|
||||||
|
@apply text-text-1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content sub,
|
||||||
|
.content sup {
|
||||||
|
font-size: 75%;
|
||||||
|
line-height: 0;
|
||||||
|
position: relative;
|
||||||
|
vertical-align: baseline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content sub {
|
||||||
|
bottom: -0.25em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content sup {
|
||||||
|
top: -0.5em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content img {
|
||||||
|
border-style: none;
|
||||||
|
max-width: 100%;
|
||||||
|
box-sizing: content-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content code,
|
||||||
|
.content pre {
|
||||||
|
font-family: monospace;
|
||||||
|
font-size: 1em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content figure {
|
||||||
|
margin: 1em 2.5em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content hr {
|
||||||
|
box-sizing: content-box;
|
||||||
|
overflow: hidden;
|
||||||
|
background: transparent;
|
||||||
|
height: 0.25em;
|
||||||
|
padding: 0;
|
||||||
|
margin: 1.4em 0;
|
||||||
|
border: 0;
|
||||||
|
@apply bg-muted-1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content a:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content hr::before {
|
||||||
|
display: table;
|
||||||
|
content: "";
|
||||||
|
}
|
||||||
|
|
||||||
|
.content hr::after {
|
||||||
|
display: table;
|
||||||
|
clear: both;
|
||||||
|
content: "";
|
||||||
|
}
|
||||||
|
|
||||||
|
.content details summary {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content a:focus {
|
||||||
|
outline: 2px solid;
|
||||||
|
outline-offset: -2px;
|
||||||
|
box-shadow: none;
|
||||||
|
@apply outline-primary-1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content a:focus:not(:focus-visible) {
|
||||||
|
outline: solid 1px transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content a:focus-visible {
|
||||||
|
outline: 2px solid;
|
||||||
|
outline-offset: -2px;
|
||||||
|
box-shadow: none;
|
||||||
|
@apply outline-primary-1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content a:not([class]):focus,
|
||||||
|
.content a:not([class]):focus-visible {
|
||||||
|
outline-offset: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content p {
|
||||||
|
margin-top: 0;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content blockquote {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0 1em;
|
||||||
|
border-left: 0.25em;
|
||||||
|
border-style: solid;
|
||||||
|
@apply text-text-1 border-muted-1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content ul,
|
||||||
|
.content ol {
|
||||||
|
margin-top: 0;
|
||||||
|
margin-bottom: 0;
|
||||||
|
padding-left: 2em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content ol ol,
|
||||||
|
.content ul ol {
|
||||||
|
list-style-type: lower-roman;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content ul ul ol,
|
||||||
|
.content ul ol ol,
|
||||||
|
.content ol ul ol,
|
||||||
|
.content ol ol ol {
|
||||||
|
list-style-type: lower-alpha;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content ul {
|
||||||
|
list-style-type: disc;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content ol {
|
||||||
|
list-style-type: decimal;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content code {
|
||||||
|
font-family:
|
||||||
|
ui-monospace,
|
||||||
|
SFMono-Regular,
|
||||||
|
SF Mono,
|
||||||
|
Menlo,
|
||||||
|
Consolas,
|
||||||
|
"Liberation Mono",
|
||||||
|
monospace;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content pre {
|
||||||
|
margin-top: 0;
|
||||||
|
margin-bottom: 0;
|
||||||
|
font-family:
|
||||||
|
ui-monospace,
|
||||||
|
SFMono-Regular,
|
||||||
|
SF Mono,
|
||||||
|
Menlo,
|
||||||
|
Consolas,
|
||||||
|
"Liberation Mono",
|
||||||
|
monospace;
|
||||||
|
font-size: 12px;
|
||||||
|
word-wrap: normal;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content::before {
|
||||||
|
display: table;
|
||||||
|
content: "";
|
||||||
|
}
|
||||||
|
|
||||||
|
.content::after {
|
||||||
|
display: table;
|
||||||
|
clear: both;
|
||||||
|
content: "";
|
||||||
|
}
|
||||||
|
|
||||||
|
.content > *:first-child {
|
||||||
|
margin-top: 0 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content > *:last-child {
|
||||||
|
margin-bottom: 0 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content a:not([href]) {
|
||||||
|
color: inherit;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content p,
|
||||||
|
.content blockquote,
|
||||||
|
.content ul,
|
||||||
|
.content ol,
|
||||||
|
.content dl,
|
||||||
|
.content pre,
|
||||||
|
.content details {
|
||||||
|
margin-top: 0;
|
||||||
|
margin-bottom: 1em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content blockquote > :first-child {
|
||||||
|
margin-top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content blockquote > :last-child {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content ul ul,
|
||||||
|
.content ul ol,
|
||||||
|
.content ol ol,
|
||||||
|
.content ol ul {
|
||||||
|
margin-top: 0;
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content li > p {
|
||||||
|
margin-top: 1em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content li + li {
|
||||||
|
margin-top: 0.25em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content code {
|
||||||
|
padding: 0.2em 0.4em;
|
||||||
|
margin: 0;
|
||||||
|
font-size: 85%;
|
||||||
|
white-space: break-spaces;
|
||||||
|
border-radius: 6px;
|
||||||
|
@apply bg-muted-3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content code br {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content del code {
|
||||||
|
text-decoration: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content pre code {
|
||||||
|
font-size: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content pre > code {
|
||||||
|
padding: 0;
|
||||||
|
margin: 0;
|
||||||
|
word-break: normal;
|
||||||
|
white-space: pre;
|
||||||
|
background: transparent;
|
||||||
|
border: 0;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content pre {
|
||||||
|
padding: 1em;
|
||||||
|
overflow: auto;
|
||||||
|
font-size: 85%;
|
||||||
|
border-radius: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content pre code {
|
||||||
|
display: inline;
|
||||||
|
max-width: auto;
|
||||||
|
padding: 0;
|
||||||
|
margin: 0;
|
||||||
|
overflow: visible;
|
||||||
|
line-height: inherit;
|
||||||
|
word-wrap: normal;
|
||||||
|
background-color: transparent;
|
||||||
|
border: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content summary:focus:not(:focus-visible),
|
||||||
|
.content a:focus:not(:focus-visible) {
|
||||||
|
outline: none;
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content > *:first-child > .heading-element:first-child {
|
||||||
|
margin-top: 0 !important;
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
export interface ConfigMapData {
|
||||||
|
basic: BasicConfig;
|
||||||
|
security: SecurityConfig;
|
||||||
|
avatar: AvatarConfig;
|
||||||
|
editor?: EditorConfig;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface BasicConfig {
|
||||||
|
withReplies: boolean;
|
||||||
|
size: number;
|
||||||
|
withReplySize: number;
|
||||||
|
replySize: number;
|
||||||
|
showCommenterDevice?: boolean;
|
||||||
|
enablePrivateComment?: boolean;
|
||||||
|
showPrivateCommentBadge?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SecurityConfig {
|
||||||
|
captcha: {
|
||||||
|
anonymousCommentCaptcha: boolean;
|
||||||
|
type: 'ALPHANUMERIC' | 'ARITHMETIC';
|
||||||
|
ignoreCase: boolean;
|
||||||
|
captchaLength: number;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AvatarConfig {
|
||||||
|
provider: 'gravatar';
|
||||||
|
enable: boolean;
|
||||||
|
providerMirror: string;
|
||||||
|
policy: 'anonymousUser' | 'allUser' | 'noAvatarUser';
|
||||||
|
}
|
||||||
|
|
||||||
|
interface EditorConfig {
|
||||||
|
placeholder?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProblemDetail {
|
||||||
|
type: string;
|
||||||
|
title: string;
|
||||||
|
status: number;
|
||||||
|
detail: string;
|
||||||
|
instance: string;
|
||||||
|
requestId: string;
|
||||||
|
timestamp: string;
|
||||||
|
}
|
||||||
@@ -0,0 +1,111 @@
|
|||||||
|
import './icons/icon-loading';
|
||||||
|
import { css, html, LitElement } from 'lit';
|
||||||
|
import { property, state } from 'lit/decorators.js';
|
||||||
|
import baseStyles from './styles/base';
|
||||||
|
|
||||||
|
export class UserAvatar extends LitElement {
|
||||||
|
@property({ type: String })
|
||||||
|
src: string | undefined;
|
||||||
|
|
||||||
|
@property({ type: String })
|
||||||
|
alt: string | undefined;
|
||||||
|
|
||||||
|
@property({ type: String })
|
||||||
|
href: string | undefined;
|
||||||
|
|
||||||
|
@state()
|
||||||
|
error = false;
|
||||||
|
|
||||||
|
@state()
|
||||||
|
loading = false;
|
||||||
|
|
||||||
|
override async connectedCallback() {
|
||||||
|
super.connectedCallback();
|
||||||
|
await this.loadImage();
|
||||||
|
}
|
||||||
|
|
||||||
|
async loadImage() {
|
||||||
|
if (!this.src) {
|
||||||
|
return Promise.resolve();
|
||||||
|
}
|
||||||
|
this.loading = true;
|
||||||
|
try {
|
||||||
|
await new Promise((resolve) => {
|
||||||
|
const img = new Image();
|
||||||
|
img.src = this.src || '';
|
||||||
|
img.onload = () => {
|
||||||
|
this.error = false;
|
||||||
|
resolve(null);
|
||||||
|
};
|
||||||
|
img.onerror = () => {
|
||||||
|
this.error = true;
|
||||||
|
resolve(null);
|
||||||
|
};
|
||||||
|
});
|
||||||
|
} catch (_e) {
|
||||||
|
this.error = true;
|
||||||
|
} finally {
|
||||||
|
this.loading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
getPlaceholderText() {
|
||||||
|
if (!this.alt) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
const words = this.alt.split(' ');
|
||||||
|
if (words.length === 1) {
|
||||||
|
return words[0].charAt(0).toUpperCase();
|
||||||
|
}
|
||||||
|
if (words.length > 1) {
|
||||||
|
return (
|
||||||
|
words[0].charAt(0).toUpperCase() + words[1].charAt(0).toUpperCase()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
override render() {
|
||||||
|
if (this.href) {
|
||||||
|
return html`<a class="avatar" href="${this.href}" target="_blank" rel="noopener noreferrer">
|
||||||
|
${this.renderAvatarContent()}
|
||||||
|
</a>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return html`<div class="avatar">
|
||||||
|
${this.renderAvatarContent()}
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
renderAvatarContent() {
|
||||||
|
if (!this.src) {
|
||||||
|
return html`<span class="avatar-placeholder text-sm font-medium text-text-1 select-none">${this.getPlaceholderText()}</span>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.loading) {
|
||||||
|
return html`<icon-loading></icon-loading>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.error) {
|
||||||
|
return html`<i class="i-tabler:alert-circle size-5" aria-hidden="true"></i>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return html`<img class="avatar-image size-full object-cover" src="${this.src}" alt="${this.alt || ''}" loading="lazy" />`;
|
||||||
|
}
|
||||||
|
|
||||||
|
static override styles = [
|
||||||
|
...baseStyles,
|
||||||
|
css`
|
||||||
|
@unocss-placeholder;
|
||||||
|
`,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
customElements.get('user-avatar') ||
|
||||||
|
customElements.define('user-avatar', UserAvatar);
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
interface HTMLElementTagNameMap {
|
||||||
|
'user-avatar': UserAvatar;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import type { FetchResponse } from 'ofetch';
|
||||||
|
|
||||||
|
export const getCaptchaCodeHeader = (code: string): Record<string, string> => {
|
||||||
|
if (!code || code.trim().length === 0) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
'X-Captcha-Code': code,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface CaptchaRequiredResponse {
|
||||||
|
type: string;
|
||||||
|
title: string;
|
||||||
|
status: number;
|
||||||
|
detail: string;
|
||||||
|
captcha: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const isRequireCaptcha = (
|
||||||
|
response: FetchResponse<CaptchaRequiredResponse>
|
||||||
|
) => {
|
||||||
|
return response.status === 403 && response.headers.get('X-Require-Captcha');
|
||||||
|
};
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import dayjs from 'dayjs';
|
||||||
|
import 'dayjs/locale/zh-cn';
|
||||||
|
import 'dayjs/locale/zh-tw';
|
||||||
|
import 'dayjs/locale/en';
|
||||||
|
import 'dayjs/locale/es';
|
||||||
|
import relativeTime from 'dayjs/plugin/relativeTime';
|
||||||
|
import timezone from 'dayjs/plugin/timezone';
|
||||||
|
import { getLocale } from '../locale';
|
||||||
|
|
||||||
|
const localeMap = {
|
||||||
|
'zh-CN': 'zh-cn',
|
||||||
|
'zh-TW': 'zh-tw',
|
||||||
|
en: 'en',
|
||||||
|
es: 'es',
|
||||||
|
};
|
||||||
|
|
||||||
|
dayjs.extend(timezone);
|
||||||
|
dayjs.extend(relativeTime);
|
||||||
|
|
||||||
|
dayjs.locale(localeMap[getLocale()]);
|
||||||
|
|
||||||
|
export function formatDate(date: string | Date | undefined | null): string {
|
||||||
|
if (!date) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
return dayjs(date).format('YYYY-MM-DD HH:mm');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function timeAgo(date: string | Date | undefined | null): string {
|
||||||
|
if (!date) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentDate = new Date();
|
||||||
|
|
||||||
|
if (currentDate.getFullYear() - new Date(date).getFullYear() > 0) {
|
||||||
|
return formatDate(new Date(date));
|
||||||
|
}
|
||||||
|
|
||||||
|
return dayjs().to(dayjs(date));
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import sanitizeHtml from 'sanitize-html';
|
||||||
|
export function cleanHtml(content?: string) {
|
||||||
|
if (!content) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
return sanitizeHtml(content, {
|
||||||
|
allowedAttributes: {
|
||||||
|
...sanitizeHtml.defaults.allowedAttributes,
|
||||||
|
code: ['class'],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
+1
@@ -0,0 +1 @@
|
|||||||
|
/// <reference types="vite/client" />
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "es2021",
|
||||||
|
"module": "es2020",
|
||||||
|
"lib": ["es2021", "DOM", "DOM.Iterable"],
|
||||||
|
"declaration": true,
|
||||||
|
"declarationMap": true,
|
||||||
|
"sourceMap": true,
|
||||||
|
"inlineSources": true,
|
||||||
|
"outDir": "./dist",
|
||||||
|
"rootDir": "./src",
|
||||||
|
"strict": true,
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"noUnusedParameters": true,
|
||||||
|
"noImplicitReturns": true,
|
||||||
|
"noFallthroughCasesInSwitch": true,
|
||||||
|
"noImplicitAny": true,
|
||||||
|
"noImplicitThis": true,
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowSyntheticDefaultImports": true,
|
||||||
|
"experimentalDecorators": true,
|
||||||
|
"forceConsistentCasingInFileNames": true,
|
||||||
|
"noImplicitOverride": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"plugins": [
|
||||||
|
{
|
||||||
|
"name": "ts-lit-plugin",
|
||||||
|
"strict": true
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"include": ["src/**/*.ts"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
import {
|
||||||
|
defineConfig,
|
||||||
|
definePreset,
|
||||||
|
presetIcons,
|
||||||
|
presetWind3,
|
||||||
|
transformerDirectives,
|
||||||
|
} from 'unocss';
|
||||||
|
|
||||||
|
const remRE = /(-?[.\d]+)rem/g;
|
||||||
|
|
||||||
|
const presetRemToEm = definePreset(() => {
|
||||||
|
return {
|
||||||
|
name: '@unocss/preset-rem-to-em',
|
||||||
|
postprocess: (util) => {
|
||||||
|
util.entries.forEach((i) => {
|
||||||
|
const value = i[1];
|
||||||
|
if (typeof value === 'string' && remRE.test(value))
|
||||||
|
i[1] = value.replace(remRE, (_, p1) => `${p1}em`);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
presets: [presetWind3(), presetIcons(), presetRemToEm()],
|
||||||
|
theme: {
|
||||||
|
colors: {
|
||||||
|
primary: {
|
||||||
|
1: 'var(--halo-cw-primary-1-color, #4CCBA0)',
|
||||||
|
2: 'var(--halo-cw-primary-2-color, #6EE7B7)',
|
||||||
|
3: 'var(--halo-cw-primary-3-color, #99F6E4)',
|
||||||
|
},
|
||||||
|
text: {
|
||||||
|
1: 'var(--halo-cw-text-1-color, #0f172a)',
|
||||||
|
2: 'var(--halo-cw-text-2-color, #1e293b)',
|
||||||
|
3: 'var(--halo-cw-text-3-color, #475569)',
|
||||||
|
},
|
||||||
|
muted: {
|
||||||
|
1: 'var(--halo-cw-muted-1-color, #cbd5e1)',
|
||||||
|
2: 'var(--halo-cw-muted-2-color, #e2e8f0)',
|
||||||
|
3: 'var(--halo-cw-muted-3-color, #f1f5f9)',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
borderRadius: {
|
||||||
|
base: 'var(--halo-cw-base-rounded, 0.5em)',
|
||||||
|
avatar: 'var(--halo-cw-avatar-rounded, 9999px)',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
rules: [
|
||||||
|
[
|
||||||
|
'shadow-input',
|
||||||
|
{
|
||||||
|
'box-shadow': '0 0 0 3px var(--halo-cw-primary-3-color, #99F6E4)',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
],
|
||||||
|
shortcuts: {
|
||||||
|
input:
|
||||||
|
'px-3 py-0 text-sm rounded-base bg-transparent border h-12 border-muted-1 border-solid outline-none transition-all focus:border-primary-1 focus:shadow-input placeholder:text-text-3',
|
||||||
|
'icon-button': 'inline-flex items-center gap-[0.1em] cursor-pointer',
|
||||||
|
'icon-button-icon':
|
||||||
|
'inline-flex items-center justify-center rounded-full transition-all duration-150 p-2 text-text-3 group-hover:bg-muted-3 group-hover:text-text-1',
|
||||||
|
'icon-button-text':
|
||||||
|
'select-none text-xs text-text-3 group-hover:text-text-1',
|
||||||
|
'pagination-button':
|
||||||
|
'inline-flex h-10 items-center text-sm gap-1 hover:bg-muted-3 rounded-base px-3 transition-all text-text-1 opacity-80 disabled:!opacity-70 disabled:cursor-not-allowed hover:opacity-100 font-medium justify-center',
|
||||||
|
avatar:
|
||||||
|
'rounded-avatar size-[var(--halo-cw-avatar-size,2.25em)] overflow-hidden inline-flex items-center justify-center bg-muted-2',
|
||||||
|
},
|
||||||
|
transformers: [transformerDirectives()],
|
||||||
|
});
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
.color-scheme-auto,
|
||||||
|
[data-color-scheme="auto"] {
|
||||||
|
color-scheme: dark;
|
||||||
|
|
||||||
|
--halo-cw-primary-1-color: #059669;
|
||||||
|
--halo-cw-primary-2-color: #047857;
|
||||||
|
--halo-cw-primary-3-color: #065f46;
|
||||||
|
|
||||||
|
--halo-cw-text-1-color: #f9fafb;
|
||||||
|
--halo-cw-text-2-color: #e5e7eb;
|
||||||
|
--halo-cw-text-3-color: #9ca3af;
|
||||||
|
|
||||||
|
--halo-cw-muted-1-color: #4b5563;
|
||||||
|
--halo-cw-muted-2-color: #374151;
|
||||||
|
--halo-cw-muted-3-color: #1f2937;
|
||||||
|
|
||||||
|
--halo-cw-emoji-picker-rgb-color: 222, 222, 221;
|
||||||
|
--halo-cw-emoji-picker-rgb-accent: 58, 130, 247;
|
||||||
|
--halo-cw-emoji-picker-rgb-background: 21, 22, 23;
|
||||||
|
--halo-cw-emoji-picker-rgb-input: 0, 0, 0;
|
||||||
|
--halo-cw-emoji-picker-color-border: rgba(255, 255, 255, 0.1);
|
||||||
|
--halo-cw-emoji-picker-color-border-over: rgba(255, 255, 255, 0.2);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.color-scheme-dark,
|
||||||
|
.dark,
|
||||||
|
[data-color-scheme="dark"] {
|
||||||
|
color-scheme: dark;
|
||||||
|
|
||||||
|
--halo-cw-primary-1-color: #059669;
|
||||||
|
--halo-cw-primary-2-color: #047857;
|
||||||
|
--halo-cw-primary-3-color: #065f46;
|
||||||
|
|
||||||
|
--halo-cw-text-1-color: #f9fafb;
|
||||||
|
--halo-cw-text-2-color: #e2e8f0;
|
||||||
|
--halo-cw-text-3-color: #94a3b8;
|
||||||
|
|
||||||
|
--halo-cw-muted-1-color: #64748b;
|
||||||
|
--halo-cw-muted-2-color: #475569;
|
||||||
|
--halo-cw-muted-3-color: #334155;
|
||||||
|
|
||||||
|
--halo-cw-emoji-picker-rgb-color: 222, 222, 221;
|
||||||
|
--halo-cw-emoji-picker-rgb-accent: 58, 130, 247;
|
||||||
|
--halo-cw-emoji-picker-rgb-background: 21, 22, 23;
|
||||||
|
--halo-cw-emoji-picker-rgb-input: 0, 0, 0;
|
||||||
|
--halo-cw-emoji-picker-color-border: rgba(255, 255, 255, 0.1);
|
||||||
|
--halo-cw-emoji-picker-color-border-over: rgba(255, 255, 255, 0.2);
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import UnoCSS from 'unocss/vite';
|
||||||
|
import { defineConfig, type Plugin } from 'vite';
|
||||||
|
import dts from 'vite-plugin-dts';
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
experimental: {
|
||||||
|
enableNativePlugin: true,
|
||||||
|
},
|
||||||
|
plugins: [
|
||||||
|
dts() as Plugin,
|
||||||
|
UnoCSS({
|
||||||
|
mode: 'shadow-dom',
|
||||||
|
configFile: './uno.config.ts',
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
build: {
|
||||||
|
lib: {
|
||||||
|
entry: ['src/index.ts'],
|
||||||
|
formats: ['es'],
|
||||||
|
fileName: 'index',
|
||||||
|
},
|
||||||
|
minify: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -0,0 +1,167 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<xliff version="1.2" xmlns="urn:oasis:names:tc:xliff:document:1.2">
|
||||||
|
<file target-language="es" source-language="en" original="lit-localize-inputs" datatype="plaintext">
|
||||||
|
<body>
|
||||||
|
<trans-unit id="s67749057edb2586b">
|
||||||
|
<source>Logout</source>
|
||||||
|
<target>Cerrar sesión</target>
|
||||||
|
</trans-unit>
|
||||||
|
<trans-unit id="s9f2ed66340f019c6">
|
||||||
|
<source>Write a comment</source>
|
||||||
|
<target>Escribir un comentario</target>
|
||||||
|
</trans-unit>
|
||||||
|
<trans-unit id="s7437373e541a8037">
|
||||||
|
<source>Nicename</source>
|
||||||
|
<target>Apodo</target>
|
||||||
|
</trans-unit>
|
||||||
|
<trans-unit id="sd1f44f1a8bc20e67">
|
||||||
|
<source>Email</source>
|
||||||
|
<target>Correo electrónico</target>
|
||||||
|
</trans-unit>
|
||||||
|
<trans-unit id="s82665b2ffabc9c0a">
|
||||||
|
<source>Website</source>
|
||||||
|
<target>Sitio web</target>
|
||||||
|
</trans-unit>
|
||||||
|
<trans-unit id="sc8da3cc71de63832">
|
||||||
|
<source>Login</source>
|
||||||
|
<target>Iniciar sesión</target>
|
||||||
|
</trans-unit>
|
||||||
|
<trans-unit id="sb3d4f79d9d8b71e5">
|
||||||
|
<source>Submit</source>
|
||||||
|
<target>Enviar comentario</target>
|
||||||
|
</trans-unit>
|
||||||
|
<trans-unit id="s09c7e0ff5f4e18cb">
|
||||||
|
<source>Cancel reply</source>
|
||||||
|
<target>Cancelar respuesta</target>
|
||||||
|
</trans-unit>
|
||||||
|
<trans-unit id="s0b7ae9543c001867">
|
||||||
|
<source>Reply</source>
|
||||||
|
<target>Responder</target>
|
||||||
|
</trans-unit>
|
||||||
|
<trans-unit id="sf3ff78cc329d3528">
|
||||||
|
<source>Previous</source>
|
||||||
|
<target>Anterior</target>
|
||||||
|
</trans-unit>
|
||||||
|
<trans-unit id="s0fbf6dc6a1966408">
|
||||||
|
<source>Next</source>
|
||||||
|
<target>Siguiente</target>
|
||||||
|
</trans-unit>
|
||||||
|
<trans-unit id="sf77128b082955d42">
|
||||||
|
<source>(Or login)</source>
|
||||||
|
<target>(O iniciar sesión)</target>
|
||||||
|
</trans-unit>
|
||||||
|
<trans-unit id="sa8dddacbaa66f8e0">
|
||||||
|
<source>Please login first</source>
|
||||||
|
<target>Por favor, inicie sesión primero</target>
|
||||||
|
</trans-unit>
|
||||||
|
<trans-unit id="s7584ded3d749c75e">
|
||||||
|
<source>Load more</source>
|
||||||
|
<target>Cargar más</target>
|
||||||
|
</trans-unit>
|
||||||
|
<trans-unit id="h6a83761bb66bbcc5">
|
||||||
|
<source><x id="0" equiv-text="${this.comments.total}"/> Comments</source>
|
||||||
|
<target><x id="0" equiv-text="${this.comments.total}"/> Comentarios</target>
|
||||||
|
</trans-unit>
|
||||||
|
<trans-unit id="s98a5f7789c49dd3f">
|
||||||
|
<source>Reviewing</source>
|
||||||
|
<target>En revisión</target>
|
||||||
|
</trans-unit>
|
||||||
|
<trans-unit id="sea7e567ed89dc0d7">
|
||||||
|
<source>Select emoticon</source>
|
||||||
|
<target>Seleccionar emoticono</target>
|
||||||
|
</trans-unit>
|
||||||
|
<trans-unit id="s4c0e15f9073382e6">
|
||||||
|
<source>Failed to obtain verification code</source>
|
||||||
|
<target>Error al obtener el código de verificación</target>
|
||||||
|
</trans-unit>
|
||||||
|
<trans-unit id="s053945ad43e9d1d6">
|
||||||
|
<source>Please enter the verification code</source>
|
||||||
|
<target>Por favor, ingrese el código de verificación</target>
|
||||||
|
</trans-unit>
|
||||||
|
<trans-unit id="s84b033b2f7360187">
|
||||||
|
<source>Please log in or complete the information first</source>
|
||||||
|
<target>Por favor, inicie sesión o complete la información primero</target>
|
||||||
|
</trans-unit>
|
||||||
|
<trans-unit id="sd5e242ab9574958a">
|
||||||
|
<source>Comment failed, please try again later</source>
|
||||||
|
<target>Error al comentar, por favor intente más tarde</target>
|
||||||
|
</trans-unit>
|
||||||
|
<trans-unit id="sb5a28aa35006ff08">
|
||||||
|
<source>Comment submitted successfully</source>
|
||||||
|
<target>Comentario enviado con éxito</target>
|
||||||
|
</trans-unit>
|
||||||
|
<trans-unit id="s3fb33d17bad61aa9">
|
||||||
|
<source>Comment submitted successfully, pending review</source>
|
||||||
|
<target>Comentario enviado con éxito, pendiente de revisión</target>
|
||||||
|
</trans-unit>
|
||||||
|
<trans-unit id="sa10fbe3fcd6ae148">
|
||||||
|
<source>Add reply</source>
|
||||||
|
<target>Agregar respuesta</target>
|
||||||
|
</trans-unit>
|
||||||
|
<trans-unit id="s1c6fefb092506753">
|
||||||
|
<source>Failed to load comment list, please try again later</source>
|
||||||
|
<target>Error al cargar la lista de comentarios, por favor intente más tarde</target>
|
||||||
|
</trans-unit>
|
||||||
|
<trans-unit id="s1e3e30a26025484c">
|
||||||
|
<source>Failed to load reply list, please try again later</source>
|
||||||
|
<target>Error al cargar la lista de respuestas, por favor intente más tarde</target>
|
||||||
|
</trans-unit>
|
||||||
|
<trans-unit id="s299b10f3a58a09fd">
|
||||||
|
<source>Click OK to jump to the logout page, Please make sure the content being edited has been saved.</source>
|
||||||
|
<target>Haga clic en OK para ir a la página de cierre de sesión, Por favor, asegúrese de que el contenido editado haya sido guardado.</target>
|
||||||
|
</trans-unit>
|
||||||
|
<trans-unit id="s26e4d65f2801ac9c">
|
||||||
|
<source>Please enter content</source>
|
||||||
|
<target>Por favor, ingrese el contenido</target>
|
||||||
|
</trans-unit>
|
||||||
|
<trans-unit id="sf7c0eba7c822e3d6">
|
||||||
|
<source>Bold</source>
|
||||||
|
<target>Negrita</target>
|
||||||
|
</trans-unit>
|
||||||
|
<trans-unit id="s523eb9043213ff0d">
|
||||||
|
<source>Italic</source>
|
||||||
|
<target>Itálica</target>
|
||||||
|
</trans-unit>
|
||||||
|
<trans-unit id="sb206d700d26b14ff">
|
||||||
|
<source>Underline</source>
|
||||||
|
<target>Subrayado</target>
|
||||||
|
</trans-unit>
|
||||||
|
<trans-unit id="s58a3c1ecd4dd06cf">
|
||||||
|
<source>Strike</source>
|
||||||
|
<target>Tachado</target>
|
||||||
|
</trans-unit>
|
||||||
|
<trans-unit id="s3643189d1abbb7f4">
|
||||||
|
<source>Code</source>
|
||||||
|
<target>Código</target>
|
||||||
|
</trans-unit>
|
||||||
|
<trans-unit id="s35c3125c7750681e">
|
||||||
|
<source>Blockquote</source>
|
||||||
|
<target>Cita</target>
|
||||||
|
</trans-unit>
|
||||||
|
<trans-unit id="s6cb61eeccda272d5">
|
||||||
|
<source>Code Block</source>
|
||||||
|
<target>Bloque de código</target>
|
||||||
|
</trans-unit>
|
||||||
|
<trans-unit id="sf9709983d4da2c54">
|
||||||
|
<source>Upvote</source>
|
||||||
|
<target>Me gusta</target>
|
||||||
|
</trans-unit>
|
||||||
|
<trans-unit id="sa82fbb7f361c6f44">
|
||||||
|
<source>Show replies</source>
|
||||||
|
<target>Mostrar respuestas</target>
|
||||||
|
</trans-unit>
|
||||||
|
<trans-unit id="se7bee6e9a9b5394c">
|
||||||
|
<source>Private</source>
|
||||||
|
<target>Íntimo</target>
|
||||||
|
</trans-unit>
|
||||||
|
<trans-unit id="s838e512973be01d4">
|
||||||
|
<source>Currently logged in. After selecting the private option, comments will only be visible to yourself and the site administrator.</source>
|
||||||
|
<target>Actualmente conectado. Después de seleccionar la opción privada, los comentarios solo serán visibles para usted y el administrador del sitio.</target>
|
||||||
|
</trans-unit>
|
||||||
|
<trans-unit id="s5184a3f3e2f7b603">
|
||||||
|
<source>You are currently anonymous. After selecting the private option, the comment will only be visible to the site administrator.</source>
|
||||||
|
<target>Actualmente anónimo. Después de seleccionar la opción privada, el comentario solo será visible para el administrador del sitio.</target>
|
||||||
|
</trans-unit>
|
||||||
|
</body>
|
||||||
|
</file>
|
||||||
|
</xliff>
|
||||||
@@ -0,0 +1,167 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<xliff version="1.2" xmlns="urn:oasis:names:tc:xliff:document:1.2">
|
||||||
|
<file target-language="zh-CN" source-language="en" original="lit-localize-inputs" datatype="plaintext">
|
||||||
|
<body>
|
||||||
|
<trans-unit id="s67749057edb2586b">
|
||||||
|
<source>Logout</source>
|
||||||
|
<target>退出登录</target>
|
||||||
|
</trans-unit>
|
||||||
|
<trans-unit id="s9f2ed66340f019c6">
|
||||||
|
<source>Write a comment</source>
|
||||||
|
<target>编写评论</target>
|
||||||
|
</trans-unit>
|
||||||
|
<trans-unit id="s7437373e541a8037">
|
||||||
|
<source>Nicename</source>
|
||||||
|
<target>昵称</target>
|
||||||
|
</trans-unit>
|
||||||
|
<trans-unit id="sd1f44f1a8bc20e67">
|
||||||
|
<source>Email</source>
|
||||||
|
<target>电子邮件</target>
|
||||||
|
</trans-unit>
|
||||||
|
<trans-unit id="s82665b2ffabc9c0a">
|
||||||
|
<source>Website</source>
|
||||||
|
<target>网站</target>
|
||||||
|
</trans-unit>
|
||||||
|
<trans-unit id="sc8da3cc71de63832">
|
||||||
|
<source>Login</source>
|
||||||
|
<target>登录</target>
|
||||||
|
</trans-unit>
|
||||||
|
<trans-unit id="sb3d4f79d9d8b71e5">
|
||||||
|
<source>Submit</source>
|
||||||
|
<target>提交评论</target>
|
||||||
|
</trans-unit>
|
||||||
|
<trans-unit id="s09c7e0ff5f4e18cb">
|
||||||
|
<source>Cancel reply</source>
|
||||||
|
<target>取消回复</target>
|
||||||
|
</trans-unit>
|
||||||
|
<trans-unit id="s0b7ae9543c001867">
|
||||||
|
<source>Reply</source>
|
||||||
|
<target>回复</target>
|
||||||
|
</trans-unit>
|
||||||
|
<trans-unit id="sf3ff78cc329d3528">
|
||||||
|
<source>Previous</source>
|
||||||
|
<target>上一页</target>
|
||||||
|
</trans-unit>
|
||||||
|
<trans-unit id="s0fbf6dc6a1966408">
|
||||||
|
<source>Next</source>
|
||||||
|
<target>下一页</target>
|
||||||
|
</trans-unit>
|
||||||
|
<trans-unit id="sf77128b082955d42">
|
||||||
|
<source>(Or login)</source>
|
||||||
|
<target>(或登录账号)</target>
|
||||||
|
</trans-unit>
|
||||||
|
<trans-unit id="sa8dddacbaa66f8e0">
|
||||||
|
<source>Please login first</source>
|
||||||
|
<target>请先登录</target>
|
||||||
|
</trans-unit>
|
||||||
|
<trans-unit id="s7584ded3d749c75e">
|
||||||
|
<source>Load more</source>
|
||||||
|
<target>加载更多</target>
|
||||||
|
</trans-unit>
|
||||||
|
<trans-unit id="h6a83761bb66bbcc5">
|
||||||
|
<source><x id="0" equiv-text="${this.comments.total}"/> Comments</source>
|
||||||
|
<target><x id="0" equiv-text="${this.comments.total}"/> 条评论</target>
|
||||||
|
</trans-unit>
|
||||||
|
<trans-unit id="s98a5f7789c49dd3f">
|
||||||
|
<source>Reviewing</source>
|
||||||
|
<target>审核中</target>
|
||||||
|
</trans-unit>
|
||||||
|
<trans-unit id="sea7e567ed89dc0d7">
|
||||||
|
<source>Select emoticon</source>
|
||||||
|
<target>选择表情</target>
|
||||||
|
</trans-unit>
|
||||||
|
<trans-unit id="s4c0e15f9073382e6">
|
||||||
|
<source>Failed to obtain verification code</source>
|
||||||
|
<target>获取验证码失败</target>
|
||||||
|
</trans-unit>
|
||||||
|
<trans-unit id="s053945ad43e9d1d6">
|
||||||
|
<source>Please enter the verification code</source>
|
||||||
|
<target>请输入验证码</target>
|
||||||
|
</trans-unit>
|
||||||
|
<trans-unit id="s84b033b2f7360187">
|
||||||
|
<source>Please log in or complete the information first</source>
|
||||||
|
<target>请先登录或者完善信息</target>
|
||||||
|
</trans-unit>
|
||||||
|
<trans-unit id="sd5e242ab9574958a">
|
||||||
|
<source>Comment failed, please try again later</source>
|
||||||
|
<target>评论失败,请稍后重试</target>
|
||||||
|
</trans-unit>
|
||||||
|
<trans-unit id="sb5a28aa35006ff08">
|
||||||
|
<source>Comment submitted successfully</source>
|
||||||
|
<target>评论成功</target>
|
||||||
|
</trans-unit>
|
||||||
|
<trans-unit id="s3fb33d17bad61aa9">
|
||||||
|
<source>Comment submitted successfully, pending review</source>
|
||||||
|
<target>评论成功,请等待审核</target>
|
||||||
|
</trans-unit>
|
||||||
|
<trans-unit id="sa10fbe3fcd6ae148">
|
||||||
|
<source>Add reply</source>
|
||||||
|
<target>加入回复</target>
|
||||||
|
</trans-unit>
|
||||||
|
<trans-unit id="s1c6fefb092506753">
|
||||||
|
<source>Failed to load comment list, please try again later</source>
|
||||||
|
<target>加载评论列表失败,请稍后重试</target>
|
||||||
|
</trans-unit>
|
||||||
|
<trans-unit id="s1e3e30a26025484c">
|
||||||
|
<source>Failed to load reply list, please try again later</source>
|
||||||
|
<target>加载回复列表失败,请稍后重试</target>
|
||||||
|
</trans-unit>
|
||||||
|
<trans-unit id="s299b10f3a58a09fd">
|
||||||
|
<source>Click OK to jump to the logout page, Please make sure the content being edited has been saved.</source>
|
||||||
|
<target>点击确定将跳转至退出登录页面,请确保正在编辑的内容已保存。</target>
|
||||||
|
</trans-unit>
|
||||||
|
<trans-unit id="s26e4d65f2801ac9c">
|
||||||
|
<source>Please enter content</source>
|
||||||
|
<target>请输入内容</target>
|
||||||
|
</trans-unit>
|
||||||
|
<trans-unit id="sf7c0eba7c822e3d6">
|
||||||
|
<source>Bold</source>
|
||||||
|
<target>粗体</target>
|
||||||
|
</trans-unit>
|
||||||
|
<trans-unit id="s523eb9043213ff0d">
|
||||||
|
<source>Italic</source>
|
||||||
|
<target>斜体</target>
|
||||||
|
</trans-unit>
|
||||||
|
<trans-unit id="sb206d700d26b14ff">
|
||||||
|
<source>Underline</source>
|
||||||
|
<target>下划线</target>
|
||||||
|
</trans-unit>
|
||||||
|
<trans-unit id="s58a3c1ecd4dd06cf">
|
||||||
|
<source>Strike</source>
|
||||||
|
<target>删除线</target>
|
||||||
|
</trans-unit>
|
||||||
|
<trans-unit id="s3643189d1abbb7f4">
|
||||||
|
<source>Code</source>
|
||||||
|
<target>代码</target>
|
||||||
|
</trans-unit>
|
||||||
|
<trans-unit id="s35c3125c7750681e">
|
||||||
|
<source>Blockquote</source>
|
||||||
|
<target>引用</target>
|
||||||
|
</trans-unit>
|
||||||
|
<trans-unit id="s6cb61eeccda272d5">
|
||||||
|
<source>Code Block</source>
|
||||||
|
<target>代码块</target>
|
||||||
|
</trans-unit>
|
||||||
|
<trans-unit id="sf9709983d4da2c54">
|
||||||
|
<source>Upvote</source>
|
||||||
|
<target>点赞</target>
|
||||||
|
</trans-unit>
|
||||||
|
<trans-unit id="sa82fbb7f361c6f44">
|
||||||
|
<source>Show replies</source>
|
||||||
|
<target>显示回复</target>
|
||||||
|
</trans-unit>
|
||||||
|
<trans-unit id="se7bee6e9a9b5394c">
|
||||||
|
<source>Private</source>
|
||||||
|
<target>私密</target>
|
||||||
|
</trans-unit>
|
||||||
|
<trans-unit id="s838e512973be01d4">
|
||||||
|
<source>Currently logged in. After selecting the private option, comments will only be visible to yourself and the site administrator.</source>
|
||||||
|
<target>当前已登录,选择私密选项后,评论将仅对您和网站管理员可见。</target>
|
||||||
|
</trans-unit>
|
||||||
|
<trans-unit id="s5184a3f3e2f7b603">
|
||||||
|
<source>You are currently anonymous. After selecting the private option, the comment will only be visible to the site administrator.</source>
|
||||||
|
<target>当前是匿名状态,选择私密选项后,评论将仅对网站管理员可见。</target>
|
||||||
|
</trans-unit>
|
||||||
|
</body>
|
||||||
|
</file>
|
||||||
|
</xliff>
|
||||||
@@ -0,0 +1,167 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<xliff version="1.2" xmlns="urn:oasis:names:tc:xliff:document:1.2">
|
||||||
|
<file target-language="zh-TW" source-language="en" original="lit-localize-inputs" datatype="plaintext">
|
||||||
|
<body>
|
||||||
|
<trans-unit id="s67749057edb2586b">
|
||||||
|
<source>Logout</source>
|
||||||
|
<target>登出</target>
|
||||||
|
</trans-unit>
|
||||||
|
<trans-unit id="s9f2ed66340f019c6">
|
||||||
|
<source>Write a comment</source>
|
||||||
|
<target>撰寫評論</target>
|
||||||
|
</trans-unit>
|
||||||
|
<trans-unit id="s7437373e541a8037">
|
||||||
|
<source>Nicename</source>
|
||||||
|
<target>暱稱</target>
|
||||||
|
</trans-unit>
|
||||||
|
<trans-unit id="sd1f44f1a8bc20e67">
|
||||||
|
<source>Email</source>
|
||||||
|
<target>電子郵件</target>
|
||||||
|
</trans-unit>
|
||||||
|
<trans-unit id="s82665b2ffabc9c0a">
|
||||||
|
<source>Website</source>
|
||||||
|
<target>網站</target>
|
||||||
|
</trans-unit>
|
||||||
|
<trans-unit id="sc8da3cc71de63832">
|
||||||
|
<source>Login</source>
|
||||||
|
<target>登入</target>
|
||||||
|
</trans-unit>
|
||||||
|
<trans-unit id="sb3d4f79d9d8b71e5">
|
||||||
|
<source>Submit</source>
|
||||||
|
<target>提交評論</target>
|
||||||
|
</trans-unit>
|
||||||
|
<trans-unit id="s09c7e0ff5f4e18cb">
|
||||||
|
<source>Cancel reply</source>
|
||||||
|
<target>取消回覆</target>
|
||||||
|
</trans-unit>
|
||||||
|
<trans-unit id="s0b7ae9543c001867">
|
||||||
|
<source>Reply</source>
|
||||||
|
<target>回覆</target>
|
||||||
|
</trans-unit>
|
||||||
|
<trans-unit id="sf3ff78cc329d3528">
|
||||||
|
<source>Previous</source>
|
||||||
|
<target>上一頁</target>
|
||||||
|
</trans-unit>
|
||||||
|
<trans-unit id="s0fbf6dc6a1966408">
|
||||||
|
<source>Next</source>
|
||||||
|
<target>下一頁</target>
|
||||||
|
</trans-unit>
|
||||||
|
<trans-unit id="sf77128b082955d42">
|
||||||
|
<source>(Or login)</source>
|
||||||
|
<target>(或登入帳號)</target>
|
||||||
|
</trans-unit>
|
||||||
|
<trans-unit id="sa8dddacbaa66f8e0">
|
||||||
|
<source>Please login first</source>
|
||||||
|
<target>請先登入</target>
|
||||||
|
</trans-unit>
|
||||||
|
<trans-unit id="s7584ded3d749c75e">
|
||||||
|
<source>Load more</source>
|
||||||
|
<target>載入更多</target>
|
||||||
|
</trans-unit>
|
||||||
|
<trans-unit id="h6a83761bb66bbcc5">
|
||||||
|
<source><x id="0" equiv-text="${this.comments.total}"/> Comments</source>
|
||||||
|
<target><x id="0" equiv-text="${this.comments.total}"/> 則評論</target>
|
||||||
|
</trans-unit>
|
||||||
|
<trans-unit id="s98a5f7789c49dd3f">
|
||||||
|
<source>Reviewing</source>
|
||||||
|
<target>審核中</target>
|
||||||
|
</trans-unit>
|
||||||
|
<trans-unit id="sea7e567ed89dc0d7">
|
||||||
|
<source>Select emoticon</source>
|
||||||
|
<target>選擇表情</target>
|
||||||
|
</trans-unit>
|
||||||
|
<trans-unit id="s4c0e15f9073382e6">
|
||||||
|
<source>Failed to obtain verification code</source>
|
||||||
|
<target>獲取驗證碼失敗</target>
|
||||||
|
</trans-unit>
|
||||||
|
<trans-unit id="s053945ad43e9d1d6">
|
||||||
|
<source>Please enter the verification code</source>
|
||||||
|
<target>請輸入驗證碼</target>
|
||||||
|
</trans-unit>
|
||||||
|
<trans-unit id="s84b033b2f7360187">
|
||||||
|
<source>Please log in or complete the information first</source>
|
||||||
|
<target>請先登入或者完善資訊</target>
|
||||||
|
</trans-unit>
|
||||||
|
<trans-unit id="sd5e242ab9574958a">
|
||||||
|
<source>Comment failed, please try again later</source>
|
||||||
|
<target>評論失敗,請稍後重試</target>
|
||||||
|
</trans-unit>
|
||||||
|
<trans-unit id="sb5a28aa35006ff08">
|
||||||
|
<source>Comment submitted successfully</source>
|
||||||
|
<target>評論成功</target>
|
||||||
|
</trans-unit>
|
||||||
|
<trans-unit id="s3fb33d17bad61aa9">
|
||||||
|
<source>Comment submitted successfully, pending review</source>
|
||||||
|
<target>評論成功,請等待審核</target>
|
||||||
|
</trans-unit>
|
||||||
|
<trans-unit id="sa10fbe3fcd6ae148">
|
||||||
|
<source>Add reply</source>
|
||||||
|
<target>新增回覆</target>
|
||||||
|
</trans-unit>
|
||||||
|
<trans-unit id="s1c6fefb092506753">
|
||||||
|
<source>Failed to load comment list, please try again later</source>
|
||||||
|
<target>載入評論列表失敗,請稍後重試</target>
|
||||||
|
</trans-unit>
|
||||||
|
<trans-unit id="s1e3e30a26025484c">
|
||||||
|
<source>Failed to load reply list, please try again later</source>
|
||||||
|
<target>載入回覆列表失敗,請稍後重試</target>
|
||||||
|
</trans-unit>
|
||||||
|
<trans-unit id="s299b10f3a58a09fd">
|
||||||
|
<source>Click OK to jump to the logout page, Please make sure the content being edited has been saved.</source>
|
||||||
|
<target>点击确定将跳转至退出登录页面,请确保正在编辑的内容已保存。</target>
|
||||||
|
</trans-unit>
|
||||||
|
<trans-unit id="s26e4d65f2801ac9c">
|
||||||
|
<source>Please enter content</source>
|
||||||
|
<target>請輸入內容</target>
|
||||||
|
</trans-unit>
|
||||||
|
<trans-unit id="sf7c0eba7c822e3d6">
|
||||||
|
<source>Bold</source>
|
||||||
|
<target>粗體</target>
|
||||||
|
</trans-unit>
|
||||||
|
<trans-unit id="s523eb9043213ff0d">
|
||||||
|
<source>Italic</source>
|
||||||
|
<target>斜體</target>
|
||||||
|
</trans-unit>
|
||||||
|
<trans-unit id="sb206d700d26b14ff">
|
||||||
|
<source>Underline</source>
|
||||||
|
<target>底線</target>
|
||||||
|
</trans-unit>
|
||||||
|
<trans-unit id="s58a3c1ecd4dd06cf">
|
||||||
|
<source>Strike</source>
|
||||||
|
<target>刪除線</target>
|
||||||
|
</trans-unit>
|
||||||
|
<trans-unit id="s3643189d1abbb7f4">
|
||||||
|
<source>Code</source>
|
||||||
|
<target>程式碼</target>
|
||||||
|
</trans-unit>
|
||||||
|
<trans-unit id="s35c3125c7750681e">
|
||||||
|
<source>Blockquote</source>
|
||||||
|
<target>引用</target>
|
||||||
|
</trans-unit>
|
||||||
|
<trans-unit id="s6cb61eeccda272d5">
|
||||||
|
<source>Code Block</source>
|
||||||
|
<target>程式碼區塊</target>
|
||||||
|
</trans-unit>
|
||||||
|
<trans-unit id="sf9709983d4da2c54">
|
||||||
|
<source>Upvote</source>
|
||||||
|
<target>讚</target>
|
||||||
|
</trans-unit>
|
||||||
|
<trans-unit id="sa82fbb7f361c6f44">
|
||||||
|
<source>Show replies</source>
|
||||||
|
<target>顯示回覆</target>
|
||||||
|
</trans-unit>
|
||||||
|
<trans-unit id="se7bee6e9a9b5394c">
|
||||||
|
<source>Private</source>
|
||||||
|
<target>私密</target>
|
||||||
|
</trans-unit>
|
||||||
|
<trans-unit id="s838e512973be01d4">
|
||||||
|
<source>Currently logged in. After selecting the private option, comments will only be visible to yourself and the site administrator.</source>
|
||||||
|
<target>目前已登入,選擇私密選項後,評論將僅對您和網站管理員可見。</target>
|
||||||
|
</trans-unit>
|
||||||
|
<trans-unit id="s5184a3f3e2f7b603">
|
||||||
|
<source>You are currently anonymous. After selecting the private option, the comment will only be visible to the site administrator.</source>
|
||||||
|
<target>目前是匿名狀態,選擇私密選項後,評論將僅對網站管理員可見。</target>
|
||||||
|
</trans-unit>
|
||||||
|
</body>
|
||||||
|
</file>
|
||||||
|
</xliff>
|
||||||
Vendored
+1
@@ -0,0 +1 @@
|
|||||||
|
/// <reference types="vite/client" />
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<link href="/favicon.ico" rel="icon" />
|
||||||
|
<meta content="width=device-width, initial-scale=1.0" name="viewport" />
|
||||||
|
<title>@halo-dev/comment-widget - example</title>
|
||||||
|
<script src="/src/main.ts" type="module"></script>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
#toggle-theme {
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
:root {
|
||||||
|
--halo-cw-base-rounded: 0.5em;
|
||||||
|
--halo-cw-avatar-rounded: 1px;
|
||||||
|
--halo-cw-avatar-size: 2em;
|
||||||
|
--halo-cw-base-font-family: 'Inter', sans-serif;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
document.addEventListener("DOMContentLoaded", () => {
|
||||||
|
const button = document.getElementById("toggle-theme");
|
||||||
|
button.addEventListener("click", () => {
|
||||||
|
document.documentElement.classList.toggle("dark");
|
||||||
|
// save the theme to local storage
|
||||||
|
localStorage.setItem(
|
||||||
|
"theme",
|
||||||
|
document.documentElement.classList.contains("dark")
|
||||||
|
? "dark"
|
||||||
|
: "light"
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// get the theme from local storage
|
||||||
|
const theme = localStorage.getItem("theme");
|
||||||
|
if (theme) {
|
||||||
|
document.documentElement.classList.add(theme);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
window.addEventListener("halo:comment:created", () => {
|
||||||
|
console.log("halo:comment:created");
|
||||||
|
});
|
||||||
|
|
||||||
|
window.addEventListener("halo:comment-reply:created", () => {
|
||||||
|
console.log("halo:comment-reply:created");
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<button id="toggle-theme">Dark / Light</button>
|
||||||
|
<comment-widget
|
||||||
|
baseUrl="http://127.0.0.1:8090"
|
||||||
|
group="content.halo.run"
|
||||||
|
kind="Post"
|
||||||
|
version="v1alpha1"
|
||||||
|
name="5152aea5-c2e8-4717-8bba-2263d46e19d5"
|
||||||
|
with-replies="false"
|
||||||
|
></comment-widget>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@halo-dev/comment-widget": "workspace:*"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
import '@halo-dev/comment-widget';
|
||||||
|
import '@halo-dev/comment-widget/var.css';
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
{
|
||||||
|
"extends": "../../tsconfig.json",
|
||||||
|
"include": ["src"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import { fileURLToPath, URL } from 'node:url';
|
||||||
|
|
||||||
|
import { defineConfig } from 'vite';
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
'@': fileURLToPath(new URL('./src', import.meta.url)),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
server: {
|
||||||
|
port: 4000,
|
||||||
|
proxy: {
|
||||||
|
'/actuator': {
|
||||||
|
target: 'http://localhost:8090',
|
||||||
|
changeOrigin: true,
|
||||||
|
},
|
||||||
|
'/apis': {
|
||||||
|
target: 'http://localhost:8090',
|
||||||
|
changeOrigin: true,
|
||||||
|
},
|
||||||
|
'/upload': {
|
||||||
|
target: 'http://localhost:8090',
|
||||||
|
changeOrigin: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
Vendored
+2
@@ -0,0 +1,2 @@
|
|||||||
|
/// <reference types="@rsbuild/core/types" />
|
||||||
|
/// <reference types="unplugin-icons/types/vue" />
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
{
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"build": "rsbuild build",
|
||||||
|
"dev": "rsbuild build --watch --env-mode=development",
|
||||||
|
"type-check": "vue-tsc --build"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@halo-dev/comment-widget": "workspace:*",
|
||||||
|
"@halo-dev/components": "^2.21.0",
|
||||||
|
"@halo-dev/console-shared": "link:/Users/ryanwang/Workspace/github/ruibaby/halo-next/ui/packages/shared",
|
||||||
|
"vue": "^3.5.18"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@halo-dev/ui-plugin-bundler-kit": "^2.21.2",
|
||||||
|
"@rsbuild/core": "^1.4.15",
|
||||||
|
"@rsbuild/plugin-vue": "^1.1.1",
|
||||||
|
"@types/node": "^20.19.10",
|
||||||
|
"@vue/tsconfig": "^0.7.0",
|
||||||
|
"typescript": "~5.8.3",
|
||||||
|
"vue-tsc": "^2.2.12"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import { rsbuildConfig } from '@halo-dev/ui-plugin-bundler-kit';
|
||||||
|
import { pluginVue } from '@rsbuild/plugin-vue';
|
||||||
|
|
||||||
|
const MANIFEST_PATH = '../../src/main/resources/plugin.yaml';
|
||||||
|
const OUT_DIR_PROD = '../../src/main/resources/console';
|
||||||
|
const OUT_DIR_DEV = '../../build/resources/main/console';
|
||||||
|
|
||||||
|
export default rsbuildConfig({
|
||||||
|
manifestPath: MANIFEST_PATH,
|
||||||
|
rsbuild: ({ envMode }) => {
|
||||||
|
const isProduction = envMode === 'production';
|
||||||
|
const outDir = isProduction ? OUT_DIR_PROD : OUT_DIR_DEV;
|
||||||
|
|
||||||
|
return {
|
||||||
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
'@': './src',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
plugins: [
|
||||||
|
pluginVue({
|
||||||
|
vueLoaderOptions: {
|
||||||
|
compilerOptions: {
|
||||||
|
isCustomElement: (tag) => tag.startsWith('comment-'),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
output: {
|
||||||
|
distPath: {
|
||||||
|
root: outDir,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
<script lang="ts" setup>
|
||||||
|
import '@halo-dev/comment-widget';
|
||||||
|
|
||||||
|
defineProps<{
|
||||||
|
content: string;
|
||||||
|
}>();
|
||||||
|
</script>
|
||||||
|
<template>
|
||||||
|
<comment-content :content="content"></comment-content>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
<script lang="ts" setup>
|
||||||
|
import '@halo-dev/comment-widget';
|
||||||
|
import type { CommentEditor } from '@halo-dev/comment-widget';
|
||||||
|
import { onMounted, ref } from 'vue';
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
autoFocus?: boolean;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const emit =
|
||||||
|
defineEmits<
|
||||||
|
(e: 'update', content: { content: string; characterCount: number }) => void
|
||||||
|
>();
|
||||||
|
|
||||||
|
const editorRef = ref<InstanceType<typeof CommentEditor>>();
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
if (props.autoFocus) {
|
||||||
|
editorRef.value?.setFocus();
|
||||||
|
}
|
||||||
|
|
||||||
|
editorRef.value?.addEventListener('update', (e) => {
|
||||||
|
const detail = (e as CustomEvent).detail;
|
||||||
|
emit('update', {
|
||||||
|
content: detail.content,
|
||||||
|
characterCount: detail.characterCount ?? 0,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
<template>
|
||||||
|
<comment-editor ref="editorRef" keep-alive></comment-editor>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import { VLoading } from '@halo-dev/components';
|
||||||
|
import { definePlugin } from '@halo-dev/console-shared';
|
||||||
|
import { defineAsyncComponent, markRaw } from 'vue';
|
||||||
|
|
||||||
|
export default definePlugin({
|
||||||
|
components: {},
|
||||||
|
routes: [],
|
||||||
|
extensionPoints: {
|
||||||
|
'comment:editor:replace': () => {
|
||||||
|
return {
|
||||||
|
component: markRaw(
|
||||||
|
defineAsyncComponent({
|
||||||
|
loader: () => import('./components/Editor.vue'),
|
||||||
|
loadingComponent: VLoading,
|
||||||
|
})
|
||||||
|
),
|
||||||
|
};
|
||||||
|
},
|
||||||
|
'comment:list-item:content:replace': () => {
|
||||||
|
return {
|
||||||
|
component: markRaw(
|
||||||
|
defineAsyncComponent({
|
||||||
|
loader: () => import('./components/Content.vue'),
|
||||||
|
})
|
||||||
|
),
|
||||||
|
};
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"extends": "@vue/tsconfig/tsconfig.dom.json",
|
||||||
|
"include": ["env.d.ts", "src/**/*", "src/**/*.vue"],
|
||||||
|
"exclude": ["src/**/__tests__/*"],
|
||||||
|
"compilerOptions": {
|
||||||
|
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
||||||
|
|
||||||
|
"paths": {
|
||||||
|
"@/*": ["./src/*"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"scripts": {
|
||||||
|
"build": "tsc && rslib build",
|
||||||
|
"dev": "rslib build --watch"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@halo-dev/comment-widget": "workspace:*"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@rslib/core": "^0.12.1"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
import { defineConfig } from '@rslib/core';
|
||||||
|
|
||||||
|
const PLUGIN_NAME = 'PluginCommentWidget';
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
lib: [
|
||||||
|
{
|
||||||
|
format: 'esm',
|
||||||
|
autoExternal: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
output: {
|
||||||
|
target: 'web',
|
||||||
|
minify: true,
|
||||||
|
cleanDistPath: true,
|
||||||
|
filename: {
|
||||||
|
js: (pathData) => {
|
||||||
|
if (pathData.chunk.name === 'index') {
|
||||||
|
return 'comment-widget.js';
|
||||||
|
}
|
||||||
|
return '[name].[contenthash:8].js';
|
||||||
|
},
|
||||||
|
},
|
||||||
|
publicPath: `/plugins/${PLUGIN_NAME}/assets/static/`,
|
||||||
|
distPath: {
|
||||||
|
root: fileURLToPath(
|
||||||
|
new URL('../../src/main/resources/static', import.meta.url)
|
||||||
|
),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
Vendored
+1
@@ -0,0 +1 @@
|
|||||||
|
/// <reference types="@rslib/core/types" />
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import { CommentWidget } from '@halo-dev/comment-widget';
|
||||||
|
import '@halo-dev/comment-widget/var.css';
|
||||||
|
|
||||||
|
export { CommentWidget };
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
group: string;
|
||||||
|
kind: string;
|
||||||
|
name: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function init(el: string, props: Props) {
|
||||||
|
const parent = document.querySelector(el) as HTMLElement;
|
||||||
|
|
||||||
|
if (!parent) {
|
||||||
|
console.error('Element not found', el);
|
||||||
|
}
|
||||||
|
|
||||||
|
const commentWidget = document.createElement(
|
||||||
|
'comment-widget'
|
||||||
|
) as CommentWidget;
|
||||||
|
|
||||||
|
commentWidget.kind = props.kind;
|
||||||
|
commentWidget.group = props.group;
|
||||||
|
commentWidget.version = 'v1alpha1';
|
||||||
|
commentWidget.name = props.name;
|
||||||
|
|
||||||
|
const observer = new IntersectionObserver((entries) => {
|
||||||
|
entries.forEach((entry) => {
|
||||||
|
if (entry.isIntersecting && parent.childElementCount === 0) {
|
||||||
|
parent.appendChild(commentWidget);
|
||||||
|
|
||||||
|
parent.animate([{ opacity: 0 }, { opacity: 1 }], {
|
||||||
|
duration: 300,
|
||||||
|
fill: 'forwards',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
observer.observe(parent as Element);
|
||||||
|
}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
{
|
||||||
|
"extends": "../../tsconfig.json",
|
||||||
|
"include": ["src"]
|
||||||
|
}
|
||||||
Generated
+6070
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,5 @@
|
|||||||
|
packages:
|
||||||
|
- packages/**
|
||||||
|
onlyBuiltDependencies:
|
||||||
|
- core-js
|
||||||
|
- esbuild
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
rootProject.name = 'plugin-comment'
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
package run.halo.comment.widget;
|
||||||
|
|
||||||
|
import java.util.Properties;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
import org.springframework.util.PropertyPlaceholderHelper;
|
||||||
|
import org.thymeleaf.context.ITemplateContext;
|
||||||
|
import org.thymeleaf.model.IModel;
|
||||||
|
import org.thymeleaf.model.IModelFactory;
|
||||||
|
import org.thymeleaf.processor.element.IElementModelStructureHandler;
|
||||||
|
import reactor.core.publisher.Mono;
|
||||||
|
import run.halo.app.plugin.PluginContext;
|
||||||
|
import run.halo.app.theme.dialect.TemplateHeadProcessor;
|
||||||
|
|
||||||
|
@Component
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class CommentWidgetHeadProcessor implements TemplateHeadProcessor {
|
||||||
|
|
||||||
|
static final PropertyPlaceholderHelper PROPERTY_PLACEHOLDER_HELPER = new PropertyPlaceholderHelper("${", "}");
|
||||||
|
|
||||||
|
private final PluginContext pluginContext;
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Mono<Void> process(ITemplateContext context, IModel model,
|
||||||
|
IElementModelStructureHandler structureHandler) {
|
||||||
|
final IModelFactory modelFactory = context.getModelFactory();
|
||||||
|
model.add(modelFactory.createText(commentWidgetScript()));
|
||||||
|
return Mono.empty();
|
||||||
|
}
|
||||||
|
|
||||||
|
private String commentWidgetScript() {
|
||||||
|
|
||||||
|
final Properties properties = new Properties();
|
||||||
|
properties.setProperty("version", pluginContext.getVersion());
|
||||||
|
|
||||||
|
return PROPERTY_PLACEHOLDER_HELPER.replacePlaceholders("""
|
||||||
|
<!-- plugin-comment-widget start -->
|
||||||
|
<link rel="modulepreload" href="/plugins/PluginCommentWidget/assets/static/comment-widget.js?version=${version}">
|
||||||
|
<link rel="stylesheet" href="/plugins/PluginCommentWidget/assets/static/index.css?version=${version}" />
|
||||||
|
<!-- plugin-comment-widget end -->
|
||||||
|
""", properties);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
package run.halo.comment.widget;
|
||||||
|
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
import run.halo.app.plugin.BasePlugin;
|
||||||
|
import run.halo.app.plugin.PluginContext;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @author ryanwang
|
||||||
|
* @since 2.0.0
|
||||||
|
*/
|
||||||
|
@Component
|
||||||
|
public class CommentWidgetPlugin extends BasePlugin {
|
||||||
|
public CommentWidgetPlugin(PluginContext pluginContext) {
|
||||||
|
super(pluginContext);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
package run.halo.comment.widget;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.databind.JsonNode;
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import com.fasterxml.jackson.databind.node.ObjectNode;
|
||||||
|
import java.util.Map;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
import org.springframework.web.reactive.function.server.RouterFunction;
|
||||||
|
import org.springframework.web.reactive.function.server.RouterFunctions;
|
||||||
|
import org.springframework.web.reactive.function.server.ServerRequest;
|
||||||
|
import org.springframework.web.reactive.function.server.ServerResponse;
|
||||||
|
import reactor.core.publisher.Mono;
|
||||||
|
import run.halo.app.core.extension.endpoint.CustomEndpoint;
|
||||||
|
import run.halo.app.extension.ConfigMap;
|
||||||
|
import run.halo.app.extension.GroupVersion;
|
||||||
|
import run.halo.app.extension.ReactiveExtensionClient;
|
||||||
|
import run.halo.app.plugin.PluginContext;
|
||||||
|
|
||||||
|
@Component
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class ConfigEndpoint implements CustomEndpoint {
|
||||||
|
|
||||||
|
private final ReactiveExtensionClient client;
|
||||||
|
|
||||||
|
private final PluginContext context;
|
||||||
|
|
||||||
|
private final ObjectMapper objectMapper = new ObjectMapper();
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public RouterFunction<ServerResponse> endpoint() {
|
||||||
|
return RouterFunctions.route()
|
||||||
|
.GET("config", this::getConfig)
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
private Mono<ServerResponse> getConfig(ServerRequest request) {
|
||||||
|
return client.fetch(ConfigMap.class, context.getConfigMapName())
|
||||||
|
.flatMap(configMap -> {
|
||||||
|
Map<String, String> data = configMap.getData();
|
||||||
|
ObjectNode rootNode = objectMapper.createObjectNode();
|
||||||
|
|
||||||
|
data.forEach((key, value) -> {
|
||||||
|
try {
|
||||||
|
JsonNode jsonNode = objectMapper.readTree(value);
|
||||||
|
rootNode.set(key, jsonNode);
|
||||||
|
} catch (Exception e) {
|
||||||
|
rootNode.put(key, value);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return ServerResponse.ok().bodyValue(rootNode);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public GroupVersion groupVersion() {
|
||||||
|
return GroupVersion.parseAPIVersion("api.commentwidget.halo.run/v1alpha1");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
package run.halo.comment.widget;
|
||||||
|
|
||||||
|
import java.util.Properties;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.apache.commons.lang3.StringUtils;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
import org.springframework.util.Assert;
|
||||||
|
import org.springframework.util.PropertyPlaceholderHelper;
|
||||||
|
import org.thymeleaf.context.ITemplateContext;
|
||||||
|
import org.thymeleaf.model.IAttribute;
|
||||||
|
import org.thymeleaf.model.IProcessableElementTag;
|
||||||
|
import org.thymeleaf.processor.element.IElementTagStructureHandler;
|
||||||
|
import run.halo.app.plugin.PluginContext;
|
||||||
|
import run.halo.app.theme.dialect.CommentWidget;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A default implementation of {@link CommentWidget}.
|
||||||
|
*
|
||||||
|
* @author guqing
|
||||||
|
* @since 2.0.0
|
||||||
|
*/
|
||||||
|
@Slf4j
|
||||||
|
@Component
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class DefaultCommentWidget implements CommentWidget {
|
||||||
|
static final PropertyPlaceholderHelper PROPERTY_PLACEHOLDER_HELPER = new PropertyPlaceholderHelper("${", "}");
|
||||||
|
|
||||||
|
private final PluginContext pluginContext;
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void render(ITemplateContext context,
|
||||||
|
IProcessableElementTag tag,
|
||||||
|
IElementTagStructureHandler structureHandler) {
|
||||||
|
IAttribute groupAttribute = tag.getAttribute("group");
|
||||||
|
IAttribute kindAttribute = tag.getAttribute("kind");
|
||||||
|
IAttribute nameAttribute = tag.getAttribute("name");
|
||||||
|
|
||||||
|
structureHandler.replaceWith(commentHtml(groupAttribute, kindAttribute, nameAttribute),
|
||||||
|
false);
|
||||||
|
}
|
||||||
|
|
||||||
|
private String commentHtml(IAttribute groupAttribute, IAttribute kindAttribute,
|
||||||
|
IAttribute nameAttribute) {
|
||||||
|
if (kindAttribute == null || StringUtils.isBlank(kindAttribute.getValue())) {
|
||||||
|
log.warn("Comment widget tag attributes 'kind' is missing.");
|
||||||
|
return "<p style=\"color:red\">Comment widget attributes 'kind' is required but missing found.</p>";
|
||||||
|
}
|
||||||
|
if (nameAttribute == null || StringUtils.isBlank(nameAttribute.getValue())) {
|
||||||
|
log.warn("Comment widget tag attributes 'name' is missing.");
|
||||||
|
return "<p style=\"color:red\">Comment widget attributes 'name' is required but missing found.</p>";
|
||||||
|
}
|
||||||
|
|
||||||
|
String group = getGroup(groupAttribute);
|
||||||
|
|
||||||
|
final Properties properties = new Properties();
|
||||||
|
|
||||||
|
properties.setProperty("version", pluginContext.getVersion());
|
||||||
|
properties.setProperty("group", group);
|
||||||
|
properties.setProperty("kind", kindAttribute.getValue());
|
||||||
|
properties.setProperty("name", nameAttribute.getValue());
|
||||||
|
properties.setProperty("domId", domIdFrom(group, kindAttribute.getValue(), nameAttribute.getValue()));
|
||||||
|
|
||||||
|
properties.setProperty("version", pluginContext.getVersion());
|
||||||
|
|
||||||
|
// placeholderHelper only support string, so we need to convert boolean to string
|
||||||
|
return PROPERTY_PLACEHOLDER_HELPER.replacePlaceholders("""
|
||||||
|
<div id="${domId}"></div>
|
||||||
|
<script type="module" data-pjax>
|
||||||
|
import { init } from "/plugins/PluginCommentWidget/assets/static/comment-widget.js?version=${version}";
|
||||||
|
init(
|
||||||
|
"#${domId}",
|
||||||
|
{
|
||||||
|
group: "${group}",
|
||||||
|
kind: "${kind}",
|
||||||
|
name: "${name}",
|
||||||
|
}
|
||||||
|
);
|
||||||
|
</script>
|
||||||
|
""", properties);
|
||||||
|
}
|
||||||
|
|
||||||
|
private String domIdFrom(String group, String kind, String name) {
|
||||||
|
Assert.notNull(name, "The name must not be null.");
|
||||||
|
Assert.notNull(kind, "The kind must not be null.");
|
||||||
|
String groupKindNameAsDomId = String.join("-", group, kind, name);
|
||||||
|
return "comment-" + groupKindNameAsDomId.replaceAll("[^\\-_a-zA-Z0-9\\s]", "-")
|
||||||
|
.replaceAll("(-)+", "-");
|
||||||
|
}
|
||||||
|
|
||||||
|
private String getGroup(IAttribute groupAttribute) {
|
||||||
|
return groupAttribute.getValue() == null ? ""
|
||||||
|
: StringUtils.defaultString(groupAttribute.getValue());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
package run.halo.comment.widget;
|
||||||
|
|
||||||
|
import lombok.Data;
|
||||||
|
import lombok.Getter;
|
||||||
|
import lombok.experimental.Accessors;
|
||||||
|
import org.springframework.lang.NonNull;
|
||||||
|
import reactor.core.publisher.Mono;
|
||||||
|
import run.halo.comment.widget.captcha.CaptchaType;
|
||||||
|
|
||||||
|
public interface SettingConfigGetter {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Never {@link Mono#empty()}.
|
||||||
|
*/
|
||||||
|
Mono<BasicConfig> getBasicConfig();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Never {@link Mono#empty()}.
|
||||||
|
*/
|
||||||
|
Mono<AvatarConfig> getAvatarConfig();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Never {@link Mono#empty()}.
|
||||||
|
*/
|
||||||
|
Mono<SecurityConfig> getSecurityConfig();
|
||||||
|
|
||||||
|
@Data
|
||||||
|
@Accessors(chain = true)
|
||||||
|
class SecurityConfig {
|
||||||
|
public static final String GROUP = "security";
|
||||||
|
|
||||||
|
@Getter(onMethod_ = @NonNull)
|
||||||
|
private CaptchaConfig captcha = CaptchaConfig.empty();
|
||||||
|
|
||||||
|
public SecurityConfig setCaptcha(CaptchaConfig captcha) {
|
||||||
|
this.captcha = (captcha == null ? CaptchaConfig.empty() : captcha);
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static SecurityConfig empty() {
|
||||||
|
return new SecurityConfig()
|
||||||
|
.setCaptcha(CaptchaConfig.empty());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Data
|
||||||
|
@Accessors(chain = true)
|
||||||
|
class CaptchaConfig {
|
||||||
|
|
||||||
|
private boolean anonymousCommentCaptcha;
|
||||||
|
|
||||||
|
@Getter(onMethod_ = @NonNull)
|
||||||
|
private CaptchaType type = CaptchaType.ALPHANUMERIC;
|
||||||
|
|
||||||
|
private boolean ignoreCase = true;
|
||||||
|
|
||||||
|
private int captchaLength = 4;
|
||||||
|
|
||||||
|
private int arithmeticRange = 90;
|
||||||
|
|
||||||
|
public CaptchaConfig setType(CaptchaType type) {
|
||||||
|
this.type = (type == null ? CaptchaType.ALPHANUMERIC : type);
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static CaptchaConfig empty() {
|
||||||
|
return new CaptchaConfig();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Data
|
||||||
|
class BasicConfig {
|
||||||
|
public static final String GROUP = "basic";
|
||||||
|
private int size;
|
||||||
|
private int replySize;
|
||||||
|
private boolean withReplies;
|
||||||
|
private int withReplySize;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Data
|
||||||
|
class AvatarConfig {
|
||||||
|
public static final String GROUP = "avatar";
|
||||||
|
private boolean enable;
|
||||||
|
private String provider;
|
||||||
|
private String providerMirror;
|
||||||
|
private String policy;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
package run.halo.comment.widget;
|
||||||
|
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
import reactor.core.publisher.Mono;
|
||||||
|
import run.halo.app.plugin.ReactiveSettingFetcher;
|
||||||
|
|
||||||
|
@Component
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class SettingConfigGetterImpl implements SettingConfigGetter {
|
||||||
|
private final ReactiveSettingFetcher settingFetcher;
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Mono<BasicConfig> getBasicConfig() {
|
||||||
|
return settingFetcher.fetch(BasicConfig.GROUP, BasicConfig.class)
|
||||||
|
.defaultIfEmpty(new BasicConfig());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Mono<AvatarConfig> getAvatarConfig() {
|
||||||
|
return settingFetcher.fetch(AvatarConfig.GROUP, AvatarConfig.class)
|
||||||
|
.defaultIfEmpty(new AvatarConfig());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Mono<SecurityConfig> getSecurityConfig() {
|
||||||
|
return settingFetcher.fetch(SecurityConfig.GROUP, SecurityConfig.class)
|
||||||
|
.defaultIfEmpty(SecurityConfig.empty());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
package run.halo.comment.widget.captcha;
|
||||||
|
|
||||||
|
import java.time.Duration;
|
||||||
|
import org.springframework.http.HttpCookie;
|
||||||
|
import org.springframework.lang.Nullable;
|
||||||
|
import org.springframework.web.server.ServerWebExchange;
|
||||||
|
|
||||||
|
public interface CaptchaCookieResolver {
|
||||||
|
@Nullable
|
||||||
|
HttpCookie resolveCookie(ServerWebExchange exchange);
|
||||||
|
|
||||||
|
void setCookie(ServerWebExchange exchange, String value);
|
||||||
|
|
||||||
|
void expireCookie(ServerWebExchange exchange);
|
||||||
|
|
||||||
|
String getCookieName();
|
||||||
|
|
||||||
|
Duration getCookieMaxAge();
|
||||||
|
}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
package run.halo.comment.widget.captcha;
|
||||||
|
|
||||||
|
import java.time.Duration;
|
||||||
|
import lombok.Getter;
|
||||||
|
import org.springframework.http.HttpCookie;
|
||||||
|
import org.springframework.http.ResponseCookie;
|
||||||
|
import org.springframework.lang.Nullable;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
import org.springframework.util.Assert;
|
||||||
|
import org.springframework.web.server.ServerWebExchange;
|
||||||
|
|
||||||
|
@Getter
|
||||||
|
@Component
|
||||||
|
public class CaptchaCookieResolverImpl implements CaptchaCookieResolver {
|
||||||
|
public static final String CAPTCHA_COOKIE_KEY = "comment-widget-captcha";
|
||||||
|
|
||||||
|
private final String cookieName = CAPTCHA_COOKIE_KEY;
|
||||||
|
|
||||||
|
private final Duration cookieMaxAge = Duration.ofHours(1);
|
||||||
|
|
||||||
|
@Override
|
||||||
|
@Nullable
|
||||||
|
public HttpCookie resolveCookie(ServerWebExchange exchange) {
|
||||||
|
return exchange.getRequest().getCookies().getFirst(getCookieName());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void setCookie(ServerWebExchange exchange, String value) {
|
||||||
|
Assert.notNull(value, "'value' is required");
|
||||||
|
exchange.getResponse().getCookies()
|
||||||
|
.set(getCookieName(), initCookie(exchange, value).build());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void expireCookie(ServerWebExchange exchange) {
|
||||||
|
ResponseCookie cookie = initCookie(exchange, "").maxAge(0).build();
|
||||||
|
exchange.getResponse().getCookies().set(this.cookieName, cookie);
|
||||||
|
}
|
||||||
|
|
||||||
|
private ResponseCookie.ResponseCookieBuilder initCookie(ServerWebExchange exchange,
|
||||||
|
String value) {
|
||||||
|
return ResponseCookie.from(this.cookieName, value)
|
||||||
|
.path(exchange.getRequest().getPath().contextPath().value() + "/")
|
||||||
|
.maxAge(getCookieMaxAge())
|
||||||
|
.httpOnly(true)
|
||||||
|
.secure("https".equalsIgnoreCase(exchange.getRequest().getURI().getScheme()))
|
||||||
|
.sameSite("Lax");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
package run.halo.comment.widget.captcha;
|
||||||
|
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
import org.springframework.web.reactive.function.server.RouterFunction;
|
||||||
|
import org.springframework.web.reactive.function.server.RouterFunctions;
|
||||||
|
import org.springframework.web.reactive.function.server.ServerRequest;
|
||||||
|
import org.springframework.web.reactive.function.server.ServerResponse;
|
||||||
|
import reactor.core.publisher.Mono;
|
||||||
|
import run.halo.app.core.extension.endpoint.CustomEndpoint;
|
||||||
|
import run.halo.app.extension.GroupVersion;
|
||||||
|
import run.halo.comment.widget.SettingConfigGetter;
|
||||||
|
|
||||||
|
@Component
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class CaptchaEndpoint implements CustomEndpoint {
|
||||||
|
|
||||||
|
private final CaptchaManager captchaManager;
|
||||||
|
private final SettingConfigGetter settingConfigGetter;
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public RouterFunction<ServerResponse> endpoint() {
|
||||||
|
return RouterFunctions.route()
|
||||||
|
.GET("captcha/-/generate", this::generateCaptcha)
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
|
||||||
|
private Mono<ServerResponse> generateCaptcha(ServerRequest request) {
|
||||||
|
return settingConfigGetter.getSecurityConfig()
|
||||||
|
.map(SettingConfigGetter.SecurityConfig::getCaptcha)
|
||||||
|
.flatMap(captchaConfig -> captchaManager.generate(request.exchange(), captchaConfig))
|
||||||
|
.flatMap(captcha -> ServerResponse.ok().bodyValue(captcha.imageBase64()));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public GroupVersion groupVersion() {
|
||||||
|
return GroupVersion.parseAPIVersion("api.commentwidget.halo.run/v1alpha1");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,145 @@
|
|||||||
|
package run.halo.comment.widget.captcha;
|
||||||
|
|
||||||
|
import javax.imageio.ImageIO;
|
||||||
|
import java.awt.*;
|
||||||
|
import java.awt.image.BufferedImage;
|
||||||
|
import java.io.ByteArrayOutputStream;
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.io.InputStream;
|
||||||
|
import java.util.Base64;
|
||||||
|
import java.util.Random;
|
||||||
|
import java.util.function.Function;
|
||||||
|
import lombok.experimental.UtilityClass;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
|
||||||
|
@Slf4j
|
||||||
|
@UtilityClass
|
||||||
|
public class CaptchaGenerator {
|
||||||
|
private static final String CHAR_STRING = "ABCDEFGHJKMNPRSTUVWXYZabcdefghjkmnpqrstuvwxyz0123456789";
|
||||||
|
private static final int WIDTH = 120;
|
||||||
|
private static final int HEIGHT = 36;
|
||||||
|
private static final int CHAR_LENGTH = 4;
|
||||||
|
|
||||||
|
private static final Font customFont;
|
||||||
|
|
||||||
|
static {
|
||||||
|
customFont = loadArialFont();
|
||||||
|
}
|
||||||
|
|
||||||
|
public static Captcha generateMathCaptcha(int arithmeticRange) {
|
||||||
|
return generateCaptchaImage((g2d) -> drawMathCaptchaText(g2d, arithmeticRange));
|
||||||
|
}
|
||||||
|
|
||||||
|
public static Captcha generateSimpleCaptcha(int captchaLength) {
|
||||||
|
return generateCaptchaImage((g2d) -> drawSimpleText(g2d, captchaLength));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Captcha generateCaptchaImage(Function<Graphics2D, String> drawCaptchaTextFunc) {
|
||||||
|
BufferedImage bufferedImage = new BufferedImage(WIDTH, HEIGHT, BufferedImage.TYPE_INT_RGB);
|
||||||
|
Graphics2D g2d = bufferedImage.createGraphics();
|
||||||
|
|
||||||
|
// paint white background
|
||||||
|
g2d.setColor(Color.WHITE);
|
||||||
|
g2d.fillRect(0, 0, WIDTH, HEIGHT);
|
||||||
|
|
||||||
|
g2d.setFont(customFont);
|
||||||
|
|
||||||
|
var captchaText = drawCaptchaTextFunc.apply(g2d);
|
||||||
|
|
||||||
|
// add some noise
|
||||||
|
Random random = new Random();
|
||||||
|
for (int i = 0; i < 10; i++) {
|
||||||
|
g2d.setColor(new Color(random.nextInt(256), random.nextInt(256), random.nextInt(256)));
|
||||||
|
int x1 = random.nextInt(WIDTH);
|
||||||
|
int y1 = random.nextInt(HEIGHT);
|
||||||
|
int x2 = random.nextInt(WIDTH);
|
||||||
|
int y2 = random.nextInt(HEIGHT);
|
||||||
|
g2d.drawLine(x1, y1, x2, y2);
|
||||||
|
}
|
||||||
|
|
||||||
|
g2d.dispose();
|
||||||
|
return new Captcha(captchaText, bufferedImage);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String drawMathCaptchaText(Graphics2D g2d, int arithmeticRange) {
|
||||||
|
Random random = new Random();
|
||||||
|
int num1 = random.nextInt(arithmeticRange) + 1;
|
||||||
|
int num2 = random.nextInt(arithmeticRange) + 1;
|
||||||
|
char operator = getRandomOperator();
|
||||||
|
|
||||||
|
int result;
|
||||||
|
String mathText = switch (operator) {
|
||||||
|
case '+' -> {
|
||||||
|
result = num1 + num2;
|
||||||
|
yield num1 + " + " + num2 + " = ?";
|
||||||
|
}
|
||||||
|
case '-' -> {
|
||||||
|
result = num1 - num2;
|
||||||
|
yield num1 + " - " + num2 + " = ?";
|
||||||
|
}
|
||||||
|
case '*' -> {
|
||||||
|
result = num1 * num2;
|
||||||
|
yield num1 + " * " + num2 + " = ?";
|
||||||
|
}
|
||||||
|
default -> throw new IllegalStateException("Unexpected value: " + operator);
|
||||||
|
};
|
||||||
|
|
||||||
|
g2d.setColor(Color.BLACK);
|
||||||
|
g2d.drawString(mathText, 20, 30);
|
||||||
|
return String.valueOf(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
public record Captcha(String code, BufferedImage image) {
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String drawSimpleText(Graphics2D g2d, int captchaLength) {
|
||||||
|
var captchaText = generateRandomText(captchaLength);
|
||||||
|
Random random = new Random();
|
||||||
|
int charSpacing = WIDTH / (captchaLength + 1);
|
||||||
|
for (int i = 0; i < captchaText.length(); i++) {
|
||||||
|
g2d.setColor(new Color(random.nextInt(256), random.nextInt(256), random.nextInt(256)));
|
||||||
|
// 动态计算每个字符的位置
|
||||||
|
int xPos = charSpacing + i * charSpacing;
|
||||||
|
g2d.drawString(String.valueOf(captchaText.charAt(i)), xPos, 30);
|
||||||
|
}
|
||||||
|
return captchaText;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Font loadArialFont() {
|
||||||
|
var fontPath = "/fonts/Arial_Bold.ttf";
|
||||||
|
try (InputStream is = CaptchaGenerator.class.getResourceAsStream(fontPath)) {
|
||||||
|
if (is == null) {
|
||||||
|
throw new RuntimeException("Cannot load font file for " + fontPath + ", please check if it exists.");
|
||||||
|
}
|
||||||
|
return Font.createFont(Font.TRUETYPE_FONT, is).deriveFont(Font.BOLD, 24);
|
||||||
|
} catch (FontFormatException | IOException e) {
|
||||||
|
log.warn("Failed to load font file for {}, fallback to default font.", fontPath);
|
||||||
|
return new Font("Serif", Font.BOLD, 24);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static char getRandomOperator() {
|
||||||
|
char[] operators = {'+', '-', '*'};
|
||||||
|
Random random = new Random();
|
||||||
|
return operators[random.nextInt(operators.length)];
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String generateRandomText(int captchaLength) {
|
||||||
|
StringBuilder sb = new StringBuilder(captchaLength);
|
||||||
|
Random random = new Random();
|
||||||
|
for (int i = 0; i < captchaLength; i++) {
|
||||||
|
sb.append(CHAR_STRING.charAt(random.nextInt(CHAR_STRING.length())));
|
||||||
|
}
|
||||||
|
return sb.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
public static String encodeToBase64(BufferedImage image) {
|
||||||
|
try (ByteArrayOutputStream outputStream = new ByteArrayOutputStream()) {
|
||||||
|
ImageIO.write(image, "png", outputStream);
|
||||||
|
byte[] imageBytes = outputStream.toByteArray();
|
||||||
|
return Base64.getEncoder().encodeToString(imageBytes);
|
||||||
|
} catch (IOException e) {
|
||||||
|
throw new RuntimeException(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
package run.halo.comment.widget.captcha;
|
||||||
|
|
||||||
|
import org.springframework.web.server.ServerWebExchange;
|
||||||
|
import reactor.core.publisher.Mono;
|
||||||
|
import run.halo.comment.widget.SettingConfigGetter;
|
||||||
|
|
||||||
|
public interface CaptchaManager {
|
||||||
|
Mono<Boolean> verify(String id, String captchaCode, boolean ignoreCase);
|
||||||
|
|
||||||
|
Mono<Void> invalidate(String id);
|
||||||
|
|
||||||
|
Mono<Captcha> generate(ServerWebExchange exchange, SettingConfigGetter.CaptchaConfig captchaConfig);
|
||||||
|
|
||||||
|
record Captcha(String id, String code, String imageBase64) {
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
package run.halo.comment.widget.captcha;
|
||||||
|
|
||||||
|
import java.awt.image.BufferedImage;
|
||||||
|
import java.util.UUID;
|
||||||
|
import java.util.concurrent.TimeUnit;
|
||||||
|
import com.google.common.cache.Cache;
|
||||||
|
import com.google.common.cache.CacheBuilder;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
import org.springframework.web.server.ServerWebExchange;
|
||||||
|
import reactor.core.publisher.Mono;
|
||||||
|
import reactor.core.scheduler.Schedulers;
|
||||||
|
import run.halo.comment.widget.SettingConfigGetter;
|
||||||
|
|
||||||
|
@Component
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class CaptchaManagerImpl implements CaptchaManager {
|
||||||
|
public static final long CODE_EXPIRATION_MINUTES = 1;
|
||||||
|
|
||||||
|
private final Cache<String, Captcha> captchaCache =
|
||||||
|
CacheBuilder.newBuilder()
|
||||||
|
.expireAfterWrite(CODE_EXPIRATION_MINUTES, TimeUnit.MINUTES)
|
||||||
|
.maximumSize(100)
|
||||||
|
.build();
|
||||||
|
|
||||||
|
private final CaptchaCookieResolver captchaCookieResolver;
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Mono<Boolean> verify(String key, String captchaCode, boolean ignoreCase) {
|
||||||
|
return Mono.justOrEmpty(captchaCache.getIfPresent(key))
|
||||||
|
.filter(captcha -> ignoreCase ? captcha.code().equalsIgnoreCase(captchaCode) : captcha.code().equals(captchaCode))
|
||||||
|
.hasElement();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Mono<Void> invalidate(String id) {
|
||||||
|
captchaCache.invalidate(id);
|
||||||
|
return Mono.empty();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Mono<Captcha> generate(ServerWebExchange exchange, SettingConfigGetter.CaptchaConfig captchaConfig) {
|
||||||
|
return doGenerate(captchaConfig)
|
||||||
|
.doOnNext(captcha -> captchaCookieResolver.setCookie(exchange, captcha.id()));
|
||||||
|
}
|
||||||
|
|
||||||
|
private Mono<Captcha> doGenerate(SettingConfigGetter.CaptchaConfig captchaConfig) {
|
||||||
|
return Mono.fromSupplier(() -> {
|
||||||
|
var captcha = switch (captchaConfig.getType()) {
|
||||||
|
case ALPHANUMERIC -> CaptchaGenerator.generateSimpleCaptcha(captchaConfig.getCaptchaLength());
|
||||||
|
case ARITHMETIC -> CaptchaGenerator.generateMathCaptcha(captchaConfig.getArithmeticRange());
|
||||||
|
};
|
||||||
|
var imageBase64 = encodeBufferedImageToDataUri(captcha.image());
|
||||||
|
var id = UUID.randomUUID().toString();
|
||||||
|
return new Captcha(id, captcha.code(), imageBase64);
|
||||||
|
})
|
||||||
|
.subscribeOn(Schedulers.boundedElastic())
|
||||||
|
.doOnNext(captcha -> captchaCache.put(captcha.id(), captcha));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String encodeBufferedImageToDataUri(BufferedImage image) {
|
||||||
|
var imageBase64 = CaptchaGenerator.encodeToBase64(image);
|
||||||
|
return "data:image/png;base64," + imageBase64;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
package run.halo.comment.widget.captcha;
|
||||||
|
|
||||||
|
public enum CaptchaType {
|
||||||
|
ALPHANUMERIC,
|
||||||
|
ARITHMETIC
|
||||||
|
}
|
||||||
@@ -0,0 +1,154 @@
|
|||||||
|
package run.halo.comment.widget.captcha;
|
||||||
|
|
||||||
|
import static org.springframework.security.web.server.util.matcher.ServerWebExchangeMatchers.pathMatchers;
|
||||||
|
|
||||||
|
import java.net.URI;
|
||||||
|
import java.util.Locale;
|
||||||
|
import java.util.Optional;
|
||||||
|
import com.fasterxml.jackson.core.JsonProcessingException;
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.apache.commons.lang3.StringUtils;
|
||||||
|
import org.springframework.http.HttpMethod;
|
||||||
|
import org.springframework.http.HttpStatus;
|
||||||
|
import org.springframework.http.ProblemDetail;
|
||||||
|
import org.springframework.http.converter.json.Jackson2ObjectMapperBuilder;
|
||||||
|
import org.springframework.http.converter.json.ProblemDetailJacksonMixin;
|
||||||
|
import org.springframework.lang.NonNull;
|
||||||
|
import org.springframework.security.config.web.server.SecurityWebFiltersOrder;
|
||||||
|
import org.springframework.security.web.server.context.ServerSecurityContextRepository;
|
||||||
|
import org.springframework.security.web.server.util.matcher.OrServerWebExchangeMatcher;
|
||||||
|
import org.springframework.security.web.server.util.matcher.ServerWebExchangeMatcher;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
import org.springframework.web.server.ResponseStatusException;
|
||||||
|
import org.springframework.web.server.ServerWebExchange;
|
||||||
|
import org.springframework.web.server.WebFilterChain;
|
||||||
|
import reactor.core.publisher.Mono;
|
||||||
|
import run.halo.app.infra.AnonymousUserConst;
|
||||||
|
import run.halo.app.security.AdditionalWebFilter;
|
||||||
|
import run.halo.comment.widget.SettingConfigGetter;
|
||||||
|
|
||||||
|
@Component
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class CommentCaptchaFilter implements AdditionalWebFilter {
|
||||||
|
static final String CAPTCHA_INVALID_TYPE = "https://www.halo.run/probs/captcha-invalid";
|
||||||
|
static final String CAPTCHA_REQUIRED_TYPE = "https://www.halo.run/probs/captcha-required";
|
||||||
|
private final static String CAPTCHA_CODE_HEADER = "X-Captcha-Code";
|
||||||
|
private final static String CAPTCHA_REQUIRED_HEADER = "X-Require-Captcha";
|
||||||
|
private static final String CONTENT_TYPE = "application/problem+json";
|
||||||
|
|
||||||
|
private final ServerWebExchangeMatcher pathMatcher = createPathMatcher();
|
||||||
|
private final ObjectMapper objectMapper = createObjectMapper();
|
||||||
|
|
||||||
|
private final SettingConfigGetter settingConfigGetter;
|
||||||
|
private final CaptchaManager captchaManager;
|
||||||
|
private final CaptchaCookieResolverImpl captchaCookieResolver;
|
||||||
|
private final ServerSecurityContextRepository contextRepository;
|
||||||
|
|
||||||
|
@Override
|
||||||
|
@NonNull
|
||||||
|
public Mono<Void> filter(@NonNull ServerWebExchange exchange, @NonNull WebFilterChain chain) {
|
||||||
|
return pathMatcher.matches(exchange)
|
||||||
|
.filter(ServerWebExchangeMatcher.MatchResult::isMatch)
|
||||||
|
.flatMap(result -> settingConfigGetter.getSecurityConfig())
|
||||||
|
.map(SettingConfigGetter.SecurityConfig::getCaptcha)
|
||||||
|
.filterWhen(captchaConfig -> isAnonymousCommenter(exchange))
|
||||||
|
.switchIfEmpty(chain.filter(exchange).then(Mono.empty()))
|
||||||
|
.flatMap(captchaConfig -> {
|
||||||
|
if (!captchaConfig.isAnonymousCommentCaptcha()) {
|
||||||
|
return chain.filter(exchange);
|
||||||
|
}
|
||||||
|
return validateCaptcha(exchange, chain, captchaConfig);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private Mono<Void> sendCaptchaRequiredResponse(ServerWebExchange exchange,
|
||||||
|
SettingConfigGetter.CaptchaConfig captchaConfig,
|
||||||
|
ResponseStatusException e) {
|
||||||
|
exchange.getResponse().getHeaders().addIfAbsent(CAPTCHA_REQUIRED_HEADER, "true");
|
||||||
|
exchange.getResponse().setStatusCode(HttpStatus.FORBIDDEN);
|
||||||
|
return captchaManager.generate(exchange, captchaConfig)
|
||||||
|
.flatMap(captcha -> {
|
||||||
|
var problemDetail = toProblemDetail(e);
|
||||||
|
problemDetail.setProperty("captcha", captcha.imageBase64());
|
||||||
|
var responseData = getResponseData(problemDetail);
|
||||||
|
exchange.getResponse().getHeaders().addIfAbsent("Content-Type", CONTENT_TYPE);
|
||||||
|
return exchange.getResponse()
|
||||||
|
.writeWith(Mono.just(exchange.getResponse().bufferFactory().wrap(responseData)));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private byte[] getResponseData(ProblemDetail problemDetail) {
|
||||||
|
try {
|
||||||
|
return objectMapper.writeValueAsBytes(problemDetail);
|
||||||
|
} catch (JsonProcessingException e) {
|
||||||
|
throw new RuntimeException(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private Mono<Void> validateCaptcha(ServerWebExchange exchange, WebFilterChain chain,
|
||||||
|
SettingConfigGetter.CaptchaConfig captchaConfig) {
|
||||||
|
var captchaCodeOpt = getCaptchaCode(exchange);
|
||||||
|
var cookie = captchaCookieResolver.resolveCookie(exchange);
|
||||||
|
if (captchaCodeOpt.isEmpty() || cookie == null) {
|
||||||
|
return sendCaptchaRequiredResponse(exchange, captchaConfig, new CaptchaCodeMissingException());
|
||||||
|
}
|
||||||
|
return captchaManager.verify(cookie.getValue(), captchaCodeOpt.get(), captchaConfig.isIgnoreCase())
|
||||||
|
.flatMap(valid -> {
|
||||||
|
if (valid) {
|
||||||
|
captchaCookieResolver.expireCookie(exchange);
|
||||||
|
return chain.filter(exchange);
|
||||||
|
}
|
||||||
|
return sendCaptchaRequiredResponse(exchange, captchaConfig, new InvalidCaptchaCodeException());
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Optional<String> getCaptchaCode(ServerWebExchange exchange) {
|
||||||
|
var captchaCode = exchange.getRequest().getHeaders().getFirst(CAPTCHA_CODE_HEADER);
|
||||||
|
return Optional.ofNullable(captchaCode)
|
||||||
|
.filter(StringUtils::isNotBlank);
|
||||||
|
}
|
||||||
|
|
||||||
|
private OrServerWebExchangeMatcher createPathMatcher() {
|
||||||
|
var commentMatcher = pathMatchers(HttpMethod.POST, "/apis/api.halo.run/v1alpha1/comments");
|
||||||
|
var replyMatcher = pathMatchers(HttpMethod.POST, "/apis/api.halo.run/v1alpha1/comments/{name}/reply");
|
||||||
|
return new OrServerWebExchangeMatcher(commentMatcher, replyMatcher);
|
||||||
|
}
|
||||||
|
|
||||||
|
static class InvalidCaptchaCodeException extends ResponseStatusException {
|
||||||
|
public InvalidCaptchaCodeException() {
|
||||||
|
super(HttpStatus.FORBIDDEN, "验证码错误,请重新输入");
|
||||||
|
setType(URI.create(CAPTCHA_INVALID_TYPE));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static class CaptchaCodeMissingException extends ResponseStatusException {
|
||||||
|
public CaptchaCodeMissingException() {
|
||||||
|
super(HttpStatus.FORBIDDEN, "请先输入验证码");
|
||||||
|
setType(URI.create(CAPTCHA_REQUIRED_TYPE));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ProblemDetail toProblemDetail(ResponseStatusException e) {
|
||||||
|
var problemDetail = e.updateAndGetBody(null, Locale.getDefault());
|
||||||
|
problemDetail.setTitle("Captcha Verification");
|
||||||
|
return problemDetail;
|
||||||
|
}
|
||||||
|
|
||||||
|
static ObjectMapper createObjectMapper() {
|
||||||
|
return Jackson2ObjectMapperBuilder.json()
|
||||||
|
.mixIn(ProblemDetail.class, ProblemDetailJacksonMixin.class)
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
|
||||||
|
Mono<Boolean> isAnonymousCommenter(ServerWebExchange exchange) {
|
||||||
|
return contextRepository.load(exchange)
|
||||||
|
.map(context -> AnonymousUserConst.isAnonymousUser(context.getAuthentication().getName()))
|
||||||
|
.defaultIfEmpty(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public int getOrder() {
|
||||||
|
return SecurityWebFiltersOrder.AUTHORIZATION.getOrder();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
apiVersion: plugin.halo.run/v1alpha1
|
||||||
|
kind: ExtensionDefinition
|
||||||
|
metadata:
|
||||||
|
name: default-comment-widget
|
||||||
|
spec:
|
||||||
|
className: run.halo.comment.widget.DefaultCommentWidget
|
||||||
|
extensionPointName: comment-widget
|
||||||
|
displayName: "默认评论组件"
|
||||||
|
description: "Halo 默认提供的评论组件插件"
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
apiVersion: plugin.halo.run/v1alpha1
|
||||||
|
kind: ReverseProxy
|
||||||
|
metadata:
|
||||||
|
name: reverse-proxy-comment-widget
|
||||||
|
rules:
|
||||||
|
- path: /static/**
|
||||||
|
file:
|
||||||
|
directory: static
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
apiVersion: v1alpha1
|
||||||
|
kind: Role
|
||||||
|
metadata:
|
||||||
|
name: comment-widget-public-apis
|
||||||
|
labels:
|
||||||
|
halo.run/role-template: "true"
|
||||||
|
halo.run/hidden: "true"
|
||||||
|
rbac.authorization.halo.run/aggregate-to-anonymous: "true"
|
||||||
|
annotations:
|
||||||
|
rbac.authorization.halo.run/module: "Comment Widget"
|
||||||
|
rbac.authorization.halo.run/display-name: "Comment Widget Public APIs"
|
||||||
|
rules:
|
||||||
|
- apiGroups: [ "api.commentwidget.halo.run" ]
|
||||||
|
resources: [ "captcha/generate" ]
|
||||||
|
verbs: [ "get" ]
|
||||||
|
- apiGroups: [ "api.commentwidget.halo.run" ]
|
||||||
|
resources: [ "config" ]
|
||||||
|
verbs: [ "list" ]
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user