migrate frontend
@ -0,0 +1,3 @@
|
||||
{
|
||||
"extends": ["prettier"]
|
||||
}
|
@ -0,0 +1,7 @@
|
||||
node_modules
|
||||
.DS_Store
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
|
||||
.yarn/*
|
@ -0,0 +1,6 @@
|
||||
{
|
||||
"printWidth": 140,
|
||||
"useTabs": false,
|
||||
"semi": true,
|
||||
"singleQuote": false
|
||||
}
|
@ -0,0 +1,674 @@
|
||||
GNU GENERAL PUBLIC LICENSE
|
||||
Version 3, 29 June 2007
|
||||
|
||||
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
|
||||
Everyone is permitted to copy and distribute verbatim copies
|
||||
of this license document, but changing it is not allowed.
|
||||
|
||||
Preamble
|
||||
|
||||
The GNU General Public License is a free, copyleft license for
|
||||
software and other kinds of works.
|
||||
|
||||
The licenses for most software and other practical works are designed
|
||||
to take away your freedom to share and change the works. By contrast,
|
||||
the GNU General Public License is intended to guarantee your freedom to
|
||||
share and change all versions of a program--to make sure it remains free
|
||||
software for all its users. We, the Free Software Foundation, use the
|
||||
GNU General Public License for most of our software; it applies also to
|
||||
any other work released this way by its authors. You can apply it to
|
||||
your programs, too.
|
||||
|
||||
When we speak of free software, we are referring to freedom, not
|
||||
price. Our General Public Licenses are designed to make sure that you
|
||||
have the freedom to distribute copies of free software (and charge for
|
||||
them if you wish), that you receive source code or can get it if you
|
||||
want it, that you can change the software or use pieces of it in new
|
||||
free programs, and that you know you can do these things.
|
||||
|
||||
To protect your rights, we need to prevent others from denying you
|
||||
these rights or asking you to surrender the rights. Therefore, you have
|
||||
certain responsibilities if you distribute copies of the software, or if
|
||||
you modify it: responsibilities to respect the freedom of others.
|
||||
|
||||
For example, if you distribute copies of such a program, whether
|
||||
gratis or for a fee, you must pass on to the recipients the same
|
||||
freedoms that you received. You must make sure that they, too, receive
|
||||
or can get the source code. And you must show them these terms so they
|
||||
know their rights.
|
||||
|
||||
Developers that use the GNU GPL protect your rights with two steps:
|
||||
(1) assert copyright on the software, and (2) offer you this License
|
||||
giving you legal permission to copy, distribute and/or modify it.
|
||||
|
||||
For the developers' and authors' protection, the GPL clearly explains
|
||||
that there is no warranty for this free software. For both users' and
|
||||
authors' sake, the GPL requires that modified versions be marked as
|
||||
changed, so that their problems will not be attributed erroneously to
|
||||
authors of previous versions.
|
||||
|
||||
Some devices are designed to deny users access to install or run
|
||||
modified versions of the software inside them, although the manufacturer
|
||||
can do so. This is fundamentally incompatible with the aim of
|
||||
protecting users' freedom to change the software. The systematic
|
||||
pattern of such abuse occurs in the area of products for individuals to
|
||||
use, which is precisely where it is most unacceptable. Therefore, we
|
||||
have designed this version of the GPL to prohibit the practice for those
|
||||
products. If such problems arise substantially in other domains, we
|
||||
stand ready to extend this provision to those domains in future versions
|
||||
of the GPL, as needed to protect the freedom of users.
|
||||
|
||||
Finally, every program is threatened constantly by software patents.
|
||||
States should not allow patents to restrict development and use of
|
||||
software on general-purpose computers, but in those that do, we wish to
|
||||
avoid the special danger that patents applied to a free program could
|
||||
make it effectively proprietary. To prevent this, the GPL assures that
|
||||
patents cannot be used to render the program non-free.
|
||||
|
||||
The precise terms and conditions for copying, distribution and
|
||||
modification follow.
|
||||
|
||||
TERMS AND CONDITIONS
|
||||
|
||||
0. Definitions.
|
||||
|
||||
"This License" refers to version 3 of the GNU General Public License.
|
||||
|
||||
"Copyright" also means copyright-like laws that apply to other kinds of
|
||||
works, such as semiconductor masks.
|
||||
|
||||
"The Program" refers to any copyrightable work licensed under this
|
||||
License. Each licensee is addressed as "you". "Licensees" and
|
||||
"recipients" may be individuals or organizations.
|
||||
|
||||
To "modify" a work means to copy from or adapt all or part of the work
|
||||
in a fashion requiring copyright permission, other than the making of an
|
||||
exact copy. The resulting work is called a "modified version" of the
|
||||
earlier work or a work "based on" the earlier work.
|
||||
|
||||
A "covered work" means either the unmodified Program or a work based
|
||||
on the Program.
|
||||
|
||||
To "propagate" a work means to do anything with it that, without
|
||||
permission, would make you directly or secondarily liable for
|
||||
infringement under applicable copyright law, except executing it on a
|
||||
computer or modifying a private copy. Propagation includes copying,
|
||||
distribution (with or without modification), making available to the
|
||||
public, and in some countries other activities as well.
|
||||
|
||||
To "convey" a work means any kind of propagation that enables other
|
||||
parties to make or receive copies. Mere interaction with a user through
|
||||
a computer network, with no transfer of a copy, is not conveying.
|
||||
|
||||
An interactive user interface displays "Appropriate Legal Notices"
|
||||
to the extent that it includes a convenient and prominently visible
|
||||
feature that (1) displays an appropriate copyright notice, and (2)
|
||||
tells the user that there is no warranty for the work (except to the
|
||||
extent that warranties are provided), that licensees may convey the
|
||||
work under this License, and how to view a copy of this License. If
|
||||
the interface presents a list of user commands or options, such as a
|
||||
menu, a prominent item in the list meets this criterion.
|
||||
|
||||
1. Source Code.
|
||||
|
||||
The "source code" for a work means the preferred form of the work
|
||||
for making modifications to it. "Object code" means any non-source
|
||||
form of a work.
|
||||
|
||||
A "Standard Interface" means an interface that either is an official
|
||||
standard defined by a recognized standards body, or, in the case of
|
||||
interfaces specified for a particular programming language, one that
|
||||
is widely used among developers working in that language.
|
||||
|
||||
The "System Libraries" of an executable work include anything, other
|
||||
than the work as a whole, that (a) is included in the normal form of
|
||||
packaging a Major Component, but which is not part of that Major
|
||||
Component, and (b) serves only to enable use of the work with that
|
||||
Major Component, or to implement a Standard Interface for which an
|
||||
implementation is available to the public in source code form. A
|
||||
"Major Component", in this context, means a major essential component
|
||||
(kernel, window system, and so on) of the specific operating system
|
||||
(if any) on which the executable work runs, or a compiler used to
|
||||
produce the work, or an object code interpreter used to run it.
|
||||
|
||||
The "Corresponding Source" for a work in object code form means all
|
||||
the source code needed to generate, install, and (for an executable
|
||||
work) run the object code and to modify the work, including scripts to
|
||||
control those activities. However, it does not include the work's
|
||||
System Libraries, or general-purpose tools or generally available free
|
||||
programs which are used unmodified in performing those activities but
|
||||
which are not part of the work. For example, Corresponding Source
|
||||
includes interface definition files associated with source files for
|
||||
the work, and the source code for shared libraries and dynamically
|
||||
linked subprograms that the work is specifically designed to require,
|
||||
such as by intimate data communication or control flow between those
|
||||
subprograms and other parts of the work.
|
||||
|
||||
The Corresponding Source need not include anything that users
|
||||
can regenerate automatically from other parts of the Corresponding
|
||||
Source.
|
||||
|
||||
The Corresponding Source for a work in source code form is that
|
||||
same work.
|
||||
|
||||
2. Basic Permissions.
|
||||
|
||||
All rights granted under this License are granted for the term of
|
||||
copyright on the Program, and are irrevocable provided the stated
|
||||
conditions are met. This License explicitly affirms your unlimited
|
||||
permission to run the unmodified Program. The output from running a
|
||||
covered work is covered by this License only if the output, given its
|
||||
content, constitutes a covered work. This License acknowledges your
|
||||
rights of fair use or other equivalent, as provided by copyright law.
|
||||
|
||||
You may make, run and propagate covered works that you do not
|
||||
convey, without conditions so long as your license otherwise remains
|
||||
in force. You may convey covered works to others for the sole purpose
|
||||
of having them make modifications exclusively for you, or provide you
|
||||
with facilities for running those works, provided that you comply with
|
||||
the terms of this License in conveying all material for which you do
|
||||
not control copyright. Those thus making or running the covered works
|
||||
for you must do so exclusively on your behalf, under your direction
|
||||
and control, on terms that prohibit them from making any copies of
|
||||
your copyrighted material outside their relationship with you.
|
||||
|
||||
Conveying under any other circumstances is permitted solely under
|
||||
the conditions stated below. Sublicensing is not allowed; section 10
|
||||
makes it unnecessary.
|
||||
|
||||
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
|
||||
|
||||
No covered work shall be deemed part of an effective technological
|
||||
measure under any applicable law fulfilling obligations under article
|
||||
11 of the WIPO copyright treaty adopted on 20 December 1996, or
|
||||
similar laws prohibiting or restricting circumvention of such
|
||||
measures.
|
||||
|
||||
When you convey a covered work, you waive any legal power to forbid
|
||||
circumvention of technological measures to the extent such circumvention
|
||||
is effected by exercising rights under this License with respect to
|
||||
the covered work, and you disclaim any intention to limit operation or
|
||||
modification of the work as a means of enforcing, against the work's
|
||||
users, your or third parties' legal rights to forbid circumvention of
|
||||
technological measures.
|
||||
|
||||
4. Conveying Verbatim Copies.
|
||||
|
||||
You may convey verbatim copies of the Program's source code as you
|
||||
receive it, in any medium, provided that you conspicuously and
|
||||
appropriately publish on each copy an appropriate copyright notice;
|
||||
keep intact all notices stating that this License and any
|
||||
non-permissive terms added in accord with section 7 apply to the code;
|
||||
keep intact all notices of the absence of any warranty; and give all
|
||||
recipients a copy of this License along with the Program.
|
||||
|
||||
You may charge any price or no price for each copy that you convey,
|
||||
and you may offer support or warranty protection for a fee.
|
||||
|
||||
5. Conveying Modified Source Versions.
|
||||
|
||||
You may convey a work based on the Program, or the modifications to
|
||||
produce it from the Program, in the form of source code under the
|
||||
terms of section 4, provided that you also meet all of these conditions:
|
||||
|
||||
a) The work must carry prominent notices stating that you modified
|
||||
it, and giving a relevant date.
|
||||
|
||||
b) The work must carry prominent notices stating that it is
|
||||
released under this License and any conditions added under section
|
||||
7. This requirement modifies the requirement in section 4 to
|
||||
"keep intact all notices".
|
||||
|
||||
c) You must license the entire work, as a whole, under this
|
||||
License to anyone who comes into possession of a copy. This
|
||||
License will therefore apply, along with any applicable section 7
|
||||
additional terms, to the whole of the work, and all its parts,
|
||||
regardless of how they are packaged. This License gives no
|
||||
permission to license the work in any other way, but it does not
|
||||
invalidate such permission if you have separately received it.
|
||||
|
||||
d) If the work has interactive user interfaces, each must display
|
||||
Appropriate Legal Notices; however, if the Program has interactive
|
||||
interfaces that do not display Appropriate Legal Notices, your
|
||||
work need not make them do so.
|
||||
|
||||
A compilation of a covered work with other separate and independent
|
||||
works, which are not by their nature extensions of the covered work,
|
||||
and which are not combined with it such as to form a larger program,
|
||||
in or on a volume of a storage or distribution medium, is called an
|
||||
"aggregate" if the compilation and its resulting copyright are not
|
||||
used to limit the access or legal rights of the compilation's users
|
||||
beyond what the individual works permit. Inclusion of a covered work
|
||||
in an aggregate does not cause this License to apply to the other
|
||||
parts of the aggregate.
|
||||
|
||||
6. Conveying Non-Source Forms.
|
||||
|
||||
You may convey a covered work in object code form under the terms
|
||||
of sections 4 and 5, provided that you also convey the
|
||||
machine-readable Corresponding Source under the terms of this License,
|
||||
in one of these ways:
|
||||
|
||||
a) Convey the object code in, or embodied in, a physical product
|
||||
(including a physical distribution medium), accompanied by the
|
||||
Corresponding Source fixed on a durable physical medium
|
||||
customarily used for software interchange.
|
||||
|
||||
b) Convey the object code in, or embodied in, a physical product
|
||||
(including a physical distribution medium), accompanied by a
|
||||
written offer, valid for at least three years and valid for as
|
||||
long as you offer spare parts or customer support for that product
|
||||
model, to give anyone who possesses the object code either (1) a
|
||||
copy of the Corresponding Source for all the software in the
|
||||
product that is covered by this License, on a durable physical
|
||||
medium customarily used for software interchange, for a price no
|
||||
more than your reasonable cost of physically performing this
|
||||
conveying of source, or (2) access to copy the
|
||||
Corresponding Source from a network server at no charge.
|
||||
|
||||
c) Convey individual copies of the object code with a copy of the
|
||||
written offer to provide the Corresponding Source. This
|
||||
alternative is allowed only occasionally and noncommercially, and
|
||||
only if you received the object code with such an offer, in accord
|
||||
with subsection 6b.
|
||||
|
||||
d) Convey the object code by offering access from a designated
|
||||
place (gratis or for a charge), and offer equivalent access to the
|
||||
Corresponding Source in the same way through the same place at no
|
||||
further charge. You need not require recipients to copy the
|
||||
Corresponding Source along with the object code. If the place to
|
||||
copy the object code is a network server, the Corresponding Source
|
||||
may be on a different server (operated by you or a third party)
|
||||
that supports equivalent copying facilities, provided you maintain
|
||||
clear directions next to the object code saying where to find the
|
||||
Corresponding Source. Regardless of what server hosts the
|
||||
Corresponding Source, you remain obligated to ensure that it is
|
||||
available for as long as needed to satisfy these requirements.
|
||||
|
||||
e) Convey the object code using peer-to-peer transmission, provided
|
||||
you inform other peers where the object code and Corresponding
|
||||
Source of the work are being offered to the general public at no
|
||||
charge under subsection 6d.
|
||||
|
||||
A separable portion of the object code, whose source code is excluded
|
||||
from the Corresponding Source as a System Library, need not be
|
||||
included in conveying the object code work.
|
||||
|
||||
A "User Product" is either (1) a "consumer product", which means any
|
||||
tangible personal property which is normally used for personal, family,
|
||||
or household purposes, or (2) anything designed or sold for incorporation
|
||||
into a dwelling. In determining whether a product is a consumer product,
|
||||
doubtful cases shall be resolved in favor of coverage. For a particular
|
||||
product received by a particular user, "normally used" refers to a
|
||||
typical or common use of that class of product, regardless of the status
|
||||
of the particular user or of the way in which the particular user
|
||||
actually uses, or expects or is expected to use, the product. A product
|
||||
is a consumer product regardless of whether the product has substantial
|
||||
commercial, industrial or non-consumer uses, unless such uses represent
|
||||
the only significant mode of use of the product.
|
||||
|
||||
"Installation Information" for a User Product means any methods,
|
||||
procedures, authorization keys, or other information required to install
|
||||
and execute modified versions of a covered work in that User Product from
|
||||
a modified version of its Corresponding Source. The information must
|
||||
suffice to ensure that the continued functioning of the modified object
|
||||
code is in no case prevented or interfered with solely because
|
||||
modification has been made.
|
||||
|
||||
If you convey an object code work under this section in, or with, or
|
||||
specifically for use in, a User Product, and the conveying occurs as
|
||||
part of a transaction in which the right of possession and use of the
|
||||
User Product is transferred to the recipient in perpetuity or for a
|
||||
fixed term (regardless of how the transaction is characterized), the
|
||||
Corresponding Source conveyed under this section must be accompanied
|
||||
by the Installation Information. But this requirement does not apply
|
||||
if neither you nor any third party retains the ability to install
|
||||
modified object code on the User Product (for example, the work has
|
||||
been installed in ROM).
|
||||
|
||||
The requirement to provide Installation Information does not include a
|
||||
requirement to continue to provide support service, warranty, or updates
|
||||
for a work that has been modified or installed by the recipient, or for
|
||||
the User Product in which it has been modified or installed. Access to a
|
||||
network may be denied when the modification itself materially and
|
||||
adversely affects the operation of the network or violates the rules and
|
||||
protocols for communication across the network.
|
||||
|
||||
Corresponding Source conveyed, and Installation Information provided,
|
||||
in accord with this section must be in a format that is publicly
|
||||
documented (and with an implementation available to the public in
|
||||
source code form), and must require no special password or key for
|
||||
unpacking, reading or copying.
|
||||
|
||||
7. Additional Terms.
|
||||
|
||||
"Additional permissions" are terms that supplement the terms of this
|
||||
License by making exceptions from one or more of its conditions.
|
||||
Additional permissions that are applicable to the entire Program shall
|
||||
be treated as though they were included in this License, to the extent
|
||||
that they are valid under applicable law. If additional permissions
|
||||
apply only to part of the Program, that part may be used separately
|
||||
under those permissions, but the entire Program remains governed by
|
||||
this License without regard to the additional permissions.
|
||||
|
||||
When you convey a copy of a covered work, you may at your option
|
||||
remove any additional permissions from that copy, or from any part of
|
||||
it. (Additional permissions may be written to require their own
|
||||
removal in certain cases when you modify the work.) You may place
|
||||
additional permissions on material, added by you to a covered work,
|
||||
for which you have or can give appropriate copyright permission.
|
||||
|
||||
Notwithstanding any other provision of this License, for material you
|
||||
add to a covered work, you may (if authorized by the copyright holders of
|
||||
that material) supplement the terms of this License with terms:
|
||||
|
||||
a) Disclaiming warranty or limiting liability differently from the
|
||||
terms of sections 15 and 16 of this License; or
|
||||
|
||||
b) Requiring preservation of specified reasonable legal notices or
|
||||
author attributions in that material or in the Appropriate Legal
|
||||
Notices displayed by works containing it; or
|
||||
|
||||
c) Prohibiting misrepresentation of the origin of that material, or
|
||||
requiring that modified versions of such material be marked in
|
||||
reasonable ways as different from the original version; or
|
||||
|
||||
d) Limiting the use for publicity purposes of names of licensors or
|
||||
authors of the material; or
|
||||
|
||||
e) Declining to grant rights under trademark law for use of some
|
||||
trade names, trademarks, or service marks; or
|
||||
|
||||
f) Requiring indemnification of licensors and authors of that
|
||||
material by anyone who conveys the material (or modified versions of
|
||||
it) with contractual assumptions of liability to the recipient, for
|
||||
any liability that these contractual assumptions directly impose on
|
||||
those licensors and authors.
|
||||
|
||||
All other non-permissive additional terms are considered "further
|
||||
restrictions" within the meaning of section 10. If the Program as you
|
||||
received it, or any part of it, contains a notice stating that it is
|
||||
governed by this License along with a term that is a further
|
||||
restriction, you may remove that term. If a license document contains
|
||||
a further restriction but permits relicensing or conveying under this
|
||||
License, you may add to a covered work material governed by the terms
|
||||
of that license document, provided that the further restriction does
|
||||
not survive such relicensing or conveying.
|
||||
|
||||
If you add terms to a covered work in accord with this section, you
|
||||
must place, in the relevant source files, a statement of the
|
||||
additional terms that apply to those files, or a notice indicating
|
||||
where to find the applicable terms.
|
||||
|
||||
Additional terms, permissive or non-permissive, may be stated in the
|
||||
form of a separately written license, or stated as exceptions;
|
||||
the above requirements apply either way.
|
||||
|
||||
8. Termination.
|
||||
|
||||
You may not propagate or modify a covered work except as expressly
|
||||
provided under this License. Any attempt otherwise to propagate or
|
||||
modify it is void, and will automatically terminate your rights under
|
||||
this License (including any patent licenses granted under the third
|
||||
paragraph of section 11).
|
||||
|
||||
However, if you cease all violation of this License, then your
|
||||
license from a particular copyright holder is reinstated (a)
|
||||
provisionally, unless and until the copyright holder explicitly and
|
||||
finally terminates your license, and (b) permanently, if the copyright
|
||||
holder fails to notify you of the violation by some reasonable means
|
||||
prior to 60 days after the cessation.
|
||||
|
||||
Moreover, your license from a particular copyright holder is
|
||||
reinstated permanently if the copyright holder notifies you of the
|
||||
violation by some reasonable means, this is the first time you have
|
||||
received notice of violation of this License (for any work) from that
|
||||
copyright holder, and you cure the violation prior to 30 days after
|
||||
your receipt of the notice.
|
||||
|
||||
Termination of your rights under this section does not terminate the
|
||||
licenses of parties who have received copies or rights from you under
|
||||
this License. If your rights have been terminated and not permanently
|
||||
reinstated, you do not qualify to receive new licenses for the same
|
||||
material under section 10.
|
||||
|
||||
9. Acceptance Not Required for Having Copies.
|
||||
|
||||
You are not required to accept this License in order to receive or
|
||||
run a copy of the Program. Ancillary propagation of a covered work
|
||||
occurring solely as a consequence of using peer-to-peer transmission
|
||||
to receive a copy likewise does not require acceptance. However,
|
||||
nothing other than this License grants you permission to propagate or
|
||||
modify any covered work. These actions infringe copyright if you do
|
||||
not accept this License. Therefore, by modifying or propagating a
|
||||
covered work, you indicate your acceptance of this License to do so.
|
||||
|
||||
10. Automatic Licensing of Downstream Recipients.
|
||||
|
||||
Each time you convey a covered work, the recipient automatically
|
||||
receives a license from the original licensors, to run, modify and
|
||||
propagate that work, subject to this License. You are not responsible
|
||||
for enforcing compliance by third parties with this License.
|
||||
|
||||
An "entity transaction" is a transaction transferring control of an
|
||||
organization, or substantially all assets of one, or subdividing an
|
||||
organization, or merging organizations. If propagation of a covered
|
||||
work results from an entity transaction, each party to that
|
||||
transaction who receives a copy of the work also receives whatever
|
||||
licenses to the work the party's predecessor in interest had or could
|
||||
give under the previous paragraph, plus a right to possession of the
|
||||
Corresponding Source of the work from the predecessor in interest, if
|
||||
the predecessor has it or can get it with reasonable efforts.
|
||||
|
||||
You may not impose any further restrictions on the exercise of the
|
||||
rights granted or affirmed under this License. For example, you may
|
||||
not impose a license fee, royalty, or other charge for exercise of
|
||||
rights granted under this License, and you may not initiate litigation
|
||||
(including a cross-claim or counterclaim in a lawsuit) alleging that
|
||||
any patent claim is infringed by making, using, selling, offering for
|
||||
sale, or importing the Program or any portion of it.
|
||||
|
||||
11. Patents.
|
||||
|
||||
A "contributor" is a copyright holder who authorizes use under this
|
||||
License of the Program or a work on which the Program is based. The
|
||||
work thus licensed is called the contributor's "contributor version".
|
||||
|
||||
A contributor's "essential patent claims" are all patent claims
|
||||
owned or controlled by the contributor, whether already acquired or
|
||||
hereafter acquired, that would be infringed by some manner, permitted
|
||||
by this License, of making, using, or selling its contributor version,
|
||||
but do not include claims that would be infringed only as a
|
||||
consequence of further modification of the contributor version. For
|
||||
purposes of this definition, "control" includes the right to grant
|
||||
patent sublicenses in a manner consistent with the requirements of
|
||||
this License.
|
||||
|
||||
Each contributor grants you a non-exclusive, worldwide, royalty-free
|
||||
patent license under the contributor's essential patent claims, to
|
||||
make, use, sell, offer for sale, import and otherwise run, modify and
|
||||
propagate the contents of its contributor version.
|
||||
|
||||
In the following three paragraphs, a "patent license" is any express
|
||||
agreement or commitment, however denominated, not to enforce a patent
|
||||
(such as an express permission to practice a patent or covenant not to
|
||||
sue for patent infringement). To "grant" such a patent license to a
|
||||
party means to make such an agreement or commitment not to enforce a
|
||||
patent against the party.
|
||||
|
||||
If you convey a covered work, knowingly relying on a patent license,
|
||||
and the Corresponding Source of the work is not available for anyone
|
||||
to copy, free of charge and under the terms of this License, through a
|
||||
publicly available network server or other readily accessible means,
|
||||
then you must either (1) cause the Corresponding Source to be so
|
||||
available, or (2) arrange to deprive yourself of the benefit of the
|
||||
patent license for this particular work, or (3) arrange, in a manner
|
||||
consistent with the requirements of this License, to extend the patent
|
||||
license to downstream recipients. "Knowingly relying" means you have
|
||||
actual knowledge that, but for the patent license, your conveying the
|
||||
covered work in a country, or your recipient's use of the covered work
|
||||
in a country, would infringe one or more identifiable patents in that
|
||||
country that you have reason to believe are valid.
|
||||
|
||||
If, pursuant to or in connection with a single transaction or
|
||||
arrangement, you convey, or propagate by procuring conveyance of, a
|
||||
covered work, and grant a patent license to some of the parties
|
||||
receiving the covered work authorizing them to use, propagate, modify
|
||||
or convey a specific copy of the covered work, then the patent license
|
||||
you grant is automatically extended to all recipients of the covered
|
||||
work and works based on it.
|
||||
|
||||
A patent license is "discriminatory" if it does not include within
|
||||
the scope of its coverage, prohibits the exercise of, or is
|
||||
conditioned on the non-exercise of one or more of the rights that are
|
||||
specifically granted under this License. You may not convey a covered
|
||||
work if you are a party to an arrangement with a third party that is
|
||||
in the business of distributing software, under which you make payment
|
||||
to the third party based on the extent of your activity of conveying
|
||||
the work, and under which the third party grants, to any of the
|
||||
parties who would receive the covered work from you, a discriminatory
|
||||
patent license (a) in connection with copies of the covered work
|
||||
conveyed by you (or copies made from those copies), or (b) primarily
|
||||
for and in connection with specific products or compilations that
|
||||
contain the covered work, unless you entered into that arrangement,
|
||||
or that patent license was granted, prior to 28 March 2007.
|
||||
|
||||
Nothing in this License shall be construed as excluding or limiting
|
||||
any implied license or other defenses to infringement that may
|
||||
otherwise be available to you under applicable patent law.
|
||||
|
||||
12. No Surrender of Others' Freedom.
|
||||
|
||||
If conditions are imposed on you (whether by court order, agreement or
|
||||
otherwise) that contradict the conditions of this License, they do not
|
||||
excuse you from the conditions of this License. If you cannot convey a
|
||||
covered work so as to satisfy simultaneously your obligations under this
|
||||
License and any other pertinent obligations, then as a consequence you may
|
||||
not convey it at all. For example, if you agree to terms that obligate you
|
||||
to collect a royalty for further conveying from those to whom you convey
|
||||
the Program, the only way you could satisfy both those terms and this
|
||||
License would be to refrain entirely from conveying the Program.
|
||||
|
||||
13. Use with the GNU Affero General Public License.
|
||||
|
||||
Notwithstanding any other provision of this License, you have
|
||||
permission to link or combine any covered work with a work licensed
|
||||
under version 3 of the GNU Affero General Public License into a single
|
||||
combined work, and to convey the resulting work. The terms of this
|
||||
License will continue to apply to the part which is the covered work,
|
||||
but the special requirements of the GNU Affero General Public License,
|
||||
section 13, concerning interaction through a network will apply to the
|
||||
combination as such.
|
||||
|
||||
14. Revised Versions of this License.
|
||||
|
||||
The Free Software Foundation may publish revised and/or new versions of
|
||||
the GNU General Public License from time to time. Such new versions will
|
||||
be similar in spirit to the present version, but may differ in detail to
|
||||
address new problems or concerns.
|
||||
|
||||
Each version is given a distinguishing version number. If the
|
||||
Program specifies that a certain numbered version of the GNU General
|
||||
Public License "or any later version" applies to it, you have the
|
||||
option of following the terms and conditions either of that numbered
|
||||
version or of any later version published by the Free Software
|
||||
Foundation. If the Program does not specify a version number of the
|
||||
GNU General Public License, you may choose any version ever published
|
||||
by the Free Software Foundation.
|
||||
|
||||
If the Program specifies that a proxy can decide which future
|
||||
versions of the GNU General Public License can be used, that proxy's
|
||||
public statement of acceptance of a version permanently authorizes you
|
||||
to choose that version for the Program.
|
||||
|
||||
Later license versions may give you additional or different
|
||||
permissions. However, no additional obligations are imposed on any
|
||||
author or copyright holder as a result of your choosing to follow a
|
||||
later version.
|
||||
|
||||
15. Disclaimer of Warranty.
|
||||
|
||||
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
|
||||
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
|
||||
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
|
||||
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
|
||||
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
|
||||
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
|
||||
IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
|
||||
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
|
||||
|
||||
16. Limitation of Liability.
|
||||
|
||||
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
|
||||
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
|
||||
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
|
||||
GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
|
||||
USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
|
||||
DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
|
||||
PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
|
||||
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
|
||||
SUCH DAMAGES.
|
||||
|
||||
17. Interpretation of Sections 15 and 16.
|
||||
|
||||
If the disclaimer of warranty and limitation of liability provided
|
||||
above cannot be given local legal effect according to their terms,
|
||||
reviewing courts shall apply local law that most closely approximates
|
||||
an absolute waiver of all civil liability in connection with the
|
||||
Program, unless a warranty or assumption of liability accompanies a
|
||||
copy of the Program in return for a fee.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
How to Apply These Terms to Your New Programs
|
||||
|
||||
If you develop a new program, and you want it to be of the greatest
|
||||
possible use to the public, the best way to achieve this is to make it
|
||||
free software which everyone can redistribute and change under these terms.
|
||||
|
||||
To do so, attach the following notices to the program. It is safest
|
||||
to attach them to the start of each source file to most effectively
|
||||
state the exclusion of warranty; and each file should have at least
|
||||
the "copyright" line and a pointer to where the full notice is found.
|
||||
|
||||
<one line to give the program's name and a brief idea of what it does.>
|
||||
Copyright (C) <year> <name of author>
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
Also add information on how to contact you by electronic and paper mail.
|
||||
|
||||
If the program does terminal interaction, make it output a short
|
||||
notice like this when it starts in an interactive mode:
|
||||
|
||||
<program> Copyright (C) <year> <name of author>
|
||||
This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
|
||||
This is free software, and you are welcome to redistribute it
|
||||
under certain conditions; type `show c' for details.
|
||||
|
||||
The hypothetical commands `show w' and `show c' should show the appropriate
|
||||
parts of the General Public License. Of course, your program's commands
|
||||
might be different; for a GUI interface, you would use an "about box".
|
||||
|
||||
You should also get your employer (if you work as a programmer) or school,
|
||||
if any, to sign a "copyright disclaimer" for the program, if necessary.
|
||||
For more information on this, and how to apply and follow the GNU GPL, see
|
||||
<https://www.gnu.org/licenses/>.
|
||||
|
||||
The GNU General Public License does not permit incorporating your program
|
||||
into proprietary programs. If your program is a subroutine library, you
|
||||
may consider it more useful to permit linking proprietary applications with
|
||||
the library. If this is what you want to do, use the GNU Lesser General
|
||||
Public License instead of this License. But first, please read
|
||||
<https://www.gnu.org/licenses/why-not-lgpl.html>.
|
@ -0,0 +1 @@
|
||||
# Memos web
|
@ -0,0 +1,25 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" href="/logo.svg" sizes="64x64" type="image/*" />
|
||||
<meta name="theme-color" content="#f6f5f4" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, minimum-scale=1, user-scalable=no" />
|
||||
<title>Memos</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
<!-- Global site tag (gtag.js) - Google Analytics -->
|
||||
<script async src="https://www.googletagmanager.com/gtag/js?id=G-QMWPX445H6"></script>
|
||||
<script>
|
||||
window.dataLayer = window.dataLayer || [];
|
||||
function gtag() {
|
||||
dataLayer.push(arguments);
|
||||
}
|
||||
gtag("js", new Date());
|
||||
|
||||
gtag("config", "G-QMWPX445H6");
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
@ -0,0 +1,25 @@
|
||||
{
|
||||
"name": "memos",
|
||||
"version": "2.0.6",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc && vite build",
|
||||
"serve": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"prismjs": "^1.25.0",
|
||||
"react": "^17.0.2",
|
||||
"react-dom": "^17.0.2",
|
||||
"tiny-undo": "^0.0.8"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/prismjs": "^1.16.6",
|
||||
"@types/react": "^17.0.2",
|
||||
"@types/react-dom": "^17.0.2",
|
||||
"@vitejs/plugin-react": "^1.0.0",
|
||||
"less": "^4.1.1",
|
||||
"typescript": "^4.3.2",
|
||||
"vite": "^2.6.14"
|
||||
},
|
||||
"license": "MIT"
|
||||
}
|
@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 0 24 24" width="24px" fill="#37352f"><path d="M0 0h24v24H0V0z" fill="none"/><path d="M15.41 7.41L14 6l-6 6 6 6 1.41-1.41L10.83 12l4.58-4.59z"/></svg>
|
After Width: | Height: | Size: 214 B |
@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 0 24 24" width="24px" fill="#FFFFFF"><path d="M0 0h24v24H0V0z" fill="none"/><path d="M10 6L8.59 7.41 13.17 12l-4.58 4.59L10 18l6-6-6-6z"/></svg>
|
After Width: | Height: | Size: 209 B |
@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 0 24 24" width="24px" fill="#37352f"><path d="M0 0h24v24H0V0z" fill="none"/><path d="M10 6L8.59 7.41 13.17 12l-4.58 4.59L10 18l6-6-6-6z"/></svg>
|
After Width: | Height: | Size: 209 B |
@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 0 24 24" width="24px" fill="#37352f"><path d="M0 0h24v24H0V0z" fill="none"/><path d="M19 3H5c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2zm0 16H5V5h14v14zM17.99 9l-1.41-1.42-6.59 6.59-2.58-2.57-1.42 1.41 4 3.99z"/></svg>
|
After Width: | Height: | Size: 308 B |
@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 0 24 24" width="24px" fill="#37352f"><path d="M0 0h24v24H0V0z" fill="none"/><path d="M19 5v14H5V5h14m0-2H5c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2z"/></svg>
|
After Width: | Height: | Size: 249 B |
@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 0 24 24" width="24px" fill="#37352f"><path d="M0 0h24v24H0V0z" fill="none"/><path d="M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12 19 6.41z"/></svg>
|
After Width: | Height: | Size: 268 B |
@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 0 24 24" width="24px" fill="#FFFFFF"><path d="M0 0h24v24H0V0z" fill="none"/><path d="M14.06 9.02l.92.92L5.92 19H5v-.92l9.06-9.06M17.66 3c-.25 0-.51.1-.7.29l-1.83 1.83 3.75 3.75 1.83-1.83c.39-.39.39-1.02 0-1.41l-2.34-2.34c-.2-.2-.45-.29-.71-.29zm-3.6 3.19L3 17.25V21h3.75L17.81 9.94l-3.75-3.75z"/></svg>
|
After Width: | Height: | Size: 367 B |
@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 0 24 24" width="24px" fill="#37352f"><path d="M0 0h24v24H0V0z" fill="none"/><path d="M14.06 9.02l.92.92L5.92 19H5v-.92l9.06-9.06M17.66 3c-.25 0-.51.1-.7.29l-1.83 1.83 3.75 3.75 1.83-1.83c.39-.39.39-1.02 0-1.41l-2.34-2.34c-.2-.2-.45-.29-.71-.29zm-3.6 3.19L3 17.25V21h3.75L17.81 9.94l-3.75-3.75z"/></svg>
|
After Width: | Height: | Size: 367 B |
@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 0 24 24" width="24px" fill="#37352f"><path d="M0 0h24v24H0V0z" fill="none"/><path d="M3 18h18v-2H3v2zm0-5h18v-2H3v2zm0-7v2h18V6H3z"/></svg>
|
After Width: | Height: | Size: 204 B |
@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 0 24 24" width="24px" fill="#FFFFFF"><path d="M0 0h24v24H0V0z" fill="none"/><path d="M6 10c-1.1 0-2 .9-2 2s.9 2 2 2 2-.9 2-2-.9-2-2-2zm12 0c-1.1 0-2 .9-2 2s.9 2 2 2 2-.9 2-2-.9-2-2-2zm-6 0c-1.1 0-2 .9-2 2s.9 2 2 2 2-.9 2-2-.9-2-2-2z"/></svg>
|
After Width: | Height: | Size: 306 B |
@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 0 24 24" width="24px" fill="#37352f"><path d="M0 0h24v24H0V0z" fill="none"/><path d="M6 10c-1.1 0-2 .9-2 2s.9 2 2 2 2-.9 2-2-.9-2-2-2zm12 0c-1.1 0-2 .9-2 2s.9 2 2 2 2-.9 2-2-.9-2-2-2zm-6 0c-1.1 0-2 .9-2 2s.9 2 2 2 2-.9 2-2-.9-2-2-2z"/></svg>
|
After Width: | Height: | Size: 306 B |
@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 0 24 24" width="24px" fill="#37352f"><path d="M0 0h24v24H0V0z" fill="none"/><path d="M15.5 14h-.79l-.28-.27C15.41 12.59 16 11.11 16 9.5 16 5.91 13.09 3 9.5 3S3 5.91 3 9.5 5.91 16 9.5 16c1.61 0 3.09-.59 4.23-1.57l.27.28v.79l5 4.99L20.49 19l-4.99-5zm-6 0C7.01 14 5 11.99 5 9.5S7.01 5 9.5 5 14 7.01 14 9.5 11.99 14 9.5 14z"/></svg>
|
After Width: | Height: | Size: 393 B |
@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" enable-background="new 0 0 24 24" height="24px" viewBox="0 0 24 24" width="24px" fill="#37352f"><g><rect fill="none" height="24" width="24"/></g><g><path d="M16,5l-1.42,1.42l-1.59-1.59V16h-1.98V4.83L9.42,6.42L8,5l4-4L16,5z M20,10v11c0,1.1-0.9,2-2,2H6c-1.11,0-2-0.9-2-2V10 c0-1.11,0.89-2,2-2h3v2H6v11h12V10h-3V8h3C19.1,8,20,8.89,20,10z"/></g></svg>
|
After Width: | Height: | Size: 387 B |
@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" enable-background="new 0 0 24 24" height="24px" viewBox="0 0 24 24" width="24px" fill="#000000"><g><rect fill="none" height="24" width="24"/></g><g><path d="M20,10V8h-4V4h-2v4h-4V4H8v4H4v2h4v4H4v2h4v4h2v-4h4v4h2v-4h4v-2h-4v-4H20z M14,14h-4v-4h4V14z"/></g></svg>
|
After Width: | Height: | Size: 301 B |
@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"><text x="0.1em" y=".9em" font-size="90">✍️</text></svg>
|
After Width: | Height: | Size: 121 B |
@ -0,0 +1,30 @@
|
||||
import { useContext, useEffect } from "react";
|
||||
import appContext from "./stores/appContext";
|
||||
import { appRouterSwitch } from "./routers";
|
||||
import { globalStateService } from "./services";
|
||||
import "./less/app.less";
|
||||
import 'prismjs/themes/prism.css';
|
||||
|
||||
function App() {
|
||||
const {
|
||||
locationState: { pathname },
|
||||
} = useContext(appContext);
|
||||
|
||||
useEffect(() => {
|
||||
const handleWindowResize = () => {
|
||||
globalStateService.setIsMobileView(document.body.clientWidth <= 875);
|
||||
};
|
||||
|
||||
handleWindowResize();
|
||||
|
||||
window.addEventListener("resize", handleWindowResize);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener("resize", handleWindowResize);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return <>{appRouterSwitch(pathname)}</>;
|
||||
}
|
||||
|
||||
export default App;
|
@ -0,0 +1,123 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { validate, ValidatorConfig } from "../helpers/validator";
|
||||
import { userService } from "../services";
|
||||
import { showDialog } from "./Dialog";
|
||||
import toastHelper from "./Toast";
|
||||
import "../less/change-password-dialog.less";
|
||||
|
||||
const validateConfig: ValidatorConfig = {
|
||||
minLength: 4,
|
||||
maxLength: 24,
|
||||
noSpace: true,
|
||||
noChinese: true,
|
||||
};
|
||||
|
||||
interface Props extends DialogProps {}
|
||||
|
||||
const ChangePasswordDialog: React.FC<Props> = ({ destroy }: Props) => {
|
||||
const [oldPassword, setOldPassword] = useState("");
|
||||
const [newPassword, setNewPassword] = useState("");
|
||||
const [newPasswordAgain, setNewPasswordAgain] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
// do nth
|
||||
}, []);
|
||||
|
||||
const handleCloseBtnClick = () => {
|
||||
destroy();
|
||||
};
|
||||
|
||||
const handleOldPasswordChanged = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const text = e.target.value as string;
|
||||
setOldPassword(text);
|
||||
};
|
||||
|
||||
const handleNewPasswordChanged = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const text = e.target.value as string;
|
||||
setNewPassword(text);
|
||||
};
|
||||
|
||||
const handleNewPasswordAgainChanged = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const text = e.target.value as string;
|
||||
setNewPasswordAgain(text);
|
||||
};
|
||||
|
||||
const handleSaveBtnClick = async () => {
|
||||
if (oldPassword === "" || newPassword === "" || newPasswordAgain === "") {
|
||||
toastHelper.error("密码不能为空");
|
||||
return;
|
||||
}
|
||||
|
||||
if (newPassword !== newPasswordAgain) {
|
||||
toastHelper.error("新密码两次输入不一致");
|
||||
setNewPasswordAgain("");
|
||||
return;
|
||||
}
|
||||
|
||||
const passwordValidResult = validate(newPassword, validateConfig);
|
||||
if (!passwordValidResult.result) {
|
||||
toastHelper.error("密码 " + passwordValidResult.reason);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const isValid = await userService.checkPasswordValid(oldPassword);
|
||||
|
||||
if (!isValid) {
|
||||
toastHelper.error("旧密码不匹配");
|
||||
setOldPassword("");
|
||||
return;
|
||||
}
|
||||
|
||||
await userService.updatePassword(newPassword);
|
||||
toastHelper.info("密码修改成功!");
|
||||
handleCloseBtnClick();
|
||||
} catch (error: any) {
|
||||
toastHelper.error(error);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="dialog-header-container">
|
||||
<p className="title-text">修改密码</p>
|
||||
<button className="btn close-btn" onClick={handleCloseBtnClick}>
|
||||
<img className="icon-img" src="/icons/close.svg" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="dialog-content-container">
|
||||
<label className="form-label input-form-label">
|
||||
<span className={"normal-text " + (oldPassword === "" ? "" : "not-null")}>旧密码</span>
|
||||
<input type="password" value={oldPassword} onChange={handleOldPasswordChanged} />
|
||||
</label>
|
||||
<label className="form-label input-form-label">
|
||||
<span className={"normal-text " + (newPassword === "" ? "" : "not-null")}>新密码</span>
|
||||
<input type="password" value={newPassword} onChange={handleNewPasswordChanged} />
|
||||
</label>
|
||||
<label className="form-label input-form-label">
|
||||
<span className={"normal-text " + (newPasswordAgain === "" ? "" : "not-null")}>再次输入新密码</span>
|
||||
<input type="password" value={newPasswordAgain} onChange={handleNewPasswordAgainChanged} />
|
||||
</label>
|
||||
<div className="btns-container">
|
||||
<span className="btn cancel-btn" onClick={handleCloseBtnClick}>
|
||||
取消
|
||||
</span>
|
||||
<span className="btn confirm-btn" onClick={handleSaveBtnClick}>
|
||||
保存
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
function showChangePasswordDialog() {
|
||||
showDialog(
|
||||
{
|
||||
className: "change-password-dialog",
|
||||
},
|
||||
ChangePasswordDialog
|
||||
);
|
||||
}
|
||||
|
||||
export default showChangePasswordDialog;
|
@ -0,0 +1,307 @@
|
||||
import React, { memo, useCallback, useEffect, useState } from "react";
|
||||
import { memoService, queryService } from "../services";
|
||||
import { checkShouldShowMemoWithFilters, filterConsts, getDefaultFilter, relationConsts } from "../helpers/filter";
|
||||
import useLoading from "../hooks/useLoading";
|
||||
import { showDialog } from "./Dialog";
|
||||
import toastHelper from "./Toast";
|
||||
import Selector from "./common/Selector";
|
||||
import "../less/create-query-dialog.less";
|
||||
|
||||
interface Props extends DialogProps {
|
||||
queryId?: string;
|
||||
}
|
||||
|
||||
const CreateQueryDialog: React.FC<Props> = (props: Props) => {
|
||||
const { destroy, queryId } = props;
|
||||
|
||||
const [title, setTitle] = useState<string>("");
|
||||
const [filters, setFilters] = useState<Filter[]>([]);
|
||||
const requestState = useLoading(false);
|
||||
|
||||
const shownMemoLength = memoService.getState().memos.filter((memo) => {
|
||||
return checkShouldShowMemoWithFilters(memo, filters);
|
||||
}).length;
|
||||
|
||||
useEffect(() => {
|
||||
const queryTemp = queryService.getQueryById(queryId ?? "");
|
||||
if (queryTemp) {
|
||||
setTitle(queryTemp.title);
|
||||
const temp = JSON.parse(queryTemp.querystring);
|
||||
if (Array.isArray(temp)) {
|
||||
setFilters(temp);
|
||||
}
|
||||
}
|
||||
}, [queryId]);
|
||||
|
||||
const handleTitleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const text = e.target.value as string;
|
||||
setTitle(text);
|
||||
};
|
||||
|
||||
const handleSaveBtnClick = async () => {
|
||||
if (!title) {
|
||||
toastHelper.error("标题不能为空!");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
if (queryId) {
|
||||
const editedQuery = await queryService.updateQuery(queryId, title, JSON.stringify(filters));
|
||||
queryService.editQuery(editedQuery);
|
||||
} else {
|
||||
const query = await queryService.createQuery(title, JSON.stringify(filters));
|
||||
queryService.pushQuery(query);
|
||||
}
|
||||
} catch (error: any) {
|
||||
toastHelper.error(error.message);
|
||||
}
|
||||
destroy();
|
||||
};
|
||||
|
||||
const handleAddFilterBenClick = () => {
|
||||
if (filters.length > 0) {
|
||||
const lastFilter = filters[filters.length - 1];
|
||||
if (lastFilter.value.value === "") {
|
||||
toastHelper.info("先完善上一个过滤器吧");
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
setFilters([...filters, getDefaultFilter()]);
|
||||
};
|
||||
|
||||
const handleFilterChange = useCallback((index: number, filter: Filter) => {
|
||||
setFilters((filters) => {
|
||||
const temp = [...filters];
|
||||
temp[index] = filter;
|
||||
return temp;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const handleFilterRemove = useCallback((index: number) => {
|
||||
setFilters((filters) => {
|
||||
const temp = filters.filter((_, i) => i !== index);
|
||||
return temp;
|
||||
});
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="dialog-header-container">
|
||||
<p className="title-text">
|
||||
<span className="icon-text">🔖</span>
|
||||
{queryId ? "编辑检索" : "创建检索"}
|
||||
</p>
|
||||
<button className="btn close-btn" onClick={destroy}>
|
||||
<img className="icon-img" src="/icons/close.svg" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="dialog-content-container">
|
||||
<div className="form-item-container input-form-container">
|
||||
<span className="normal-text">标题</span>
|
||||
<input className="title-input" type="text" value={title} onChange={handleTitleInputChange} />
|
||||
</div>
|
||||
<div className="form-item-container filter-form-container">
|
||||
<span className="normal-text">过滤器</span>
|
||||
<div className="filters-wrapper">
|
||||
{filters.map((f, index) => {
|
||||
return (
|
||||
<MemoFilterInputer
|
||||
key={index}
|
||||
index={index}
|
||||
filter={f}
|
||||
handleFilterChange={handleFilterChange}
|
||||
handleFilterRemove={handleFilterRemove}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
<div className="create-filter-btn" onClick={handleAddFilterBenClick}>
|
||||
添加筛选条件
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="dialog-footer-container">
|
||||
<div></div>
|
||||
<div className="btns-container">
|
||||
<span className={`tip-text ${filters.length === 0 && "hidden"}`}>
|
||||
符合条件的 Memo 有 <strong>{shownMemoLength}</strong> 条
|
||||
</span>
|
||||
<button className={`btn save-btn ${requestState.isLoading ? "requesting" : ""}`} onClick={handleSaveBtnClick}>
|
||||
保存
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
interface MemoFilterInputerProps {
|
||||
index: number;
|
||||
filter: Filter;
|
||||
handleFilterChange: (index: number, filter: Filter) => void;
|
||||
handleFilterRemove: (index: number) => void;
|
||||
}
|
||||
|
||||
const MemoFilterInputer: React.FC<MemoFilterInputerProps> = memo((props: MemoFilterInputerProps) => {
|
||||
const { index, filter, handleFilterChange, handleFilterRemove } = props;
|
||||
const { type } = filter;
|
||||
const [inputElements, setInputElements] = useState<JSX.Element>(<></>);
|
||||
|
||||
useEffect(() => {
|
||||
let operatorElement = <></>;
|
||||
if (Object.keys(filterConsts).includes(type)) {
|
||||
operatorElement = (
|
||||
<Selector
|
||||
className="operator-selector"
|
||||
dataSource={Object.values(filterConsts[type as FilterType].operators)}
|
||||
value={filter.value.operator}
|
||||
handleValueChanged={handleOperatorChange}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
let valueElement = <></>;
|
||||
switch (type) {
|
||||
case "TYPE": {
|
||||
valueElement = (
|
||||
<Selector
|
||||
className="value-selector"
|
||||
dataSource={filterConsts["TYPE"].values}
|
||||
value={filter.value.value}
|
||||
handleValueChanged={handleValueChange}
|
||||
/>
|
||||
);
|
||||
break;
|
||||
}
|
||||
case "TAG": {
|
||||
valueElement = (
|
||||
<Selector
|
||||
className="value-selector"
|
||||
dataSource={memoService
|
||||
.getState()
|
||||
.tags.sort()
|
||||
.map((t) => {
|
||||
return { text: t, value: t };
|
||||
})}
|
||||
value={filter.value.value}
|
||||
handleValueChanged={handleValueChange}
|
||||
/>
|
||||
);
|
||||
break;
|
||||
}
|
||||
case "TEXT": {
|
||||
valueElement = (
|
||||
<input
|
||||
type="text"
|
||||
className="value-inputer"
|
||||
value={filter.value.value}
|
||||
onChange={(event) => {
|
||||
handleValueChange(event.target.value);
|
||||
event.target.focus();
|
||||
}}
|
||||
/>
|
||||
);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
setInputElements(
|
||||
<>
|
||||
{operatorElement}
|
||||
{valueElement}
|
||||
</>
|
||||
);
|
||||
}, [type, filter]);
|
||||
|
||||
const handleRelationChange = useCallback(
|
||||
(value: string) => {
|
||||
if (["AND", "OR"].includes(value)) {
|
||||
handleFilterChange(index, {
|
||||
...filter,
|
||||
relation: value as MemoFilterRalation,
|
||||
});
|
||||
}
|
||||
},
|
||||
[filter]
|
||||
);
|
||||
|
||||
const handleTypeChange = useCallback(
|
||||
(value: string) => {
|
||||
if (filter.type !== value) {
|
||||
const ops = Object.values(filterConsts[value as FilterType].operators);
|
||||
handleFilterChange(index, {
|
||||
...filter,
|
||||
type: value as FilterType,
|
||||
value: {
|
||||
operator: ops[0].value,
|
||||
value: "",
|
||||
},
|
||||
});
|
||||
}
|
||||
},
|
||||
[filter]
|
||||
);
|
||||
|
||||
const handleOperatorChange = useCallback(
|
||||
(value: string) => {
|
||||
handleFilterChange(index, {
|
||||
...filter,
|
||||
value: {
|
||||
...filter.value,
|
||||
operator: value,
|
||||
},
|
||||
});
|
||||
},
|
||||
[filter]
|
||||
);
|
||||
|
||||
const handleValueChange = useCallback(
|
||||
(value: string) => {
|
||||
handleFilterChange(index, {
|
||||
...filter,
|
||||
value: {
|
||||
...filter.value,
|
||||
value,
|
||||
},
|
||||
});
|
||||
},
|
||||
[filter]
|
||||
);
|
||||
|
||||
const handleRemoveBtnClick = () => {
|
||||
handleFilterRemove(index);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="memo-filter-input-wrapper">
|
||||
{index > 0 ? (
|
||||
<Selector
|
||||
className="relation-selector"
|
||||
dataSource={relationConsts}
|
||||
value={filter.relation}
|
||||
handleValueChanged={handleRelationChange}
|
||||
/>
|
||||
) : null}
|
||||
<Selector
|
||||
className="type-selector"
|
||||
dataSource={Object.values(filterConsts)}
|
||||
value={filter.type}
|
||||
handleValueChanged={handleTypeChange}
|
||||
/>
|
||||
|
||||
{inputElements}
|
||||
<img className="remove-btn" src="/icons/close.svg" onClick={handleRemoveBtnClick} />
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
export default function showCreateQueryDialog(queryId?: string): void {
|
||||
showDialog(
|
||||
{
|
||||
className: "create-query-dialog",
|
||||
},
|
||||
CreateQueryDialog,
|
||||
{ queryId }
|
||||
);
|
||||
}
|
@ -0,0 +1,43 @@
|
||||
import { IMAGE_URL_REG } from "../helpers/consts";
|
||||
import utils from "../helpers/utils";
|
||||
import { formatMemoContent } from "./Memo";
|
||||
import Only from "./common/OnlyWhen";
|
||||
import "../less/daily-memo.less";
|
||||
|
||||
interface DailyMemo extends FormattedMemo {
|
||||
timeStr: string;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
memo: Model.Memo;
|
||||
}
|
||||
|
||||
const DailyMemo: React.FC<Props> = (props: Props) => {
|
||||
const { memo: propsMemo } = props;
|
||||
const memo: DailyMemo = {
|
||||
...propsMemo,
|
||||
createdAtStr: utils.getDateTimeString(propsMemo.createdAt),
|
||||
timeStr: utils.getTimeString(propsMemo.createdAt),
|
||||
};
|
||||
const imageUrls = Array.from(memo.content.match(IMAGE_URL_REG) ?? []);
|
||||
|
||||
return (
|
||||
<div className="daily-memo-wrapper">
|
||||
<div className="time-wrapper">
|
||||
<span className="normal-text">{memo.timeStr}</span>
|
||||
</div>
|
||||
<div className="memo-content-container">
|
||||
<div className="memo-content-text" dangerouslySetInnerHTML={{ __html: formatMemoContent(memo.content) }}></div>
|
||||
<Only when={imageUrls.length > 0}>
|
||||
<div className="images-container">
|
||||
{imageUrls.map((imgUrl, idx) => (
|
||||
<img key={idx} crossOrigin="anonymous" src={imgUrl} decoding="async" />
|
||||
))}
|
||||
</div>
|
||||
</Only>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default DailyMemo;
|
@ -0,0 +1,135 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { memoService } from "../services";
|
||||
import toImage from "../labs/html2image";
|
||||
import useToggle from "../hooks/useToggle";
|
||||
import useLoading from "../hooks/useLoading";
|
||||
import { DAILY_TIMESTAMP } from "../helpers/consts";
|
||||
import utils from "../helpers/utils";
|
||||
import { showDialog } from "./Dialog";
|
||||
import showPreviewImageDialog from "./PreviewImageDialog";
|
||||
import DailyMemo from "./DailyMemo";
|
||||
import DatePicker from "./common/DatePicker";
|
||||
import "../less/daily-memo-diary-dialog.less";
|
||||
|
||||
interface Props extends DialogProps {
|
||||
currentDateStamp: DateStamp;
|
||||
}
|
||||
|
||||
const monthChineseStrArray = ["一月", "二月", "三月", "四月", "五月", "六月", "七月", "八月", "九月", "十月", "十一月", "十二月"];
|
||||
const weekdayChineseStrArray = ["周日", "周一", "周二", "周三", "周四", "周五", "周六"];
|
||||
|
||||
const DailyMemoDiaryDialog: React.FC<Props> = (props: Props) => {
|
||||
const loadingState = useLoading();
|
||||
const [memos, setMemos] = useState<Model.Memo[]>([]);
|
||||
const [currentDateStamp, setCurrentDateStamp] = useState(utils.getDateStampByDate(utils.getDateString(props.currentDateStamp)));
|
||||
const [showDatePicker, toggleShowDatePicker] = useToggle(false);
|
||||
const memosElRef = useRef<HTMLDivElement>(null);
|
||||
const currentDate = new Date(currentDateStamp);
|
||||
|
||||
useEffect(() => {
|
||||
const setDailyMemos = () => {
|
||||
const dailyMemos = memoService
|
||||
.getState()
|
||||
.memos.filter(
|
||||
(a) =>
|
||||
utils.getTimeStampByDate(a.createdAt) >= currentDateStamp &&
|
||||
utils.getTimeStampByDate(a.createdAt) < currentDateStamp + DAILY_TIMESTAMP
|
||||
)
|
||||
.sort((a, b) => utils.getTimeStampByDate(a.createdAt) - utils.getTimeStampByDate(b.createdAt));
|
||||
setMemos(dailyMemos);
|
||||
loadingState.setFinish();
|
||||
};
|
||||
|
||||
setDailyMemos();
|
||||
}, [currentDateStamp]);
|
||||
|
||||
const handleShareBtnClick = () => {
|
||||
toggleShowDatePicker(false);
|
||||
|
||||
setTimeout(() => {
|
||||
if (!memosElRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
toImage(memosElRef.current, {
|
||||
backgroundColor: "#ffffff",
|
||||
pixelRatio: window.devicePixelRatio * 2,
|
||||
})
|
||||
.then((url) => {
|
||||
showPreviewImageDialog(url);
|
||||
})
|
||||
.catch(() => {
|
||||
// do nth
|
||||
});
|
||||
}, 0);
|
||||
};
|
||||
|
||||
const handleDataPickerChange = (datestamp: DateStamp): void => {
|
||||
setCurrentDateStamp(datestamp);
|
||||
toggleShowDatePicker(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="dialog-header-container">
|
||||
<div className="header-wrapper">
|
||||
<p className="title-text">Daily Memos</p>
|
||||
<div className="btns-container">
|
||||
<span className="btn-text" onClick={() => setCurrentDateStamp(currentDateStamp - DAILY_TIMESTAMP)}>
|
||||
<img className="icon-img" src="/icons/arrow-left.svg" />
|
||||
</span>
|
||||
<span className="btn-text" onClick={() => setCurrentDateStamp(currentDateStamp + DAILY_TIMESTAMP)}>
|
||||
<img className="icon-img" src="/icons/arrow-right.svg" />
|
||||
</span>
|
||||
<span className="btn-text share-btn" onClick={handleShareBtnClick}>
|
||||
<img className="icon-img" src="/icons/share.svg" />
|
||||
</span>
|
||||
<span className="btn-text" onClick={() => props.destroy()}>
|
||||
<img className="icon-img" src="/icons/close.svg" />
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="dialog-content-container" ref={memosElRef}>
|
||||
<div className="date-card-container" onClick={() => toggleShowDatePicker()}>
|
||||
<div className="year-text">{currentDate.getFullYear()}</div>
|
||||
<div className="date-container">
|
||||
<div className="month-text">{monthChineseStrArray[currentDate.getMonth()]}</div>
|
||||
<div className="date-text">{currentDate.getDate()}</div>
|
||||
<div className="day-text">{weekdayChineseStrArray[currentDate.getDay()]}</div>
|
||||
</div>
|
||||
</div>
|
||||
<DatePicker
|
||||
className={`date-picker ${showDatePicker ? "" : "hidden"}`}
|
||||
datestamp={currentDateStamp}
|
||||
handleDateStampChange={handleDataPickerChange}
|
||||
/>
|
||||
{loadingState.isLoading ? (
|
||||
<div className="tip-container">
|
||||
<p className="tip-text">努力加载中...</p>
|
||||
</div>
|
||||
) : memos.length === 0 ? (
|
||||
<div className="tip-container">
|
||||
<p className="tip-text">空空如也</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="dailymemos-wrapper">
|
||||
{memos.map((memo) => (
|
||||
<DailyMemo key={`${memo.id}-${memo.updatedAt}`} memo={memo} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default function showDailyMemoDiaryDialog(datestamp: DateStamp = Date.now()): void {
|
||||
showDialog(
|
||||
{
|
||||
className: "daily-memo-diary-dialog",
|
||||
},
|
||||
DailyMemoDiaryDialog,
|
||||
{ currentDateStamp: datestamp }
|
||||
);
|
||||
}
|
@ -0,0 +1,87 @@
|
||||
import { IMAGE_URL_REG } from "../helpers/consts";
|
||||
import utils from "../helpers/utils";
|
||||
import useToggle from "../hooks/useToggle";
|
||||
import { memoService } from "../services";
|
||||
import Only from "./common/OnlyWhen";
|
||||
import Image from "./Image";
|
||||
import toastHelper from "./Toast";
|
||||
import { formatMemoContent } from "./Memo";
|
||||
import "../less/memo.less";
|
||||
|
||||
interface Props {
|
||||
memo: Model.Memo;
|
||||
handleDeletedMemoAction: (memoId: string) => void;
|
||||
}
|
||||
|
||||
const DeletedMemo: React.FC<Props> = (props: Props) => {
|
||||
const { memo: propsMemo, handleDeletedMemoAction } = props;
|
||||
const memo: FormattedMemo = {
|
||||
...propsMemo,
|
||||
createdAtStr: utils.getDateTimeString(propsMemo.createdAt),
|
||||
deletedAtStr: utils.getDateTimeString(propsMemo.deletedAt ?? Date.now()),
|
||||
};
|
||||
const [showConfirmDeleteBtn, toggleConfirmDeleteBtn] = useToggle(false);
|
||||
const imageUrls = Array.from(memo.content.match(IMAGE_URL_REG) ?? []);
|
||||
|
||||
const handleDeleteMemoClick = async () => {
|
||||
if (showConfirmDeleteBtn) {
|
||||
try {
|
||||
await memoService.deleteMemoById(memo.id);
|
||||
handleDeletedMemoAction(memo.id);
|
||||
} catch (error: any) {
|
||||
toastHelper.error(error.message);
|
||||
}
|
||||
} else {
|
||||
toggleConfirmDeleteBtn();
|
||||
}
|
||||
};
|
||||
|
||||
const handleRestoreMemoClick = async () => {
|
||||
try {
|
||||
await memoService.restoreMemoById(memo.id);
|
||||
handleDeletedMemoAction(memo.id);
|
||||
toastHelper.info("恢复成功");
|
||||
} catch (error: any) {
|
||||
toastHelper.error(error.message);
|
||||
}
|
||||
};
|
||||
|
||||
const handleMouseLeaveMemoWrapper = () => {
|
||||
if (showConfirmDeleteBtn) {
|
||||
toggleConfirmDeleteBtn(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`memo-wrapper ${"memos-" + memo.id}`} onMouseLeave={handleMouseLeaveMemoWrapper}>
|
||||
<div className="memo-top-wrapper">
|
||||
<span className="time-text">删除于 {memo.deletedAtStr}</span>
|
||||
<div className="btns-container">
|
||||
<span className="btn more-action-btn">
|
||||
<img className="icon-img" src="/icons/more.svg" />
|
||||
</span>
|
||||
<div className="more-action-btns-wrapper">
|
||||
<div className="more-action-btns-container">
|
||||
<span className="btn restore-btn" onClick={handleRestoreMemoClick}>
|
||||
恢复
|
||||
</span>
|
||||
<span className={`btn delete-btn ${showConfirmDeleteBtn ? "final-confirm" : ""}`} onClick={handleDeleteMemoClick}>
|
||||
{showConfirmDeleteBtn ? "确定删除!" : "完全删除"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="memo-content-text" dangerouslySetInnerHTML={{ __html: formatMemoContent(memo.content) }}></div>
|
||||
<Only when={imageUrls.length > 0}>
|
||||
<div className="images-wrapper">
|
||||
{imageUrls.map((imgUrl, idx) => (
|
||||
<Image className="memo-img" key={idx} imgUrl={imgUrl} />
|
||||
))}
|
||||
</div>
|
||||
</Only>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default DeletedMemo;
|
@ -0,0 +1,81 @@
|
||||
import ReactDOM from "react-dom";
|
||||
import appContext from "../stores/appContext";
|
||||
import Provider from "../labs/Provider";
|
||||
import appStore from "../stores/appStore";
|
||||
import { ANIMATION_DURATION } from "../helpers/consts";
|
||||
import "../less/dialog.less";
|
||||
|
||||
interface DialogConfig {
|
||||
className: string;
|
||||
useAppContext?: boolean;
|
||||
clickSpaceDestroy?: boolean;
|
||||
}
|
||||
|
||||
interface Props extends DialogConfig, DialogProps {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
const BaseDialog: React.FC<Props> = (props: Props) => {
|
||||
const { children, className, clickSpaceDestroy, destroy } = props;
|
||||
|
||||
const handleSpaceClicked = () => {
|
||||
if (clickSpaceDestroy) {
|
||||
destroy();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`dialog-wrapper ${className}`} onClick={handleSpaceClicked}>
|
||||
<div className="dialog-container" onClick={(e) => e.stopPropagation()}>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export function showDialog<T extends DialogProps>(
|
||||
config: DialogConfig,
|
||||
DialogComponent: React.FC<T>,
|
||||
props?: Omit<T, "destroy">
|
||||
): DialogCallback {
|
||||
const tempDiv = document.createElement("div");
|
||||
document.body.append(tempDiv);
|
||||
|
||||
setTimeout(() => {
|
||||
tempDiv.firstElementChild?.classList.add("showup");
|
||||
}, 0);
|
||||
|
||||
const cbs: DialogCallback = {
|
||||
destroy: () => {
|
||||
tempDiv.firstElementChild?.classList.remove("showup");
|
||||
tempDiv.firstElementChild?.classList.add("showoff");
|
||||
setTimeout(() => {
|
||||
tempDiv.remove();
|
||||
ReactDOM.unmountComponentAtNode(tempDiv);
|
||||
}, ANIMATION_DURATION);
|
||||
},
|
||||
};
|
||||
|
||||
const dialogProps = {
|
||||
...props,
|
||||
destroy: cbs.destroy,
|
||||
} as T;
|
||||
|
||||
let Fragment = (
|
||||
<BaseDialog destroy={cbs.destroy} clickSpaceDestroy={true} {...config}>
|
||||
<DialogComponent {...dialogProps} />
|
||||
</BaseDialog>
|
||||
);
|
||||
|
||||
if (config.useAppContext) {
|
||||
Fragment = (
|
||||
<Provider store={appStore} context={appContext}>
|
||||
{Fragment}
|
||||
</Provider>
|
||||
);
|
||||
}
|
||||
|
||||
ReactDOM.render(Fragment, tempDiv);
|
||||
|
||||
return cbs;
|
||||
}
|
@ -0,0 +1,23 @@
|
||||
import showPreviewImageDialog from "./PreviewImageDialog";
|
||||
import "../less/image.less";
|
||||
|
||||
interface Props {
|
||||
imgUrl: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const Image: React.FC<Props> = (props: Props) => {
|
||||
const { className, imgUrl } = props;
|
||||
|
||||
const handleImageClick = () => {
|
||||
showPreviewImageDialog(imgUrl);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={"image-container " + className} onClick={handleImageClick}>
|
||||
<img src={imgUrl} decoding="async" loading="lazy" />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Image;
|
@ -0,0 +1,176 @@
|
||||
import { memo } from "react";
|
||||
import { IMAGE_URL_REG, LINK_REG, MEMO_LINK_REG, TAG_REG } from "../helpers/consts";
|
||||
import { encodeHtml, parseMarkedToHtml, parseRawTextToHtml } from "../helpers/marked";
|
||||
import utils from "../helpers/utils";
|
||||
import useToggle from "../hooks/useToggle";
|
||||
import { globalStateService, memoService } from "../services";
|
||||
import Only from "./common/OnlyWhen";
|
||||
import Image from "./Image";
|
||||
import showMemoCardDialog from "./MemoCardDialog";
|
||||
import showShareMemoImageDialog from "./ShareMemoImageDialog";
|
||||
import toastHelper from "./Toast";
|
||||
import "../less/memo.less";
|
||||
|
||||
interface Props {
|
||||
memo: Model.Memo;
|
||||
}
|
||||
|
||||
const Memo: React.FC<Props> = (props: Props) => {
|
||||
const { memo: propsMemo } = props;
|
||||
const memo: FormattedMemo = {
|
||||
...propsMemo,
|
||||
createdAtStr: utils.getDateTimeString(propsMemo.createdAt),
|
||||
};
|
||||
const [showConfirmDeleteBtn, toggleConfirmDeleteBtn] = useToggle(false);
|
||||
const imageUrls = Array.from(memo.content.match(IMAGE_URL_REG) ?? []);
|
||||
|
||||
const handleShowMemoStoryDialog = () => {
|
||||
showMemoCardDialog(memo);
|
||||
};
|
||||
|
||||
const handleMarkMemoClick = () => {
|
||||
globalStateService.setMarkMemoId(memo.id);
|
||||
};
|
||||
|
||||
const handleEditMemoClick = () => {
|
||||
globalStateService.setEditMemoId(memo.id);
|
||||
};
|
||||
|
||||
const handleDeleteMemoClick = async () => {
|
||||
if (showConfirmDeleteBtn) {
|
||||
try {
|
||||
await memoService.hideMemoById(memo.id);
|
||||
} catch (error: any) {
|
||||
toastHelper.error(error.message);
|
||||
}
|
||||
|
||||
if (globalStateService.getState().editMemoId === memo.id) {
|
||||
globalStateService.setEditMemoId("");
|
||||
}
|
||||
} else {
|
||||
toggleConfirmDeleteBtn();
|
||||
}
|
||||
};
|
||||
|
||||
const handleMouseLeaveMemoWrapper = () => {
|
||||
if (showConfirmDeleteBtn) {
|
||||
toggleConfirmDeleteBtn(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleGenMemoImageBtnClick = () => {
|
||||
showShareMemoImageDialog(memo);
|
||||
};
|
||||
|
||||
const handleMemoContentClick = async (e: React.MouseEvent) => {
|
||||
const targetEl = e.target as HTMLElement;
|
||||
|
||||
if (targetEl.className === "memo-link-text") {
|
||||
const memoId = targetEl.dataset?.value;
|
||||
const memoTemp = memoService.getMemoById(memoId ?? "");
|
||||
|
||||
if (memoTemp) {
|
||||
showMemoCardDialog(memoTemp);
|
||||
} else {
|
||||
toastHelper.error("MEMO Not Found");
|
||||
targetEl.classList.remove("memo-link-text");
|
||||
}
|
||||
} else if (targetEl.className === "todo-block") {
|
||||
// do nth
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`memo-wrapper ${"memos-" + memo.id}`} onMouseLeave={handleMouseLeaveMemoWrapper}>
|
||||
<div className="memo-top-wrapper">
|
||||
<span className="time-text" onClick={handleShowMemoStoryDialog}>
|
||||
{memo.createdAtStr}
|
||||
</span>
|
||||
<div className="btns-container">
|
||||
<span className="btn more-action-btn">
|
||||
<img className="icon-img" src="/icons/more.svg" />
|
||||
</span>
|
||||
<div className="more-action-btns-wrapper">
|
||||
<div className="more-action-btns-container">
|
||||
<span className="btn" onClick={handleShowMemoStoryDialog}>
|
||||
查看详情
|
||||
</span>
|
||||
<span className="btn" onClick={handleMarkMemoClick}>
|
||||
Mark
|
||||
</span>
|
||||
<span className="btn" onClick={handleGenMemoImageBtnClick}>
|
||||
分享
|
||||
</span>
|
||||
<span className="btn" onClick={handleEditMemoClick}>
|
||||
编辑
|
||||
</span>
|
||||
<span className={`btn delete-btn ${showConfirmDeleteBtn ? "final-confirm" : ""}`} onClick={handleDeleteMemoClick}>
|
||||
{showConfirmDeleteBtn ? "确定删除!" : "删除"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className="memo-content-text"
|
||||
onClick={handleMemoContentClick}
|
||||
dangerouslySetInnerHTML={{ __html: formatMemoContent(memo.content) }}
|
||||
></div>
|
||||
<Only when={imageUrls.length > 0}>
|
||||
<div className="images-wrapper">
|
||||
{imageUrls.map((imgUrl, idx) => (
|
||||
<Image className="memo-img" key={idx} imgUrl={imgUrl} />
|
||||
))}
|
||||
</div>
|
||||
</Only>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export function formatMemoContent(content: string) {
|
||||
content = encodeHtml(content);
|
||||
content = parseRawTextToHtml(content)
|
||||
.split("<br>")
|
||||
.map((t) => {
|
||||
return `<p>${t !== "" ? t : "<br>"}</p>`;
|
||||
})
|
||||
.join("");
|
||||
|
||||
const { shouldUseMarkdownParser, shouldSplitMemoWord, shouldHideImageUrl } = globalStateService.getState();
|
||||
|
||||
if (shouldUseMarkdownParser) {
|
||||
content = parseMarkedToHtml(content);
|
||||
}
|
||||
|
||||
if (shouldHideImageUrl) {
|
||||
content = content.replace(IMAGE_URL_REG, "");
|
||||
}
|
||||
|
||||
// 中英文之间加空格
|
||||
if (shouldSplitMemoWord) {
|
||||
content = content
|
||||
.replace(/([\u4e00-\u9fa5])([A-Za-z0-9?.,;[\]]+)/g, "$1 $2")
|
||||
.replace(/([A-Za-z0-9?.,;[\]]+)([\u4e00-\u9fa5])/g, "$1 $2");
|
||||
}
|
||||
|
||||
content = content
|
||||
.replace(TAG_REG, "<span class='tag-span'>#$1</span>")
|
||||
.replace(LINK_REG, "<a class='link' target='_blank' rel='noreferrer' href='$1'>$1</a>")
|
||||
.replace(MEMO_LINK_REG, "<span class='memo-link-text' data-value='$2'>$1</span>");
|
||||
|
||||
const tempDivContainer = document.createElement("div");
|
||||
tempDivContainer.innerHTML = content;
|
||||
for (let i = 0; i < tempDivContainer.children.length; i++) {
|
||||
const c = tempDivContainer.children[i];
|
||||
|
||||
if (c.tagName === "P" && c.textContent === "" && c.firstElementChild?.tagName !== "BR") {
|
||||
c.remove();
|
||||
i--;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
return tempDivContainer.innerHTML;
|
||||
}
|
||||
|
||||
export default memo(Memo);
|
@ -0,0 +1,189 @@
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { IMAGE_URL_REG, MEMO_LINK_REG } from "../helpers/consts";
|
||||
import utils from "../helpers/utils";
|
||||
import { globalStateService, memoService } from "../services";
|
||||
import { parseHtmlToRawText } from "../helpers/marked";
|
||||
import { formatMemoContent } from "./Memo";
|
||||
import toastHelper from "./Toast";
|
||||
import { showDialog } from "./Dialog";
|
||||
import Only from "./common/OnlyWhen";
|
||||
import Image from "./Image";
|
||||
import "../less/memo-card-dialog.less";
|
||||
|
||||
interface LinkedMemo extends FormattedMemo {
|
||||
dateStr: string;
|
||||
}
|
||||
|
||||
interface Props extends DialogProps {
|
||||
memo: Model.Memo;
|
||||
}
|
||||
|
||||
const MemoCardDialog: React.FC<Props> = (props: Props) => {
|
||||
const [memo, setMemo] = useState<FormattedMemo>({
|
||||
...props.memo,
|
||||
createdAtStr: utils.getDateTimeString(props.memo.createdAt),
|
||||
});
|
||||
const [linkMemos, setLinkMemos] = useState<LinkedMemo[]>([]);
|
||||
const [linkedMemos, setLinkedMemos] = useState<LinkedMemo[]>([]);
|
||||
const imageUrls = Array.from(memo.content.match(IMAGE_URL_REG) ?? []);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchLinkedMemos = async () => {
|
||||
try {
|
||||
const linkMemos: LinkedMemo[] = [];
|
||||
const matchedArr = [...memo.content.matchAll(MEMO_LINK_REG)];
|
||||
for (const matchRes of matchedArr) {
|
||||
if (matchRes && matchRes.length === 3) {
|
||||
const id = matchRes[2];
|
||||
const memoTemp = memoService.getMemoById(id);
|
||||
if (memoTemp) {
|
||||
linkMemos.push({
|
||||
...memoTemp,
|
||||
createdAtStr: utils.getDateTimeString(memoTemp.createdAt),
|
||||
dateStr: utils.getDateString(memoTemp.createdAt),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
setLinkMemos([...linkMemos]);
|
||||
|
||||
const linkedMemos = await memoService.getLinkedMemos(memo.id);
|
||||
setLinkedMemos(
|
||||
linkedMemos
|
||||
.sort((a, b) => utils.getTimeStampByDate(b.createdAt) - utils.getTimeStampByDate(a.createdAt))
|
||||
.map((m) => ({
|
||||
...m,
|
||||
createdAtStr: utils.getDateTimeString(m.createdAt),
|
||||
dateStr: utils.getDateString(m.createdAt),
|
||||
}))
|
||||
);
|
||||
} catch (error) {
|
||||
// do nth
|
||||
}
|
||||
};
|
||||
|
||||
fetchLinkedMemos();
|
||||
}, [memo.id]);
|
||||
|
||||
const handleMemoContentClick = useCallback(async (e: React.MouseEvent) => {
|
||||
const targetEl = e.target as HTMLElement;
|
||||
|
||||
if (targetEl.className === "memo-link-text") {
|
||||
const nextMemoId = targetEl.dataset?.value;
|
||||
const memoTemp = memoService.getMemoById(nextMemoId ?? "");
|
||||
|
||||
if (memoTemp) {
|
||||
const nextMemo = {
|
||||
...memoTemp,
|
||||
createdAtStr: utils.getDateTimeString(memoTemp.createdAt),
|
||||
};
|
||||
setLinkMemos([]);
|
||||
setLinkedMemos([]);
|
||||
setMemo(nextMemo);
|
||||
} else {
|
||||
toastHelper.error("MEMO Not Found");
|
||||
targetEl.classList.remove("memo-link-text");
|
||||
}
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleLinkedMemoClick = useCallback((memo: FormattedMemo) => {
|
||||
setLinkMemos([]);
|
||||
setLinkedMemos([]);
|
||||
setMemo(memo);
|
||||
}, []);
|
||||
|
||||
const handleEditMemoBtnClick = useCallback(() => {
|
||||
props.destroy();
|
||||
globalStateService.setEditMemoId(memo.id);
|
||||
}, [memo.id]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="memo-card-container">
|
||||
<div className="header-container">
|
||||
<p className="time-text">{memo.createdAtStr}</p>
|
||||
<div className="btns-container">
|
||||
<button className="btn edit-btn" onClick={handleEditMemoBtnClick}>
|
||||
<img className="icon-img" src="/icons/edit.svg" />
|
||||
</button>
|
||||
<button className="btn close-btn" onClick={props.destroy}>
|
||||
<img className="icon-img" src="/icons/close.svg" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="memo-container">
|
||||
<div
|
||||
className="memo-content-text"
|
||||
onClick={handleMemoContentClick}
|
||||
dangerouslySetInnerHTML={{ __html: formatMemoContent(memo.content) }}
|
||||
></div>
|
||||
<Only when={imageUrls.length > 0}>
|
||||
<div className="images-wrapper">
|
||||
{imageUrls.map((imgUrl, idx) => (
|
||||
<Image className="memo-img" key={idx} imgUrl={imgUrl} />
|
||||
))}
|
||||
</div>
|
||||
</Only>
|
||||
</div>
|
||||
<div className="layer-container"></div>
|
||||
{linkMemos.map((_, idx) => {
|
||||
if (idx < 4) {
|
||||
return (
|
||||
<div
|
||||
className="background-layer-container"
|
||||
key={idx}
|
||||
style={{
|
||||
bottom: (idx + 1) * -3 + "px",
|
||||
left: (idx + 1) * 5 + "px",
|
||||
width: `calc(100% - ${(idx + 1) * 10}px)`,
|
||||
zIndex: -idx - 1,
|
||||
}}
|
||||
></div>
|
||||
);
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
})}
|
||||
</div>
|
||||
{linkMemos.length > 0 ? (
|
||||
<div className="linked-memos-wrapper">
|
||||
<p className="normal-text">关联了 {linkMemos.length} 个 MEMO</p>
|
||||
{linkMemos.map((m) => {
|
||||
const rawtext = parseHtmlToRawText(formatMemoContent(m.content)).replaceAll("\n", " ");
|
||||
return (
|
||||
<div className="linked-memo-container" key={m.id} onClick={() => handleLinkedMemoClick(m)}>
|
||||
<span className="time-text">{m.dateStr} </span>
|
||||
{rawtext}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : null}
|
||||
{linkedMemos.length > 0 ? (
|
||||
<div className="linked-memos-wrapper">
|
||||
<p className="normal-text">{linkedMemos.length} 个链接至此的 MEMO</p>
|
||||
{linkedMemos.map((m) => {
|
||||
const rawtext = parseHtmlToRawText(formatMemoContent(m.content)).replaceAll("\n", " ");
|
||||
return (
|
||||
<div className="linked-memo-container" key={m.id} onClick={() => handleLinkedMemoClick(m)}>
|
||||
<span className="time-text">{m.dateStr} </span>
|
||||
{rawtext}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default function showMemoCardDialog(memo: Model.Memo): void {
|
||||
showDialog(
|
||||
{
|
||||
className: "memo-card-dialog",
|
||||
},
|
||||
MemoCardDialog,
|
||||
{ memo }
|
||||
);
|
||||
}
|
@ -0,0 +1,120 @@
|
||||
import { useCallback, useContext, useEffect, useMemo, useRef } from "react";
|
||||
import appContext from "../stores/appContext";
|
||||
import { globalStateService, locationService, memoService } from "../services";
|
||||
import utils from "../helpers/utils";
|
||||
import { storage } from "../helpers/storage";
|
||||
import toastHelper from "./Toast";
|
||||
import Editor, { EditorRefActions } from "./Editor/Editor";
|
||||
import "../less/memo-editor.less";
|
||||
|
||||
interface Props {
|
||||
className?: string;
|
||||
editMemoId?: string;
|
||||
}
|
||||
|
||||
const MemoEditor: React.FC<Props> = (props: Props) => {
|
||||
const { className, editMemoId } = props;
|
||||
const { globalState } = useContext(appContext);
|
||||
const editorRef = useRef<EditorRefActions>(null);
|
||||
const prevGlobalStateRef = useRef(globalState);
|
||||
|
||||
useEffect(() => {
|
||||
if (globalState.markMemoId) {
|
||||
const editorCurrentValue = editorRef.current?.getContent();
|
||||
const memoLinkText = `${editorCurrentValue ? "\n" : ""}Mark: [@MEMO](${globalState.markMemoId})`;
|
||||
editorRef.current?.insertText(memoLinkText);
|
||||
globalStateService.setMarkMemoId("");
|
||||
}
|
||||
|
||||
if (editMemoId && globalState.editMemoId) {
|
||||
const editMemo = memoService.getMemoById(globalState.editMemoId);
|
||||
if (editMemo) {
|
||||
editorRef.current?.setContent(editMemo.content ?? "");
|
||||
editorRef.current?.focus();
|
||||
}
|
||||
}
|
||||
|
||||
prevGlobalStateRef.current = globalState;
|
||||
}, [globalState.markMemoId, globalState.editMemoId]);
|
||||
|
||||
const handleSaveBtnClick = useCallback(async (content: string) => {
|
||||
if (content === "") {
|
||||
toastHelper.error("内容不能为空呀");
|
||||
return;
|
||||
}
|
||||
|
||||
content = content.replaceAll(" ", " ");
|
||||
|
||||
try {
|
||||
if (editMemoId) {
|
||||
const prevMemo = memoService.getMemoById(editMemoId);
|
||||
|
||||
if (prevMemo && prevMemo.content !== content) {
|
||||
const editedMemo = await memoService.updateMemo(prevMemo.id, content);
|
||||
editedMemo.updatedAt = utils.getDateTimeString(Date.now());
|
||||
memoService.editMemo(editedMemo);
|
||||
}
|
||||
globalStateService.setEditMemoId("");
|
||||
} else {
|
||||
const newMemo = await memoService.createMemo(content);
|
||||
memoService.pushMemo(newMemo);
|
||||
locationService.clearQuery();
|
||||
}
|
||||
} catch (error: any) {
|
||||
toastHelper.error(error.message);
|
||||
}
|
||||
|
||||
setEditorContentCache("");
|
||||
}, []);
|
||||
|
||||
const handleCancelBtnClick = useCallback(() => {
|
||||
globalStateService.setEditMemoId("");
|
||||
editorRef.current?.setContent("");
|
||||
setEditorContentCache("");
|
||||
}, []);
|
||||
|
||||
const handleContentChange = useCallback((content: string) => {
|
||||
const tempDiv = document.createElement("div");
|
||||
tempDiv.innerHTML = content;
|
||||
if (tempDiv.innerText.trim() === "") {
|
||||
content = "";
|
||||
}
|
||||
setEditorContentCache(content);
|
||||
}, []);
|
||||
|
||||
const showEditStatus = Boolean(editMemoId);
|
||||
|
||||
const editorConfig = useMemo(
|
||||
() => ({
|
||||
className: "memo-editor",
|
||||
initialContent: getEditorContentCache(),
|
||||
placeholder: "现在的想法是...",
|
||||
showConfirmBtn: true,
|
||||
showCancelBtn: showEditStatus,
|
||||
showTools: true,
|
||||
onConfirmBtnClick: handleSaveBtnClick,
|
||||
onCancelBtnClick: handleCancelBtnClick,
|
||||
onContentChange: handleContentChange,
|
||||
}),
|
||||
[editMemoId]
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={`memo-editor-wrapper ${className} ${editMemoId ? "edit-ing" : ""}`}>
|
||||
<p className={"tip-text " + (editMemoId ? "" : "hidden")}>正在修改中...</p>
|
||||
<Editor ref={editorRef} {...editorConfig} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
function getEditorContentCache(): string {
|
||||
return storage.get(["editorContentCache"]).editorContentCache ?? "";
|
||||
}
|
||||
|
||||
function setEditorContentCache(content: string) {
|
||||
storage.set({
|
||||
editorContentCache: content,
|
||||
});
|
||||
}
|
||||
|
||||
export default MemoEditor;
|
@ -0,0 +1,114 @@
|
||||
import { useCallback, useContext, useEffect, useRef, useState } from "react";
|
||||
import appContext from "../stores/appContext";
|
||||
import { locationService, memoService, queryService } from "../services";
|
||||
import { IMAGE_URL_REG, LINK_REG, MEMO_LINK_REG, TAG_REG } from "../helpers/consts";
|
||||
import utils from "../helpers/utils";
|
||||
import { checkShouldShowMemoWithFilters } from "../helpers/filter";
|
||||
import Memo from "./Memo";
|
||||
import toastHelper from "./Toast";
|
||||
import MemoEditor from "./MemoEditor";
|
||||
import "../less/memolist.less";
|
||||
|
||||
interface Props {}
|
||||
|
||||
const MemoList: React.FC<Props> = () => {
|
||||
const {
|
||||
locationState: { query },
|
||||
memoState: { memos },
|
||||
globalState,
|
||||
} = useContext(appContext);
|
||||
const [isFetching, setFetchStatus] = useState(true);
|
||||
const wrapperElement = useRef<HTMLDivElement>(null);
|
||||
|
||||
const { tag: tagQuery, duration, type: memoType, text: textQuery, filter: queryId } = query;
|
||||
const showMemoFilter = Boolean(tagQuery || (duration && duration.from < duration.to) || memoType || textQuery);
|
||||
|
||||
const shownMemos =
|
||||
showMemoFilter || queryId
|
||||
? memos.filter((memo) => {
|
||||
let shouldShow = true;
|
||||
|
||||
const query = queryService.getQueryById(queryId);
|
||||
if (query) {
|
||||
const filters = JSON.parse(query.querystring) as Filter[];
|
||||
if (Array.isArray(filters)) {
|
||||
shouldShow = checkShouldShowMemoWithFilters(memo, filters);
|
||||
}
|
||||
}
|
||||
|
||||
if (tagQuery && !memo.content.includes(`# ${tagQuery}`)) {
|
||||
shouldShow = false;
|
||||
}
|
||||
if (
|
||||
duration &&
|
||||
duration.from < duration.to &&
|
||||
(utils.getTimeStampByDate(memo.createdAt) < duration.from || utils.getTimeStampByDate(memo.createdAt) > duration.to)
|
||||
) {
|
||||
shouldShow = false;
|
||||
}
|
||||
if (memoType) {
|
||||
if (memoType === "NOT_TAGGED" && memo.content.match(TAG_REG) !== null) {
|
||||
shouldShow = false;
|
||||
} else if (memoType === "LINKED" && memo.content.match(LINK_REG) === null) {
|
||||
shouldShow = false;
|
||||
} else if (memoType === "IMAGED" && memo.content.match(IMAGE_URL_REG) === null) {
|
||||
shouldShow = false;
|
||||
} else if (memoType === "CONNECTED" && memo.content.match(MEMO_LINK_REG) === null) {
|
||||
shouldShow = false;
|
||||
}
|
||||
}
|
||||
if (textQuery && !memo.content.includes(textQuery)) {
|
||||
shouldShow = false;
|
||||
}
|
||||
|
||||
return shouldShow;
|
||||
})
|
||||
: memos;
|
||||
|
||||
useEffect(() => {
|
||||
memoService
|
||||
.fetchAllMemos()
|
||||
.then(() => {
|
||||
setFetchStatus(false);
|
||||
})
|
||||
.catch(() => {
|
||||
toastHelper.error("😭 请求数据失败了");
|
||||
});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
wrapperElement.current?.scrollTo({ top: 0 });
|
||||
}, [query]);
|
||||
|
||||
const handleMemoListClick = useCallback((event: React.MouseEvent) => {
|
||||
const targetEl = event.target as HTMLElement;
|
||||
if (targetEl.tagName === "SPAN" && targetEl.className === "tag-span") {
|
||||
const tagName = targetEl.innerText.slice(1);
|
||||
const currTagQuery = locationService.getState().query.tag;
|
||||
if (currTagQuery === tagName) {
|
||||
locationService.setTagQuery("");
|
||||
} else {
|
||||
locationService.setTagQuery(tagName);
|
||||
}
|
||||
}
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className={`memolist-wrapper ${isFetching ? "" : "completed"}`} onClick={handleMemoListClick} ref={wrapperElement}>
|
||||
{shownMemos.map((memo) =>
|
||||
globalState.editMemoId === memo.id ? (
|
||||
<MemoEditor key={memo.id} className="memo-edit" editMemoId={memo.id} />
|
||||
) : (
|
||||
<Memo key={`${memo.id}-${memo.updatedAt}`} memo={memo} />
|
||||
)
|
||||
)}
|
||||
<div className="status-text-container">
|
||||
<p className="status-text">
|
||||
{isFetching ? "努力请求数据中..." : shownMemos.length === 0 ? "空空如也" : showMemoFilter ? "" : "所有数据加载完啦 🎉"}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default MemoList;
|
@ -0,0 +1,61 @@
|
||||
import { useCallback, useContext, useEffect, useState } from "react";
|
||||
import appContext from "../stores/appContext";
|
||||
import SearchBar from "./SearchBar";
|
||||
import { globalStateService, memoService, queryService } from "../services";
|
||||
import Only from "./common/OnlyWhen";
|
||||
import "../less/memos-header.less";
|
||||
|
||||
let prevRequestTimestamp = Date.now();
|
||||
|
||||
interface Props {}
|
||||
|
||||
const MemosHeader: React.FC<Props> = () => {
|
||||
const {
|
||||
locationState: {
|
||||
query: { filter },
|
||||
},
|
||||
globalState: { isMobileView },
|
||||
queryState: { queries },
|
||||
} = useContext(appContext);
|
||||
|
||||
const [titleText, setTitleText] = useState("MEMOS");
|
||||
|
||||
useEffect(() => {
|
||||
const query = queryService.getQueryById(filter);
|
||||
if (query) {
|
||||
setTitleText(query.title);
|
||||
} else {
|
||||
setTitleText("MEMOS");
|
||||
}
|
||||
}, [filter, queries]);
|
||||
|
||||
const handleMemoTextClick = useCallback(() => {
|
||||
const now = Date.now();
|
||||
if (now - prevRequestTimestamp > 10 * 1000) {
|
||||
prevRequestTimestamp = now;
|
||||
memoService.fetchAllMemos().catch(() => {
|
||||
// do nth
|
||||
});
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleShowSidebarBtnClick = useCallback(() => {
|
||||
globalStateService.setShowSiderbarInMobileView(true);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="section-header-container memos-header-container">
|
||||
<div className="title-text" onClick={handleMemoTextClick}>
|
||||
<Only when={isMobileView}>
|
||||
<button className="action-btn" onClick={handleShowSidebarBtnClick}>
|
||||
<img className="icon-img" src="/icons/menu.svg" alt="menu" />
|
||||
</button>
|
||||
</Only>
|
||||
<span className="normal-text">{titleText}</span>
|
||||
</div>
|
||||
<SearchBar />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default MemosHeader;
|
@ -0,0 +1,170 @@
|
||||
import React, { useContext, useEffect } from "react";
|
||||
import appContext from "../stores/appContext";
|
||||
import useToggle from "../hooks/useToggle";
|
||||
import useLoading from "../hooks/useLoading";
|
||||
import Only from "./common/OnlyWhen";
|
||||
import utils from "../helpers/utils";
|
||||
import toastHelper from "./Toast";
|
||||
import { locationService, queryService } from "../services";
|
||||
import showCreateQueryDialog from "./CreateQueryDialog";
|
||||
import "../less/query-list.less";
|
||||
|
||||
interface Props {}
|
||||
|
||||
const QueryList: React.FC<Props> = () => {
|
||||
const {
|
||||
queryState: { queries },
|
||||
locationState: {
|
||||
query: { filter },
|
||||
},
|
||||
} = useContext(appContext);
|
||||
const loadingState = useLoading();
|
||||
const sortedQueries = queries
|
||||
.sort((a, b) => utils.getTimeStampByDate(b.createdAt) - utils.getTimeStampByDate(a.createdAt))
|
||||
.sort((a, b) => utils.getTimeStampByDate(b.pinnedAt ?? 0) - utils.getTimeStampByDate(a.pinnedAt ?? 0));
|
||||
|
||||
useEffect(() => {
|
||||
queryService
|
||||
.getMyAllQueries()
|
||||
.catch(() => {
|
||||
// do nth
|
||||
})
|
||||
.finally(() => {
|
||||
loadingState.setFinish();
|
||||
});
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="queries-wrapper">
|
||||
<p className="title-text">
|
||||
<span className="normal-text">快速检索</span>
|
||||
<span className="btn" onClick={() => showCreateQueryDialog()}>
|
||||
+
|
||||
</span>
|
||||
</p>
|
||||
<Only when={loadingState.isSucceed && sortedQueries.length === 0}>
|
||||
<div className="create-query-btn-container">
|
||||
<span className="btn" onClick={() => showCreateQueryDialog()}>
|
||||
创建检索
|
||||
</span>
|
||||
</div>
|
||||
</Only>
|
||||
<div className="queries-container">
|
||||
{sortedQueries.map((q) => {
|
||||
return <QueryItemContainer key={q.id} query={q} isActive={q.id === filter} />;
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
interface QueryItemContainerProps {
|
||||
query: Model.Query;
|
||||
isActive: boolean;
|
||||
}
|
||||
|
||||
const QueryItemContainer: React.FC<QueryItemContainerProps> = (props: QueryItemContainerProps) => {
|
||||
const { query, isActive } = props;
|
||||
const [showActionBtns, toggleShowActionBtns] = useToggle(false);
|
||||
const [showConfirmDeleteBtn, toggleConfirmDeleteBtn] = useToggle(false);
|
||||
|
||||
const handleQueryClick = () => {
|
||||
if (isActive) {
|
||||
locationService.setMemoFilter("");
|
||||
} else {
|
||||
if (!["/", "/recycle"].includes(locationService.getState().pathname)) {
|
||||
locationService.setPathname("/");
|
||||
}
|
||||
locationService.setMemoFilter(query.id);
|
||||
}
|
||||
};
|
||||
|
||||
const handleShowActionBtnClick = (event: React.MouseEvent) => {
|
||||
event.stopPropagation();
|
||||
toggleShowActionBtns();
|
||||
};
|
||||
|
||||
const handleActionBtnContainerMouseLeave = () => {
|
||||
toggleShowActionBtns(false);
|
||||
};
|
||||
|
||||
const handleDeleteMemoClick = async (event: React.MouseEvent) => {
|
||||
event.stopPropagation();
|
||||
|
||||
if (showConfirmDeleteBtn) {
|
||||
try {
|
||||
await queryService.deleteQuery(query.id);
|
||||
} catch (error: any) {
|
||||
toastHelper.error(error.message);
|
||||
}
|
||||
} else {
|
||||
toggleConfirmDeleteBtn();
|
||||
}
|
||||
};
|
||||
|
||||
const handleEditQueryBtnClick = (event: React.MouseEvent) => {
|
||||
event.stopPropagation();
|
||||
showCreateQueryDialog(query.id);
|
||||
};
|
||||
|
||||
const handlePinQueryBtnClick = async (event: React.MouseEvent) => {
|
||||
event.stopPropagation();
|
||||
|
||||
try {
|
||||
if (query.pinnedAt) {
|
||||
await queryService.unpinQuery(query.id);
|
||||
queryService.editQuery({
|
||||
...query,
|
||||
pinnedAt: undefined,
|
||||
});
|
||||
} else {
|
||||
await queryService.pinQuery(query.id);
|
||||
queryService.editQuery({
|
||||
...query,
|
||||
pinnedAt: utils.getDateTimeString(Date.now()),
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
// do nth
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteBtnMouseLeave = () => {
|
||||
toggleConfirmDeleteBtn(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className={`query-item-container ${isActive ? "active" : ""}`} onClick={handleQueryClick}>
|
||||
<div className="query-text-container">
|
||||
<span className="icon-text">#</span>
|
||||
<span className="query-text">{query.title}</span>
|
||||
</div>
|
||||
<div className="btns-container">
|
||||
<span className="action-btn toggle-btn" onClick={handleShowActionBtnClick}>
|
||||
<img className="icon-img" src={`/icons/more${isActive ? "-white" : ""}.svg`} />
|
||||
</span>
|
||||
<div className={`action-btns-wrapper ${showActionBtns ? "" : "hidden"}`} onMouseLeave={handleActionBtnContainerMouseLeave}>
|
||||
<div className="action-btns-container">
|
||||
<span className="btn" onClick={handlePinQueryBtnClick}>
|
||||
{query.pinnedAt ? "取消置顶" : "置顶"}
|
||||
</span>
|
||||
<span className="btn" onClick={handleEditQueryBtnClick}>
|
||||
编辑
|
||||
</span>
|
||||
<span
|
||||
className={`btn delete-btn ${showConfirmDeleteBtn ? "final-confirm" : ""}`}
|
||||
onClick={handleDeleteMemoClick}
|
||||
onMouseLeave={handleDeleteBtnMouseLeave}
|
||||
>
|
||||
{showConfirmDeleteBtn ? "确定删除!" : "删除"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default QueryList;
|
@ -0,0 +1,64 @@
|
||||
import { useContext } from "react";
|
||||
import appContext from "../stores/appContext";
|
||||
import { locationService } from "../services";
|
||||
import { memoSpecialTypes } from "../helpers/filter";
|
||||
import "../less/search-bar.less";
|
||||
|
||||
interface Props {}
|
||||
|
||||
const SearchBar: React.FC<Props> = () => {
|
||||
const {
|
||||
locationState: {
|
||||
query: { type: memoType },
|
||||
},
|
||||
} = useContext(appContext);
|
||||
|
||||
const handleMemoTypeItemClick = (type: MemoSpecType | "") => {
|
||||
const { type: prevType } = locationService.getState().query;
|
||||
if (type === prevType) {
|
||||
type = "";
|
||||
}
|
||||
locationService.setMemoTypeQuery(type);
|
||||
};
|
||||
|
||||
const handleTextQueryInput = (event: React.FormEvent<HTMLInputElement>) => {
|
||||
const text = event.currentTarget.value;
|
||||
locationService.setTextQuery(text);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="search-bar-container">
|
||||
<div className="search-bar-inputer">
|
||||
<img className="icon-img" src="/icons/search.svg" />
|
||||
<input className="text-input" type="text" placeholder="" onChange={handleTextQueryInput} />
|
||||
</div>
|
||||
<div className="quickly-action-wrapper">
|
||||
<div className="quickly-action-container">
|
||||
<p className="title-text">QUICKLY FILTER</p>
|
||||
<div className="section-container types-container">
|
||||
<span className="section-text">类型:</span>
|
||||
<div className="values-container">
|
||||
{memoSpecialTypes.map((t, idx) => {
|
||||
return (
|
||||
<div key={t.value}>
|
||||
<span
|
||||
className={`type-item ${memoType === t.value ? "selected" : ""}`}
|
||||
onClick={() => {
|
||||
handleMemoTypeItemClick(t.value as MemoSpecType);
|
||||
}}
|
||||
>
|
||||
{t.text}
|
||||
</span>
|
||||
{idx + 1 < memoSpecialTypes.length ? <span className="split-text">/</span> : null}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default SearchBar;
|
@ -0,0 +1,75 @@
|
||||
import { useContext, useEffect, useMemo, useRef } from "react";
|
||||
import appContext from "../stores/appContext";
|
||||
import { SHOW_SIDERBAR_MOBILE_CLASSNAME } from "../helpers/consts";
|
||||
import { globalStateService } from "../services";
|
||||
import UserBanner from "./UserBanner";
|
||||
import QueryList from "./QueryList";
|
||||
import TagList from "./TagList";
|
||||
import UsageHeatMap from "./UsageHeatMap";
|
||||
import "../less/siderbar.less";
|
||||
|
||||
interface Props {}
|
||||
|
||||
const Sidebar: React.FC<Props> = () => {
|
||||
const {
|
||||
locationState,
|
||||
globalState: { isMobileView, showSiderbarInMobileView },
|
||||
} = useContext(appContext);
|
||||
const wrapperElRef = useRef<HTMLElement>(null);
|
||||
|
||||
const handleClickOutsideOfWrapper = useMemo(() => {
|
||||
return (event: MouseEvent) => {
|
||||
const siderbarShown = globalStateService.getState().showSiderbarInMobileView;
|
||||
|
||||
if (!siderbarShown) {
|
||||
window.removeEventListener("click", handleClickOutsideOfWrapper, {
|
||||
capture: true,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (!wrapperElRef.current?.contains(event.target as Node)) {
|
||||
if (wrapperElRef.current?.parentNode?.contains(event.target as Node)) {
|
||||
if (siderbarShown) {
|
||||
event.stopPropagation();
|
||||
}
|
||||
globalStateService.setShowSiderbarInMobileView(false);
|
||||
window.removeEventListener("click", handleClickOutsideOfWrapper, {
|
||||
capture: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
globalStateService.setShowSiderbarInMobileView(false);
|
||||
}, [locationState]);
|
||||
|
||||
useEffect(() => {
|
||||
if (showSiderbarInMobileView) {
|
||||
document.body.classList.add(SHOW_SIDERBAR_MOBILE_CLASSNAME);
|
||||
} else {
|
||||
document.body.classList.remove(SHOW_SIDERBAR_MOBILE_CLASSNAME);
|
||||
}
|
||||
}, [showSiderbarInMobileView]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isMobileView && showSiderbarInMobileView) {
|
||||
window.addEventListener("click", handleClickOutsideOfWrapper, {
|
||||
capture: true,
|
||||
});
|
||||
}
|
||||
}, [isMobileView, showSiderbarInMobileView]);
|
||||
|
||||
return (
|
||||
<aside className="sidebar-wrapper" ref={wrapperElRef}>
|
||||
<UserBanner />
|
||||
<UsageHeatMap />
|
||||
<QueryList />
|
||||
<TagList />
|
||||
</aside>
|
||||
);
|
||||
};
|
||||
|
||||
export default Sidebar;
|
@ -0,0 +1,143 @@
|
||||
import { useContext, useEffect, useState } from "react";
|
||||
import appContext from "../stores/appContext";
|
||||
import { locationService, memoService } from "../services";
|
||||
import useToggle from "../hooks/useToggle";
|
||||
import Only from "./common/OnlyWhen";
|
||||
import utils from "../helpers/utils";
|
||||
import "../less/tag-list.less";
|
||||
|
||||
interface Tag {
|
||||
key: string;
|
||||
text: string;
|
||||
subTags: Tag[];
|
||||
}
|
||||
|
||||
interface Props {}
|
||||
|
||||
const TagList: React.FC<Props> = () => {
|
||||
const {
|
||||
locationState: {
|
||||
query: { tag: tagQuery },
|
||||
},
|
||||
memoState: { tags: tagsText, memos },
|
||||
} = useContext(appContext);
|
||||
const [tags, setTags] = useState<Tag[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
memoService.updateTagsState();
|
||||
}, [memos]);
|
||||
|
||||
useEffect(() => {
|
||||
const sortedTags = Array.from(tagsText).sort();
|
||||
const root: KVObject<any> = {
|
||||
subTags: [],
|
||||
};
|
||||
for (const tag of sortedTags) {
|
||||
const subtags = tag.split("/");
|
||||
let tempObj = root;
|
||||
let tagText = "";
|
||||
for (let i = 0; i < subtags.length; i++) {
|
||||
const key = subtags[i];
|
||||
if (i === 0) {
|
||||
tagText += key;
|
||||
} else {
|
||||
tagText += "/" + key;
|
||||
}
|
||||
|
||||
let obj = null;
|
||||
|
||||
for (const t of tempObj.subTags) {
|
||||
if (t.text === tagText) {
|
||||
obj = t;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!obj) {
|
||||
obj = {
|
||||
key,
|
||||
text: tagText,
|
||||
subTags: [],
|
||||
};
|
||||
tempObj.subTags.push(obj);
|
||||
}
|
||||
|
||||
tempObj = obj;
|
||||
}
|
||||
}
|
||||
setTags(root.subTags as Tag[]);
|
||||
}, [tagsText]);
|
||||
|
||||
return (
|
||||
<div className="tags-wrapper">
|
||||
<p className="title-text">常用标签</p>
|
||||
<div className="tags-container">
|
||||
{tags.map((t, idx) => (
|
||||
<TagItemContainer key={t.text + "-" + idx} tag={t} tagQuery={tagQuery} />
|
||||
))}
|
||||
<Only when={tags.length < 5 && memoService.initialized}>
|
||||
<p className="tag-tip-container">
|
||||
输入<span className="code-text"># Tag </span>来创建标签吧~
|
||||
</p>
|
||||
</Only>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
interface TagItemContainerProps {
|
||||
tag: Tag;
|
||||
tagQuery: string;
|
||||
}
|
||||
|
||||
const TagItemContainer: React.FC<TagItemContainerProps> = (props: TagItemContainerProps) => {
|
||||
const { tag, tagQuery } = props;
|
||||
const isActive = tagQuery === tag.text;
|
||||
const hasSubTags = tag.subTags.length > 0;
|
||||
const [showSubTags, toggleSubTags] = useToggle(false);
|
||||
|
||||
const handleTagClick = () => {
|
||||
if (isActive) {
|
||||
locationService.setTagQuery("");
|
||||
} else {
|
||||
utils.copyTextToClipboard(`# ${tag.text} `);
|
||||
if (!["/", "/recycle"].includes(locationService.getState().pathname)) {
|
||||
locationService.setPathname("/");
|
||||
}
|
||||
locationService.setTagQuery(tag.text);
|
||||
}
|
||||
};
|
||||
|
||||
const handleToggleBtnClick = (event: React.MouseEvent) => {
|
||||
event.stopPropagation();
|
||||
toggleSubTags();
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className={`tag-item-container ${isActive ? "active" : ""}`} onClick={handleTagClick}>
|
||||
<div className="tag-text-container">
|
||||
<span className="icon-text">#</span>
|
||||
<span className="tag-text">{tag.key}</span>
|
||||
</div>
|
||||
<div className="btns-container">
|
||||
{hasSubTags ? (
|
||||
<span className={`action-btn toggle-btn ${showSubTags ? "shown" : ""}`} onClick={handleToggleBtnClick}>
|
||||
<img className="icon-img" src="/icons/arrow-right.svg" />
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{hasSubTags ? (
|
||||
<div className={`subtags-container ${showSubTags ? "" : "hidden"}`}>
|
||||
{tag.subTags.map((st, idx) => (
|
||||
<TagItemContainer key={st.text + "-" + idx} tag={st} tagQuery={tagQuery} />
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default TagList;
|
@ -0,0 +1,104 @@
|
||||
import { useEffect } from "react";
|
||||
import ReactDOM from "react-dom";
|
||||
import { TOAST_ANIMATION_DURATION } from "../helpers/consts";
|
||||
import "../less/toast.less";
|
||||
|
||||
type ToastType = "normal" | "success" | "info" | "error";
|
||||
|
||||
type ToastConfig = {
|
||||
type: ToastType;
|
||||
content: string;
|
||||
duration: number;
|
||||
};
|
||||
|
||||
type ToastItemProps = {
|
||||
type: ToastType;
|
||||
content: string;
|
||||
duration: number;
|
||||
destory: FunctionType;
|
||||
};
|
||||
|
||||
const Toast: React.FC<ToastItemProps> = (props: ToastItemProps) => {
|
||||
const { destory, duration } = props;
|
||||
|
||||
useEffect(() => {
|
||||
if (duration > 0) {
|
||||
setTimeout(() => {
|
||||
destory();
|
||||
}, duration);
|
||||
}
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="toast-container" onClick={destory}>
|
||||
<p className="content-text">{props.content}</p>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
class ToastHelper {
|
||||
private shownToastAmount = 0;
|
||||
private toastWrapper: HTMLDivElement;
|
||||
private shownToastContainers: HTMLDivElement[] = [];
|
||||
|
||||
constructor() {
|
||||
const wrapperClassName = "toast-list-container";
|
||||
const tempDiv = document.createElement("div");
|
||||
tempDiv.className = wrapperClassName;
|
||||
document.body.appendChild(tempDiv);
|
||||
this.toastWrapper = tempDiv;
|
||||
}
|
||||
|
||||
public info = (content: string, duration = 3000) => {
|
||||
return this.showToast({ type: "normal", content, duration });
|
||||
};
|
||||
|
||||
public success = (content: string, duration = 3000) => {
|
||||
return this.showToast({ type: "success", content, duration });
|
||||
};
|
||||
|
||||
public error = (content: string, duration = 3000) => {
|
||||
return this.showToast({ type: "error", content, duration });
|
||||
};
|
||||
|
||||
private showToast = (config: ToastConfig) => {
|
||||
const tempDiv = document.createElement("div");
|
||||
tempDiv.className = `toast-wrapper ${config.type}`;
|
||||
this.toastWrapper.appendChild(tempDiv);
|
||||
this.shownToastAmount++;
|
||||
this.shownToastContainers.push(tempDiv);
|
||||
|
||||
setTimeout(() => {
|
||||
tempDiv.classList.add("showup");
|
||||
}, 0);
|
||||
|
||||
const cbs = {
|
||||
destory: () => {
|
||||
tempDiv.classList.add("destory");
|
||||
|
||||
setTimeout(() => {
|
||||
if (!tempDiv.parentElement) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.shownToastAmount--;
|
||||
if (this.shownToastAmount === 0) {
|
||||
for (const d of this.shownToastContainers) {
|
||||
ReactDOM.unmountComponentAtNode(d);
|
||||
d.remove();
|
||||
}
|
||||
this.shownToastContainers.splice(0, this.shownToastContainers.length);
|
||||
}
|
||||
}, TOAST_ANIMATION_DURATION);
|
||||
},
|
||||
};
|
||||
|
||||
ReactDOM.render(<Toast {...config} destory={cbs.destory} />, tempDiv);
|
||||
|
||||
return cbs;
|
||||
};
|
||||
}
|
||||
|
||||
const toastHelper = new ToastHelper();
|
||||
|
||||
export default toastHelper;
|
@ -0,0 +1,143 @@
|
||||
import { useCallback, useContext, useEffect, useRef, useState } from "react";
|
||||
import appContext from "../stores/appContext";
|
||||
import { globalStateService, locationService } from "../services";
|
||||
import { DAILY_TIMESTAMP } from "../helpers/consts";
|
||||
import utils from "../helpers/utils";
|
||||
import "../less/usage-heat-map.less";
|
||||
|
||||
const tableConfig = {
|
||||
width: 12,
|
||||
height: 7,
|
||||
};
|
||||
|
||||
const getInitialUsageStat = (usedDaysAmount: number, beginDayTimestemp: number): DailyUsageStat[] => {
|
||||
const initialUsageStat: DailyUsageStat[] = [];
|
||||
for (let i = 1; i <= usedDaysAmount; i++) {
|
||||
initialUsageStat.push({
|
||||
timestamp: beginDayTimestemp + DAILY_TIMESTAMP * i,
|
||||
count: 0,
|
||||
});
|
||||
}
|
||||
return initialUsageStat;
|
||||
};
|
||||
|
||||
interface DailyUsageStat {
|
||||
timestamp: number;
|
||||
count: number;
|
||||
}
|
||||
|
||||
interface Props {}
|
||||
|
||||
const UsageHeatMap: React.FC<Props> = () => {
|
||||
const todayTimeStamp = utils.getDateStampByDate(Date.now());
|
||||
const todayDay = new Date(todayTimeStamp).getDay() || 7;
|
||||
const nullCell = new Array(7 - todayDay).fill(0);
|
||||
const usedDaysAmount = (tableConfig.width - 1) * tableConfig.height + todayDay;
|
||||
const beginDayTimestemp = todayTimeStamp - usedDaysAmount * DAILY_TIMESTAMP;
|
||||
|
||||
const {
|
||||
memoState: { memos },
|
||||
} = useContext(appContext);
|
||||
const [allStat, setAllStat] = useState<DailyUsageStat[]>(getInitialUsageStat(usedDaysAmount, beginDayTimestemp));
|
||||
const [popupStat, setPopupStat] = useState<DailyUsageStat | null>(null);
|
||||
const [currentStat, setCurrentStat] = useState<DailyUsageStat | null>(null);
|
||||
const containerElRef = useRef<HTMLDivElement>(null);
|
||||
const popupRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const newStat: DailyUsageStat[] = getInitialUsageStat(usedDaysAmount, beginDayTimestemp);
|
||||
for (const m of memos) {
|
||||
const index = (utils.getDateStampByDate(m.createdAt) - beginDayTimestemp) / (1000 * 3600 * 24) - 1;
|
||||
if (index >= 0) {
|
||||
newStat[index].count += 1;
|
||||
}
|
||||
}
|
||||
setAllStat([...newStat]);
|
||||
}, [memos]);
|
||||
|
||||
const handleUsageStatItemMouseEnter = useCallback((event: React.MouseEvent, item: DailyUsageStat) => {
|
||||
setPopupStat(item);
|
||||
if (!popupRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { isMobileView } = globalStateService.getState();
|
||||
const targetEl = event.target as HTMLElement;
|
||||
const sidebarEl = document.querySelector(".sidebar-wrapper") as HTMLElement;
|
||||
popupRef.current.style.left = targetEl.offsetLeft - (containerElRef.current?.offsetLeft ?? 0) + "px";
|
||||
let topValue = targetEl.offsetTop;
|
||||
if (!isMobileView) {
|
||||
topValue -= sidebarEl.scrollTop;
|
||||
}
|
||||
popupRef.current.style.top = topValue + "px";
|
||||
}, []);
|
||||
|
||||
const handleUsageStatItemMouseLeave = useCallback(() => {
|
||||
setPopupStat(null);
|
||||
}, []);
|
||||
|
||||
const handleUsageStatItemClick = useCallback((item: DailyUsageStat) => {
|
||||
if (locationService.getState().query.duration?.from === item.timestamp) {
|
||||
locationService.setFromAndToQuery(0, 0);
|
||||
setCurrentStat(null);
|
||||
} else if (item.count > 0) {
|
||||
if (!["/", "/recycle"].includes(locationService.getState().pathname)) {
|
||||
locationService.setPathname("/");
|
||||
}
|
||||
locationService.setFromAndToQuery(item.timestamp, item.timestamp + DAILY_TIMESTAMP);
|
||||
setCurrentStat(item);
|
||||
}
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="usage-heat-map-wrapper" ref={containerElRef}>
|
||||
<div className="day-tip-text-container">
|
||||
<span className="tip-text">Mon</span>
|
||||
<span className="tip-text"></span>
|
||||
<span className="tip-text">Wed</span>
|
||||
<span className="tip-text"></span>
|
||||
<span className="tip-text">Fri</span>
|
||||
<span className="tip-text"></span>
|
||||
<span className="tip-text">Sun</span>
|
||||
</div>
|
||||
|
||||
{/* popup */}
|
||||
<div ref={popupRef} className={"usage-detail-container pop-up " + (popupStat ? "" : "hidden")}>
|
||||
{popupStat?.count} memos on <span className="date-text">{new Date(popupStat?.timestamp as number).toDateString()}</span>
|
||||
</div>
|
||||
|
||||
<div className="usage-heat-map">
|
||||
{allStat.map((v, i) => {
|
||||
const count = v.count;
|
||||
const colorLevel =
|
||||
count <= 0
|
||||
? ""
|
||||
: count <= 1
|
||||
? "stat-day-L1-bg"
|
||||
: count <= 2
|
||||
? "stat-day-L2-bg"
|
||||
: count <= 4
|
||||
? "stat-day-L3-bg"
|
||||
: "stat-day-L4-bg";
|
||||
|
||||
return (
|
||||
<span
|
||||
className={`stat-container ${colorLevel} ${currentStat === v ? "current" : ""} ${
|
||||
todayTimeStamp === v.timestamp ? "today" : ""
|
||||
}`}
|
||||
key={i}
|
||||
onMouseEnter={(e) => handleUsageStatItemMouseEnter(e, v)}
|
||||
onMouseLeave={handleUsageStatItemMouseLeave}
|
||||
onClick={() => handleUsageStatItemClick(v)}
|
||||
></span>
|
||||
);
|
||||
})}
|
||||
{nullCell.map((v, i) => (
|
||||
<span className="stat-container null" key={i}></span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default UsageHeatMap;
|
@ -0,0 +1,62 @@
|
||||
import { useCallback, useContext, useState } from "react";
|
||||
import appContext from "../stores/appContext";
|
||||
import { locationService } from "../services";
|
||||
import utils from "../helpers/utils";
|
||||
import MenuBtnsPopup from "./MenuBtnsPopup";
|
||||
import showDailyMemoDiaryDialog from "./DailyMemoDiaryDialog";
|
||||
import "../less/user-banner.less";
|
||||
|
||||
interface Props {}
|
||||
|
||||
const UserBanner: React.FC<Props> = () => {
|
||||
const {
|
||||
memoState: { memos, tags },
|
||||
userState: { user },
|
||||
} = useContext(appContext);
|
||||
const username = user ? user.username : "Memos";
|
||||
const createdDays = user ? Math.ceil((Date.now() - utils.getTimeStampByDate(user.createdAt)) / 1000 / 3600 / 24) : 0;
|
||||
|
||||
const [shouldShowPopupBtns, setShouldShowPopupBtns] = useState(false);
|
||||
|
||||
const handleUsernameClick = useCallback(() => {
|
||||
locationService.pushHistory("/");
|
||||
locationService.clearQuery();
|
||||
}, []);
|
||||
|
||||
const handlePopupBtnClick = () => {
|
||||
const sidebarEl = document.querySelector(".sidebar-wrapper") as HTMLElement;
|
||||
const popupEl = document.querySelector(".menu-btns-popup") as HTMLElement;
|
||||
popupEl.style.top = 54 - sidebarEl.scrollTop + "px";
|
||||
setShouldShowPopupBtns(true);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="user-banner-container">
|
||||
<div className="userinfo-header-container">
|
||||
<p className="username-text" onClick={handleUsernameClick}>
|
||||
{username}
|
||||
</p>
|
||||
<span className="action-btn menu-popup-btn" onClick={handlePopupBtnClick}>
|
||||
<img src="/icons/more.svg" className="icon-img" />
|
||||
</span>
|
||||
<MenuBtnsPopup shownStatus={shouldShowPopupBtns} setShownStatus={setShouldShowPopupBtns} />
|
||||
</div>
|
||||
<div className="status-text-container">
|
||||
<div className="status-text memos-text">
|
||||
<span className="amount-text">{memos.length}</span>
|
||||
<span className="type-text">MEMO</span>
|
||||
</div>
|
||||
<div className="status-text tags-text">
|
||||
<span className="amount-text">{tags.length}</span>
|
||||
<span className="type-text">TAG</span>
|
||||
</div>
|
||||
<div className="status-text duration-text" onClick={() => showDailyMemoDiaryDialog()}>
|
||||
<span className="amount-text">{createdDays}</span>
|
||||
<span className="type-text">DAY</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default UserBanner;
|
@ -0,0 +1,119 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { DAILY_TIMESTAMP } from "../../helpers/consts";
|
||||
import "../../less/common/date-picker.less";
|
||||
|
||||
interface DatePickerProps {
|
||||
className?: string;
|
||||
datestamp: DateStamp;
|
||||
handleDateStampChange: (datastamp: DateStamp) => void;
|
||||
}
|
||||
|
||||
const DatePicker: React.FC<DatePickerProps> = (props: DatePickerProps) => {
|
||||
const { className, datestamp, handleDateStampChange } = props;
|
||||
const [currentDateStamp, setCurrentDateStamp] = useState<DateStamp>(getMonthFirstDayDateStamp(datestamp));
|
||||
|
||||
useEffect(() => {
|
||||
setCurrentDateStamp(getMonthFirstDayDateStamp(datestamp));
|
||||
}, [datestamp]);
|
||||
|
||||
const firstDate = new Date(currentDateStamp);
|
||||
const firstDateDay = firstDate.getDay() === 0 ? 7 : firstDate.getDay();
|
||||
const dayList = [];
|
||||
for (let i = 1; i < firstDateDay; i++) {
|
||||
dayList.push({
|
||||
date: 0,
|
||||
datestamp: firstDate.getTime() - DAILY_TIMESTAMP * (7 - i),
|
||||
});
|
||||
}
|
||||
const dayAmount = getMonthDayAmount(currentDateStamp);
|
||||
for (let i = 1; i <= dayAmount; i++) {
|
||||
dayList.push({
|
||||
date: i,
|
||||
datestamp: firstDate.getTime() + DAILY_TIMESTAMP * (i - 1),
|
||||
});
|
||||
}
|
||||
|
||||
const handleDateItemClick = (datestamp: DateStamp) => {
|
||||
handleDateStampChange(datestamp);
|
||||
};
|
||||
|
||||
const handleChangeMonthBtnClick = (i: -1 | 1) => {
|
||||
const year = firstDate.getFullYear();
|
||||
const month = firstDate.getMonth() + 1;
|
||||
let nextDateStamp = 0;
|
||||
if (month === 1 && i === -1) {
|
||||
nextDateStamp = new Date(`${year - 1}/12/1`).getTime();
|
||||
} else if (month === 12 && i === 1) {
|
||||
nextDateStamp = new Date(`${year + 1}/1/1`).getTime();
|
||||
} else {
|
||||
nextDateStamp = new Date(`${year}/${month + i}/1`).getTime();
|
||||
}
|
||||
setCurrentDateStamp(getMonthFirstDayDateStamp(nextDateStamp));
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`date-picker-wrapper ${className}`}>
|
||||
<div className="date-picker-header">
|
||||
<span className="btn-text" onClick={() => handleChangeMonthBtnClick(-1)}>
|
||||
<img className="icon-img" src="/icons/arrow-left.svg" />
|
||||
</span>
|
||||
<span className="normal-text">
|
||||
{firstDate.getFullYear()} 年 {firstDate.getMonth() + 1} 月
|
||||
</span>
|
||||
<span className="btn-text" onClick={() => handleChangeMonthBtnClick(1)}>
|
||||
<img className="icon-img" src="/icons/arrow-right.svg" />
|
||||
</span>
|
||||
</div>
|
||||
<div className="date-picker-day-container">
|
||||
<div className="date-picker-day-header">
|
||||
<span className="day-item">周一</span>
|
||||
<span className="day-item">周二</span>
|
||||
<span className="day-item">周三</span>
|
||||
<span className="day-item">周四</span>
|
||||
<span className="day-item">周五</span>
|
||||
<span className="day-item">周六</span>
|
||||
<span className="day-item">周日</span>
|
||||
</div>
|
||||
|
||||
{dayList.map((d) => {
|
||||
if (d.date === 0) {
|
||||
return (
|
||||
<span key={d.datestamp} className="day-item null">
|
||||
{""}
|
||||
</span>
|
||||
);
|
||||
} else {
|
||||
return (
|
||||
<span
|
||||
key={d.datestamp}
|
||||
className={`day-item ${d.datestamp === datestamp ? "current" : ""}`}
|
||||
onClick={() => handleDateItemClick(d.datestamp)}
|
||||
>
|
||||
{d.date}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
function getMonthDayAmount(datestamp: DateStamp): number {
|
||||
const dateTemp = new Date(datestamp);
|
||||
const currentDate = new Date(`${dateTemp.getFullYear()}/${dateTemp.getMonth() + 1}/1`);
|
||||
const nextMonthDate =
|
||||
currentDate.getMonth() === 11
|
||||
? new Date(`${currentDate.getFullYear() + 1}/1/1`)
|
||||
: new Date(`${currentDate.getFullYear()}/${currentDate.getMonth() + 2}/1`);
|
||||
|
||||
return (nextMonthDate.getTime() - currentDate.getTime()) / DAILY_TIMESTAMP;
|
||||
}
|
||||
|
||||
function getMonthFirstDayDateStamp(timestamp: TimeStamp): DateStamp {
|
||||
const dateTemp = new Date(timestamp);
|
||||
const currentDate = new Date(`${dateTemp.getFullYear()}/${dateTemp.getMonth() + 1}/1`);
|
||||
return currentDate.getTime();
|
||||
}
|
||||
|
||||
export default DatePicker;
|
@ -0,0 +1,13 @@
|
||||
interface OnlyWhenProps {
|
||||
children: React.ReactElement;
|
||||
when: boolean;
|
||||
}
|
||||
|
||||
const OnlyWhen: React.FC<OnlyWhenProps> = (props: OnlyWhenProps) => {
|
||||
const { children, when } = props;
|
||||
return when ? <>{children}</> : null;
|
||||
};
|
||||
|
||||
const Only = OnlyWhen;
|
||||
|
||||
export default Only;
|
@ -0,0 +1,90 @@
|
||||
import React, { memo, useEffect, useRef } from "react";
|
||||
import useToggle from "../../hooks/useToggle";
|
||||
import "../../less/common/selector.less";
|
||||
|
||||
interface TVObject {
|
||||
text: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
className?: string;
|
||||
value: string;
|
||||
dataSource: TVObject[];
|
||||
handleValueChanged?: (value: string) => void;
|
||||
}
|
||||
|
||||
const nullItem = {
|
||||
text: "请选择",
|
||||
value: "",
|
||||
};
|
||||
|
||||
const Selector: React.FC<Props> = (props: Props) => {
|
||||
const { className, dataSource, handleValueChanged, value } = props;
|
||||
const [showSelector, toggleSelectorStatus] = useToggle(false);
|
||||
|
||||
const seletorElRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
let currentItem = nullItem;
|
||||
for (const d of dataSource) {
|
||||
if (d.value === value) {
|
||||
currentItem = d;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (showSelector) {
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
if (!seletorElRef.current?.contains(event.target as Node)) {
|
||||
toggleSelectorStatus(false);
|
||||
}
|
||||
};
|
||||
window.addEventListener("click", handleClickOutside, {
|
||||
capture: true,
|
||||
once: true,
|
||||
});
|
||||
}
|
||||
}, [showSelector]);
|
||||
|
||||
const handleItemClick = (item: TVObject) => {
|
||||
if (handleValueChanged) {
|
||||
handleValueChanged(item.value);
|
||||
}
|
||||
toggleSelectorStatus(false);
|
||||
};
|
||||
|
||||
const handleCurrentValueClick = (event: React.MouseEvent) => {
|
||||
event.stopPropagation();
|
||||
toggleSelectorStatus();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`selector-wrapper ${className ?? ""}`} ref={seletorElRef}>
|
||||
<div className={`current-value-container ${showSelector ? "active" : ""}`} onClick={handleCurrentValueClick}>
|
||||
<span className="value-text">{currentItem.text}</span>
|
||||
<span className="arrow-text">
|
||||
<img className="icon-img" src="/icons/arrow-right.svg" />
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className={`items-wrapper ${showSelector ? "" : "hidden"}`}>
|
||||
{dataSource.map((d) => {
|
||||
return (
|
||||
<div
|
||||
className={`item-container ${d.value === value ? "selected" : ""}`}
|
||||
key={d.value}
|
||||
onClick={() => {
|
||||
handleItemClick(d);
|
||||
}}
|
||||
>
|
||||
{d.text}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default memo(Selector);
|
@ -0,0 +1,144 @@
|
||||
type ResponseType<T = unknown> = {
|
||||
succeed: boolean;
|
||||
status: number;
|
||||
message: string;
|
||||
data: T;
|
||||
};
|
||||
|
||||
async function get<T>(url: string): Promise<ResponseType<T>> {
|
||||
const response = await fetch(url, {
|
||||
method: "GET",
|
||||
});
|
||||
const resData = (await response.json()) as ResponseType<T>;
|
||||
|
||||
if (!resData.succeed) {
|
||||
throw resData;
|
||||
}
|
||||
|
||||
return resData;
|
||||
}
|
||||
|
||||
async function post<T>(url: string, data?: BasicType): Promise<ResponseType<T>> {
|
||||
const response = await fetch(url, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
|
||||
const resData = (await response.json()) as ResponseType<T>;
|
||||
|
||||
if (!resData.succeed) {
|
||||
throw resData;
|
||||
}
|
||||
|
||||
return resData;
|
||||
}
|
||||
|
||||
namespace api {
|
||||
export function getUserInfo() {
|
||||
return get<Model.User>("/api/user/me");
|
||||
}
|
||||
|
||||
export function signin(username: string, password: string) {
|
||||
return post("/api/user/signin", { username, password });
|
||||
}
|
||||
|
||||
export function signup(username: string, password: string) {
|
||||
return post("/api/user/signup", { username, password });
|
||||
}
|
||||
|
||||
export function signout() {
|
||||
return post("/api/user/signout");
|
||||
}
|
||||
|
||||
export function checkUsernameUsable(username: string) {
|
||||
return get<boolean>("/api/user/checkusername?username=" + username);
|
||||
}
|
||||
|
||||
export function checkPasswordValid(password: string) {
|
||||
return post<boolean>("/api/user/checkpassword", { password });
|
||||
}
|
||||
|
||||
export function updateUserinfo(username?: string, password?: string, githubName?: string, wxUserId?: string) {
|
||||
return post("/api/user/update", {
|
||||
username,
|
||||
password,
|
||||
githubName,
|
||||
wxUserId,
|
||||
});
|
||||
}
|
||||
|
||||
export function getMyMemos() {
|
||||
return get<Model.Memo[]>("/api/memo/all");
|
||||
}
|
||||
|
||||
export function getMyDeletedMemos() {
|
||||
return get<Model.Memo[]>("/api/memo/deleted");
|
||||
}
|
||||
|
||||
export function createMemo(content: string) {
|
||||
return post<Model.Memo>("/api/memo/new", { content });
|
||||
}
|
||||
|
||||
export function getMemoById(id: string) {
|
||||
return get<Model.Memo>("/api/memo/?id=" + id);
|
||||
}
|
||||
|
||||
export function hideMemo(memoId: string) {
|
||||
return post("/api/memo/hide", {
|
||||
memoId,
|
||||
});
|
||||
}
|
||||
|
||||
export function restoreMemo(memoId: string) {
|
||||
return post("/api/memo/restore", {
|
||||
memoId,
|
||||
});
|
||||
}
|
||||
|
||||
export function deleteMemo(memoId: string) {
|
||||
return post("/api/memo/delete", {
|
||||
memoId,
|
||||
});
|
||||
}
|
||||
|
||||
export function updateMemo(memoId: string, content: string) {
|
||||
return post<Model.Memo>("/api/memo/update", { memoId, content });
|
||||
}
|
||||
|
||||
export function getLinkedMemos(memoId: string) {
|
||||
return get<Model.Memo[]>("/api/memo/linked?memoId=" + memoId);
|
||||
}
|
||||
|
||||
export function removeGithubName() {
|
||||
return post("/api/user/updategh", { githubName: "" });
|
||||
}
|
||||
|
||||
export function getMyQueries() {
|
||||
return get<Model.Query[]>("/api/query/all");
|
||||
}
|
||||
|
||||
export function createQuery(title: string, querystring: string) {
|
||||
return post<Model.Query>("/api/query/new", { title, querystring });
|
||||
}
|
||||
|
||||
export function updateQuery(queryId: string, title: string, querystring: string) {
|
||||
return post<Model.Query>("/api/query/update", { queryId, title, querystring });
|
||||
}
|
||||
|
||||
export function deleteQueryById(queryId: string) {
|
||||
return post("/api/query/delete", { queryId });
|
||||
}
|
||||
|
||||
export function pinQuery(queryId: string) {
|
||||
return post("/api/query/pin", { queryId });
|
||||
}
|
||||
|
||||
export function unpinQuery(queryId: string) {
|
||||
return post("/api/query/unpin", { queryId });
|
||||
}
|
||||
}
|
||||
|
||||
export default api;
|
@ -0,0 +1,23 @@
|
||||
// 移动端样式适配额外类名
|
||||
export const SHOW_SIDERBAR_MOBILE_CLASSNAME = "mobile-show-sidebar";
|
||||
|
||||
// 默认动画持续时长
|
||||
export const ANIMATION_DURATION = 200;
|
||||
|
||||
// toast 动画持续时长
|
||||
export const TOAST_ANIMATION_DURATION = 400;
|
||||
|
||||
// 一天的毫秒数
|
||||
export const DAILY_TIMESTAMP = 3600 * 24 * 1000;
|
||||
|
||||
// 标签 正则
|
||||
export const TAG_REG = /#\s(.+?)\s/g;
|
||||
|
||||
// URL 正则
|
||||
export const LINK_REG = /(https?:\/\/[^\s<\\*>']+)/g;
|
||||
|
||||
// 图片 正则
|
||||
export const IMAGE_URL_REG = /(https?:\/\/[^\s<\\*>']+\.(jpeg|jpg|gif|png|svg))/g;
|
||||
|
||||
// memo 关联正则
|
||||
export const MEMO_LINK_REG = /\[@(.+?)\]\((.+?)\)/g;
|
@ -0,0 +1,151 @@
|
||||
import { IMAGE_URL_REG, LINK_REG, MEMO_LINK_REG, TAG_REG } from "./consts";
|
||||
|
||||
export const relationConsts = [
|
||||
{ text: "且", value: "AND" },
|
||||
{ text: "或", value: "OR" },
|
||||
];
|
||||
|
||||
export const filterConsts = {
|
||||
TAG: {
|
||||
value: "TAG",
|
||||
text: "标签",
|
||||
operators: [
|
||||
{
|
||||
text: "包括",
|
||||
value: "CONTAIN",
|
||||
},
|
||||
{
|
||||
text: "排除",
|
||||
value: "NOT_CONTAIN",
|
||||
},
|
||||
],
|
||||
},
|
||||
TYPE: {
|
||||
value: "TYPE",
|
||||
text: "类型",
|
||||
operators: [
|
||||
{
|
||||
value: "IS",
|
||||
text: "是",
|
||||
},
|
||||
{
|
||||
value: "IS_NOT",
|
||||
text: "不是",
|
||||
},
|
||||
],
|
||||
values: [
|
||||
{
|
||||
value: "CONNECTED",
|
||||
text: "有关联",
|
||||
},
|
||||
{
|
||||
value: "NOT_TAGGED",
|
||||
text: "无标签",
|
||||
},
|
||||
{
|
||||
value: "LINKED",
|
||||
text: "有超链接",
|
||||
},
|
||||
{
|
||||
value: "IMAGED",
|
||||
text: "有图片",
|
||||
},
|
||||
],
|
||||
},
|
||||
TEXT: {
|
||||
value: "TEXT",
|
||||
text: "文本",
|
||||
operators: [
|
||||
{
|
||||
value: "CONTAIN",
|
||||
text: "包括",
|
||||
},
|
||||
{
|
||||
value: "NOT_CONTAIN",
|
||||
text: "排除",
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
export const memoSpecialTypes = filterConsts["TYPE"].values;
|
||||
|
||||
export const getTextWithMemoType = (type: string): string => {
|
||||
for (const t of memoSpecialTypes) {
|
||||
if (t.value === type) {
|
||||
return t.text;
|
||||
}
|
||||
}
|
||||
return "";
|
||||
};
|
||||
|
||||
export const getDefaultFilter = (): BaseFilter => {
|
||||
return {
|
||||
type: "TAG",
|
||||
value: {
|
||||
operator: "CONTAIN",
|
||||
value: "",
|
||||
},
|
||||
relation: "AND",
|
||||
};
|
||||
};
|
||||
|
||||
export const checkShouldShowMemoWithFilters = (memo: Model.Memo, filters: Filter[]) => {
|
||||
let shouldShow = true;
|
||||
|
||||
for (const f of filters) {
|
||||
const { relation } = f;
|
||||
const r = checkShouldShowMemo(memo, f);
|
||||
if (relation === "OR") {
|
||||
shouldShow = shouldShow || r;
|
||||
} else {
|
||||
shouldShow = shouldShow && r;
|
||||
}
|
||||
}
|
||||
|
||||
return shouldShow;
|
||||
};
|
||||
|
||||
export const checkShouldShowMemo = (memo: Model.Memo, filter: Filter) => {
|
||||
const {
|
||||
type,
|
||||
value: { operator, value },
|
||||
} = filter;
|
||||
|
||||
if (value === "") {
|
||||
return true;
|
||||
}
|
||||
|
||||
let shouldShow = true;
|
||||
|
||||
if (type === "TAG") {
|
||||
let contained = memo.content.includes(`# ${value}`);
|
||||
if (operator === "NOT_CONTAIN") {
|
||||
contained = !contained;
|
||||
}
|
||||
shouldShow = contained;
|
||||
} else if (type === "TYPE") {
|
||||
let matched = false;
|
||||
if (value === "NOT_TAGGED" && memo.content.match(TAG_REG) === null) {
|
||||
matched = true;
|
||||
} else if (value === "LINKED" && memo.content.match(LINK_REG) !== null) {
|
||||
matched = true;
|
||||
} else if (value === "IMAGED" && memo.content.match(IMAGE_URL_REG) !== null) {
|
||||
matched = true;
|
||||
} else if (value === "CONNECTED" && memo.content.match(MEMO_LINK_REG) !== null) {
|
||||
matched = true;
|
||||
}
|
||||
if (operator === "IS_NOT") {
|
||||
matched = !matched;
|
||||
}
|
||||
shouldShow = matched;
|
||||
} else if (type === "TEXT") {
|
||||
let contained = memo.content.includes(value);
|
||||
if (operator === "NOT_CONTAIN") {
|
||||
contained = !contained;
|
||||
}
|
||||
shouldShow = contained;
|
||||
}
|
||||
|
||||
return shouldShow;
|
||||
};
|
@ -0,0 +1,15 @@
|
||||
(() => {
|
||||
if (!String.prototype.replaceAll) {
|
||||
String.prototype.replaceAll = function (str: any, newStr: any) {
|
||||
// If a regex pattern
|
||||
if (Object.prototype.toString.call(str).toLowerCase() === "[object regexp]") {
|
||||
return this.replace(str, newStr);
|
||||
}
|
||||
|
||||
// If a string
|
||||
return this.replace(new RegExp(str, "g"), newStr);
|
||||
};
|
||||
}
|
||||
})();
|
||||
|
||||
export default null;
|
@ -0,0 +1,78 @@
|
||||
import { InputAction } from "tiny-undo";
|
||||
|
||||
/**
|
||||
* Define storage data type
|
||||
*/
|
||||
interface StorageData {
|
||||
// 编辑器输入缓存内容
|
||||
editorContentCache: string;
|
||||
// 分词开关
|
||||
shouldSplitMemoWord: boolean;
|
||||
// 是否隐藏图片链接地址
|
||||
shouldHideImageUrl: boolean;
|
||||
// markdown 解析开关
|
||||
shouldUseMarkdownParser: boolean;
|
||||
|
||||
// Editor setting
|
||||
useTinyUndoHistoryCache: boolean;
|
||||
|
||||
// tiny undo actions cache
|
||||
tinyUndoActionsCache: InputAction[];
|
||||
// tiny undo index cache
|
||||
tinyUndoIndexCache: number;
|
||||
}
|
||||
|
||||
type StorageKey = keyof StorageData;
|
||||
|
||||
/**
|
||||
* storage helper
|
||||
*/
|
||||
export namespace storage {
|
||||
export function get(keys: StorageKey[]): Partial<StorageData> {
|
||||
const data: Partial<StorageData> = {};
|
||||
|
||||
for (const key of keys) {
|
||||
try {
|
||||
const stringifyValue = localStorage.getItem(key);
|
||||
if (stringifyValue !== null) {
|
||||
const val = JSON.parse(stringifyValue);
|
||||
data[key] = val;
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error("Get storage failed in ", key, error);
|
||||
}
|
||||
}
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
export function set(data: Partial<StorageData>) {
|
||||
for (const key in data) {
|
||||
try {
|
||||
const stringifyValue = JSON.stringify(data[key as StorageKey]);
|
||||
localStorage.setItem(key, stringifyValue);
|
||||
} catch (error: any) {
|
||||
console.error("Save storage failed in ", key, error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function remove(keys: StorageKey[]) {
|
||||
for (const key of keys) {
|
||||
try {
|
||||
localStorage.removeItem(key);
|
||||
} catch (error: any) {
|
||||
console.error("Remove storage failed in ", key, error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function emitStorageChangedEvent() {
|
||||
const iframeEl = document.createElement("iframe");
|
||||
iframeEl.style.display = "none";
|
||||
document.body.appendChild(iframeEl);
|
||||
|
||||
iframeEl.contentWindow?.localStorage.setItem("t", Date.now().toString());
|
||||
iframeEl.remove();
|
||||
}
|
||||
}
|
@ -0,0 +1,219 @@
|
||||
namespace utils {
|
||||
export function getNowTimeStamp(): number {
|
||||
return Date.now();
|
||||
}
|
||||
|
||||
export function getOSVersion(): "Windows" | "MacOS" | "Linux" | "Unknown" {
|
||||
const appVersion = navigator.userAgent;
|
||||
let detectedOS: "Windows" | "MacOS" | "Linux" | "Unknown" = "Unknown";
|
||||
|
||||
if (appVersion.indexOf("Win") != -1) {
|
||||
detectedOS = "Windows";
|
||||
} else if (appVersion.indexOf("Mac") != -1) {
|
||||
detectedOS = "MacOS";
|
||||
} else if (appVersion.indexOf("Linux") != -1) {
|
||||
detectedOS = "Linux";
|
||||
}
|
||||
|
||||
return detectedOS;
|
||||
}
|
||||
|
||||
export function getTimeStampByDate(t: Date | number | string): number {
|
||||
if (typeof t === "string") {
|
||||
t = t.replaceAll("-", "/");
|
||||
}
|
||||
const d = new Date(t);
|
||||
|
||||
return d.getTime();
|
||||
}
|
||||
|
||||
export function getDateStampByDate(t: Date | number | string): number {
|
||||
const d = new Date(getTimeStampByDate(t));
|
||||
|
||||
return new Date(d.getFullYear(), d.getMonth(), d.getDate()).getTime();
|
||||
}
|
||||
|
||||
export function getDateString(t: Date | number | string): string {
|
||||
const d = new Date(getTimeStampByDate(t));
|
||||
|
||||
const year = d.getFullYear();
|
||||
const month = d.getMonth() + 1;
|
||||
const date = d.getDate();
|
||||
|
||||
return `${year}/${month}/${date}`;
|
||||
}
|
||||
|
||||
export function getTimeString(t: Date | number | string): string {
|
||||
const d = new Date(getTimeStampByDate(t));
|
||||
|
||||
const hours = d.getHours();
|
||||
const mins = d.getMinutes();
|
||||
|
||||
const hoursStr = hours < 10 ? "0" + hours : hours;
|
||||
const minsStr = mins < 10 ? "0" + mins : mins;
|
||||
|
||||
return `${hoursStr}:${minsStr}`;
|
||||
}
|
||||
|
||||
// For example: 2021-4-8 17:52:17
|
||||
export function getDateTimeString(t: Date | number | string): string {
|
||||
const d = new Date(getTimeStampByDate(t));
|
||||
|
||||
const year = d.getFullYear();
|
||||
const month = d.getMonth() + 1;
|
||||
const date = d.getDate();
|
||||
const hours = d.getHours();
|
||||
const mins = d.getMinutes();
|
||||
const secs = d.getSeconds();
|
||||
|
||||
const monthStr = month < 10 ? "0" + month : month;
|
||||
const dateStr = date < 10 ? "0" + date : date;
|
||||
const hoursStr = hours < 10 ? "0" + hours : hours;
|
||||
const minsStr = mins < 10 ? "0" + mins : mins;
|
||||
const secsStr = secs < 10 ? "0" + secs : secs;
|
||||
|
||||
return `${year}/${monthStr}/${dateStr} ${hoursStr}:${minsStr}:${secsStr}`;
|
||||
}
|
||||
|
||||
export function dedupe<T>(data: T[]): T[] {
|
||||
return Array.from(new Set(data));
|
||||
}
|
||||
|
||||
export function dedupeObjectWithId<T extends { id: string }>(data: T[]): T[] {
|
||||
const idSet = new Set<string>();
|
||||
const result = [];
|
||||
|
||||
for (const d of data) {
|
||||
if (!idSet.has(d.id)) {
|
||||
idSet.add(d.id);
|
||||
result.push(d);
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
export function debounce(fn: FunctionType, delay: number) {
|
||||
let timer: number | null = null;
|
||||
|
||||
return () => {
|
||||
if (timer) {
|
||||
clearTimeout(timer);
|
||||
timer = setTimeout(fn, delay);
|
||||
} else {
|
||||
timer = setTimeout(fn, delay);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export function throttle(fn: FunctionType, delay: number) {
|
||||
let valid = true;
|
||||
|
||||
return () => {
|
||||
if (!valid) {
|
||||
return false;
|
||||
}
|
||||
valid = false;
|
||||
setTimeout(() => {
|
||||
fn();
|
||||
valid = true;
|
||||
}, delay);
|
||||
};
|
||||
}
|
||||
|
||||
export function transformObjectToParamsString(object: KVObject): string {
|
||||
const params = [];
|
||||
const keys = Object.keys(object).sort();
|
||||
|
||||
for (const key of keys) {
|
||||
const val = object[key];
|
||||
if (val) {
|
||||
if (typeof val === "object") {
|
||||
params.push(...transformObjectToParamsString(val).split("&"));
|
||||
} else {
|
||||
params.push(`${key}=${val}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return params.join("&");
|
||||
}
|
||||
|
||||
export function transformParamsStringToObject(paramsString: string): KVObject {
|
||||
const object: KVObject = {};
|
||||
const params = paramsString.split("&");
|
||||
|
||||
for (const p of params) {
|
||||
const [key, val] = p.split("=");
|
||||
if (key && val) {
|
||||
object[key] = val;
|
||||
}
|
||||
}
|
||||
|
||||
return object;
|
||||
}
|
||||
|
||||
export function filterObjectNullKeys(object: KVObject): KVObject {
|
||||
if (!object) {
|
||||
return {};
|
||||
}
|
||||
|
||||
const finalObject: KVObject = {};
|
||||
const keys = Object.keys(object).sort();
|
||||
|
||||
for (const key of keys) {
|
||||
const val = object[key];
|
||||
if (typeof val === "object") {
|
||||
const temp = filterObjectNullKeys(JSON.parse(JSON.stringify(val)));
|
||||
if (temp && Object.keys(temp).length > 0) {
|
||||
finalObject[key] = temp;
|
||||
}
|
||||
} else {
|
||||
if (Boolean(val)) {
|
||||
finalObject[key] = val;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return finalObject;
|
||||
}
|
||||
|
||||
export async function copyTextToClipboard(text: string) {
|
||||
if (navigator.clipboard && navigator.clipboard.writeText) {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
} catch (error: unknown) {
|
||||
console.warn("Copy to clipboard failed.", error);
|
||||
}
|
||||
} else {
|
||||
console.warn("Copy to clipboard failed, methods not supports.");
|
||||
}
|
||||
}
|
||||
|
||||
export function getImageSize(src: string): Promise<{ width: number; height: number }> {
|
||||
return new Promise((resolve) => {
|
||||
const imgEl = new Image();
|
||||
|
||||
imgEl.onload = () => {
|
||||
const { width, height } = imgEl;
|
||||
|
||||
if (width > 0 && height > 0) {
|
||||
resolve({ width, height });
|
||||
} else {
|
||||
resolve({ width: 0, height: 0 });
|
||||
}
|
||||
};
|
||||
|
||||
imgEl.onerror = () => {
|
||||
resolve({ width: 0, height: 0 });
|
||||
};
|
||||
|
||||
imgEl.className = "hidden";
|
||||
imgEl.src = src;
|
||||
document.body.appendChild(imgEl);
|
||||
imgEl.remove();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export default utils;
|
@ -0,0 +1,52 @@
|
||||
// 验证器
|
||||
// * 主要用于验证表单
|
||||
const chineseReg = /[\u3000\u3400-\u4DBF\u4E00-\u9FFF]/;
|
||||
|
||||
export interface ValidatorConfig {
|
||||
// 最小长度
|
||||
minLength: number;
|
||||
// 最大长度
|
||||
maxLength: number;
|
||||
// 无空格
|
||||
noSpace: boolean;
|
||||
// 无中文
|
||||
noChinese: boolean;
|
||||
}
|
||||
|
||||
export function validate(text: string, config: Partial<ValidatorConfig>): { result: boolean; reason?: string } {
|
||||
if (config.minLength !== undefined) {
|
||||
if (text.length < config.minLength) {
|
||||
return {
|
||||
result: false,
|
||||
reason: "长度过短",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
if (config.maxLength !== undefined) {
|
||||
if (text.length > config.maxLength) {
|
||||
return {
|
||||
result: false,
|
||||
reason: "长度超出",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
if (config.noSpace && text.includes(" ")) {
|
||||
return {
|
||||
result: false,
|
||||
reason: "不应含有空格",
|
||||
};
|
||||
}
|
||||
|
||||
if (config.noChinese && chineseReg.test(text)) {
|
||||
return {
|
||||
result: false,
|
||||
reason: "不应含有中文字符",
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
result: true,
|
||||
};
|
||||
}
|
@ -0,0 +1,27 @@
|
||||
import { useCallback, useRef } from "react";
|
||||
|
||||
/**
|
||||
* useDebounce: useRef + useCallback
|
||||
* @param func function
|
||||
* @param delay delay duration
|
||||
* @param deps depends
|
||||
* @returns debounced function
|
||||
*/
|
||||
export default function useDebounce<T extends (...args: any[]) => any>(func: T, delay: number, deps: any[] = []): T {
|
||||
const timer = useRef<number>();
|
||||
|
||||
const cancel = useCallback(() => {
|
||||
if (timer.current) {
|
||||
clearTimeout(timer.current);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const run = useCallback((...args) => {
|
||||
cancel();
|
||||
timer.current = window.setTimeout(() => {
|
||||
func(...args);
|
||||
}, delay);
|
||||
}, deps);
|
||||
|
||||
return run as T;
|
||||
}
|
@ -0,0 +1,35 @@
|
||||
import { useState } from "react";
|
||||
|
||||
function useLoading(initialState: boolean = true) {
|
||||
const [state, setState] = useState({ isLoading: initialState, isFailed: false, isSucceed: false });
|
||||
|
||||
return {
|
||||
...state,
|
||||
setLoading: () => {
|
||||
setState({
|
||||
...state,
|
||||
isLoading: true,
|
||||
isFailed: false,
|
||||
isSucceed: false,
|
||||
});
|
||||
},
|
||||
setFinish: () => {
|
||||
setState({
|
||||
...state,
|
||||
isLoading: false,
|
||||
isFailed: false,
|
||||
isSucceed: true,
|
||||
});
|
||||
},
|
||||
setError: () => {
|
||||
setState({
|
||||
...state,
|
||||
isLoading: false,
|
||||
isFailed: true,
|
||||
isSucceed: false,
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export default useLoading;
|
@ -0,0 +1,15 @@
|
||||
import { useCallback, useState } from "react";
|
||||
|
||||
function useRefresh() {
|
||||
const [_, setBoolean] = useState<Boolean>(false);
|
||||
|
||||
const refresh = useCallback(() => {
|
||||
setBoolean((ps) => {
|
||||
return !ps;
|
||||
});
|
||||
}, []);
|
||||
|
||||
return refresh;
|
||||
}
|
||||
|
||||
export default useRefresh;
|
@ -0,0 +1,19 @@
|
||||
import { useCallback, useState } from "react";
|
||||
|
||||
// Parameter is the boolean, with default "false" value
|
||||
export default function useToggle(initialState = false): [boolean, (nextState?: boolean) => void] {
|
||||
// Initialize the state
|
||||
const [state, setState] = useState(initialState);
|
||||
|
||||
// Define and memorize toggler function in case we pass down the comopnent,
|
||||
// This function change the boolean value to it's opposite value
|
||||
const toggle = useCallback((nextState?: boolean) => {
|
||||
if (nextState !== undefined) {
|
||||
setState(nextState);
|
||||
} else {
|
||||
setState((state) => !state);
|
||||
}
|
||||
}, []);
|
||||
|
||||
return [state, toggle];
|
||||
}
|
@ -0,0 +1,31 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { Store } from "./createStore";
|
||||
|
||||
interface Props {
|
||||
children: React.ReactElement;
|
||||
store: Store<any, any>;
|
||||
context: React.Context<any>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Toy-Redux Provider
|
||||
* Just for debug with the app store
|
||||
*/
|
||||
const Provider: React.FC<Props> = (props: Props) => {
|
||||
const { children, store, context: Context } = props;
|
||||
const [appState, setAppState] = useState(store.getState());
|
||||
|
||||
useEffect(() => {
|
||||
const unsubscribe = store.subscribe((ns) => {
|
||||
setAppState(ns);
|
||||
});
|
||||
|
||||
return () => {
|
||||
unsubscribe();
|
||||
};
|
||||
}, []);
|
||||
|
||||
return <Context.Provider value={appState}>{children}</Context.Provider>;
|
||||
};
|
||||
|
||||
export default Provider;
|
@ -0,0 +1,36 @@
|
||||
import { Action, Reducer, State } from "./createStore";
|
||||
|
||||
interface ReducersMapObject<S extends State = any, A extends Action = any> {
|
||||
[key: string]: Reducer<S, A>;
|
||||
}
|
||||
|
||||
type StateFromReducersMapObject<M> = M extends ReducersMapObject
|
||||
? { [P in keyof M]: M[P] extends Reducer<infer S, any> ? S : never }
|
||||
: never;
|
||||
|
||||
function combineReducers<S extends State, A extends Action>(reducers: ReducersMapObject): Reducer<S, A> {
|
||||
const reducerKeys = Object.keys(reducers);
|
||||
const finalReducersObj: ReducersMapObject = {};
|
||||
|
||||
for (const key of reducerKeys) {
|
||||
if (typeof reducers[key] === "function") {
|
||||
finalReducersObj[key] = reducers[key];
|
||||
}
|
||||
}
|
||||
|
||||
return ((state: StateFromReducersMapObject<typeof reducers> = {}, action: A) => {
|
||||
let hasChanged = false;
|
||||
const nextState: StateFromReducersMapObject<typeof reducers> = {};
|
||||
|
||||
for (const key of reducerKeys) {
|
||||
const prevStateForKey = state[key];
|
||||
const nextStateForKey = finalReducersObj[key](prevStateForKey, action);
|
||||
nextState[key] = nextStateForKey;
|
||||
hasChanged = hasChanged || nextStateForKey !== prevStateForKey;
|
||||
}
|
||||
|
||||
return hasChanged ? nextState : state;
|
||||
}) as any as Reducer<S, A>;
|
||||
}
|
||||
|
||||
export default combineReducers;
|
@ -0,0 +1,63 @@
|
||||
export type State = Readonly<Object>;
|
||||
export type Action = {
|
||||
type: string;
|
||||
payload: any;
|
||||
};
|
||||
|
||||
export type Reducer<S extends State, A extends Action> = (s: S, a: A) => S;
|
||||
type Listener<S extends State> = (ns: S, ps?: S) => void;
|
||||
type Unsubscribe = () => void;
|
||||
|
||||
export interface Store<S extends State, A extends Action> {
|
||||
dispatch: (a: A) => void;
|
||||
getState: () => S;
|
||||
subscribe: (listener: Listener<S>) => Unsubscribe;
|
||||
}
|
||||
|
||||
/**
|
||||
* 简单实现的 Redux
|
||||
* @param preloadedState 初始 state
|
||||
* @param reducer reducer pure function
|
||||
* @returns store
|
||||
*/
|
||||
function createStore<S extends State, A extends Action>(preloadedState: S, reducer: Reducer<S, A>): Store<Readonly<S>, A> {
|
||||
const listeners: Listener<S>[] = [];
|
||||
let currentState = preloadedState;
|
||||
|
||||
const dispatch = (action: A) => {
|
||||
const nextState = reducer(currentState, action);
|
||||
const prevState = currentState;
|
||||
currentState = nextState;
|
||||
|
||||
for (const cb of listeners) {
|
||||
cb(currentState, prevState);
|
||||
}
|
||||
};
|
||||
|
||||
const subscribe = (listener: Listener<S>) => {
|
||||
let isSubscribed = true;
|
||||
listeners.push(listener);
|
||||
|
||||
return () => {
|
||||
if (!isSubscribed) {
|
||||
return;
|
||||
}
|
||||
|
||||
const index = listeners.indexOf(listener);
|
||||
listeners.splice(index, 1);
|
||||
isSubscribed = false;
|
||||
};
|
||||
};
|
||||
|
||||
const getState = () => {
|
||||
return currentState;
|
||||
};
|
||||
|
||||
return {
|
||||
dispatch,
|
||||
getState,
|
||||
subscribe,
|
||||
};
|
||||
}
|
||||
|
||||
export default createStore;
|
@ -0,0 +1,21 @@
|
||||
const cachedResource = new Map<string, string>();
|
||||
|
||||
function convertResourceToDataURL(url: string, useCache = true): Promise<string> {
|
||||
if (useCache && cachedResource.has(url)) {
|
||||
return Promise.resolve(cachedResource.get(url) as string);
|
||||
}
|
||||
|
||||
return new Promise(async (resolve) => {
|
||||
const res = await fetch(url);
|
||||
const blob = await res.blob();
|
||||
var reader = new FileReader();
|
||||
reader.onloadend = () => {
|
||||
const base64Url = reader.result as string;
|
||||
cachedResource.set(url, base64Url);
|
||||
resolve(base64Url);
|
||||
};
|
||||
reader.readAsDataURL(blob);
|
||||
});
|
||||
}
|
||||
|
||||
export default convertResourceToDataURL;
|
@ -0,0 +1,37 @@
|
||||
import convertResourceToDataURL from "./convertResourceToDataURL";
|
||||
|
||||
async function getCloneStyledElement(element: HTMLElement) {
|
||||
const clonedElementContainer = document.createElement(element.tagName);
|
||||
clonedElementContainer.innerHTML = element.innerHTML;
|
||||
|
||||
const applyStyles = async (sourceElement: HTMLElement, clonedElement: HTMLElement) => {
|
||||
if (!sourceElement || !clonedElement) {
|
||||
return;
|
||||
}
|
||||
|
||||
const sourceStyles = window.getComputedStyle(sourceElement);
|
||||
|
||||
if (sourceElement.tagName === "IMG") {
|
||||
try {
|
||||
const url = await convertResourceToDataURL(sourceElement.getAttribute("src") ?? "");
|
||||
(clonedElement as HTMLImageElement).src = url;
|
||||
} catch (error) {
|
||||
// do nth
|
||||
}
|
||||
}
|
||||
|
||||
for (const item of sourceStyles) {
|
||||
clonedElement.style.setProperty(item, sourceStyles.getPropertyValue(item), sourceStyles.getPropertyPriority(item));
|
||||
}
|
||||
|
||||
for (let i = 0; i < clonedElement.childElementCount; i++) {
|
||||
await applyStyles(sourceElement.children[i] as HTMLElement, clonedElement.children[i] as HTMLElement);
|
||||
}
|
||||
};
|
||||
|
||||
await applyStyles(element, clonedElementContainer);
|
||||
|
||||
return clonedElementContainer;
|
||||
}
|
||||
|
||||
export default getCloneStyledElement;
|
@ -0,0 +1,142 @@
|
||||
/**
|
||||
* HTML to Image
|
||||
*
|
||||
* References:
|
||||
* 1. html-to-image: https://github.com/bubkoo/html-to-image
|
||||
* 2. <foreignObject>: https://developer.mozilla.org/en-US/docs/Web/SVG/Element/foreignObject
|
||||
*/
|
||||
import convertResourceToDataURL from "./convertResourceToDataURL";
|
||||
import getCloneStyledElement from "./getCloneStyledElement";
|
||||
|
||||
type Options = Partial<{
|
||||
backgroundColor: string;
|
||||
pixelRatio: number;
|
||||
}>;
|
||||
|
||||
function getElementSize(element: HTMLElement) {
|
||||
const { width, height } = window.getComputedStyle(element);
|
||||
|
||||
return {
|
||||
width: parseInt(width.replace("px", "")),
|
||||
height: parseInt(height.replace("px", "")),
|
||||
};
|
||||
}
|
||||
|
||||
function convertSVGToDataURL(svg: SVGElement): string {
|
||||
const xml = new XMLSerializer().serializeToString(svg);
|
||||
const url = encodeURIComponent(xml);
|
||||
return `data:image/svg+xml;charset=utf-8,${url}`;
|
||||
}
|
||||
|
||||
function generateSVGElement(width: number, height: number, element: HTMLElement): SVGSVGElement {
|
||||
const xmlns = "http://www.w3.org/2000/svg";
|
||||
const svg = document.createElementNS(xmlns, "svg");
|
||||
|
||||
svg.setAttribute("width", `${width}`);
|
||||
svg.setAttribute("height", `${height}`);
|
||||
svg.setAttribute("viewBox", `0 0 ${width} ${height}`);
|
||||
|
||||
const foreignObject = document.createElementNS(xmlns, "foreignObject");
|
||||
|
||||
foreignObject.setAttribute("width", "100%");
|
||||
foreignObject.setAttribute("height", "100%");
|
||||
foreignObject.setAttribute("x", "0");
|
||||
foreignObject.setAttribute("y", "0");
|
||||
foreignObject.setAttribute("externalResourcesRequired", "true");
|
||||
|
||||
foreignObject.appendChild(element);
|
||||
svg.appendChild(foreignObject);
|
||||
|
||||
return svg;
|
||||
}
|
||||
|
||||
// TODO need rethink how to get the needed font-family
|
||||
async function getFontsStyleElement() {
|
||||
const styleElement = document.createElement("style");
|
||||
|
||||
const fonts = [
|
||||
{
|
||||
name: "DINPro",
|
||||
url: "/fonts/DINPro-Regular.otf",
|
||||
weight: "normal",
|
||||
},
|
||||
{
|
||||
name: "DINPro",
|
||||
url: "/fonts/DINPro-Bold.otf",
|
||||
weight: "bold",
|
||||
},
|
||||
{
|
||||
name: "ubuntu-mono",
|
||||
url: "/fonts/UbuntuMono.ttf",
|
||||
weight: "normal",
|
||||
},
|
||||
];
|
||||
|
||||
for (const f of fonts) {
|
||||
const base64Url = await convertResourceToDataURL(f.url);
|
||||
styleElement.innerHTML += `
|
||||
@font-face {
|
||||
font-family: "${f.name}";
|
||||
src: url("${base64Url}");
|
||||
font-weight: ${f.weight};
|
||||
}`;
|
||||
}
|
||||
|
||||
return styleElement;
|
||||
}
|
||||
|
||||
export async function toSVG(element: HTMLElement, options?: Options) {
|
||||
const { width, height } = getElementSize(element);
|
||||
|
||||
const clonedElement = await getCloneStyledElement(element);
|
||||
|
||||
if (options?.backgroundColor) {
|
||||
clonedElement.style.backgroundColor = options.backgroundColor;
|
||||
}
|
||||
|
||||
const svg = generateSVGElement(width, height, clonedElement);
|
||||
svg.prepend(await getFontsStyleElement());
|
||||
|
||||
const url = convertSVGToDataURL(svg);
|
||||
|
||||
return url;
|
||||
}
|
||||
|
||||
export async function toCanvas(element: HTMLElement, options?: Options): Promise<HTMLCanvasElement> {
|
||||
const url = await toSVG(element, options);
|
||||
|
||||
const imageEl = new Image();
|
||||
imageEl.src = url;
|
||||
|
||||
const canvas = document.createElement("canvas");
|
||||
const context = canvas.getContext("2d")!;
|
||||
const ratio = options?.pixelRatio || 1;
|
||||
const { width, height } = getElementSize(element);
|
||||
|
||||
canvas.width = width * ratio;
|
||||
canvas.height = height * ratio;
|
||||
|
||||
canvas.style.width = `${width}`;
|
||||
canvas.style.height = `${height}`;
|
||||
|
||||
if (options?.backgroundColor) {
|
||||
context.fillStyle = options.backgroundColor;
|
||||
context.fillRect(0, 0, canvas.width, canvas.height);
|
||||
}
|
||||
|
||||
return new Promise((resolve) => {
|
||||
imageEl.onload = () => {
|
||||
context.drawImage(imageEl, 0, 0, canvas.width, canvas.height);
|
||||
|
||||
resolve(canvas);
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
async function toImage(element: HTMLElement, options?: Options) {
|
||||
const canvas = await toCanvas(element, options);
|
||||
|
||||
return canvas.toDataURL();
|
||||
}
|
||||
|
||||
export default toImage;
|
@ -0,0 +1,29 @@
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
type State = Readonly<Object>;
|
||||
interface Action {
|
||||
type: string;
|
||||
}
|
||||
type Listener<S extends State> = (ns: S, ps?: S) => void;
|
||||
|
||||
interface Store<S extends State, A extends Action> {
|
||||
dispatch: (a: A) => void;
|
||||
getState: () => S;
|
||||
subscribe: (listener: Listener<S>) => () => void;
|
||||
}
|
||||
|
||||
export default function useSelector<S extends State, A extends Action>(store: Store<S, A>): S {
|
||||
const [state, setState] = useState(store.getState());
|
||||
|
||||
useEffect(() => {
|
||||
const unsubscribe = store.subscribe((ns) => {
|
||||
setState(ns);
|
||||
});
|
||||
|
||||
return () => {
|
||||
unsubscribe();
|
||||
};
|
||||
}, []);
|
||||
|
||||
return state;
|
||||
}
|
@ -0,0 +1,43 @@
|
||||
@import "./mixin.less";
|
||||
|
||||
.about-site-dialog {
|
||||
> .dialog-container {
|
||||
width: 420px;
|
||||
|
||||
> .dialog-content-container {
|
||||
line-height: 1.8;
|
||||
|
||||
> ul {
|
||||
margin: 4px 0;
|
||||
padding-left: 4px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
> hr {
|
||||
margin: 4px 0;
|
||||
width: 100%;
|
||||
height: 1px;
|
||||
background-color: lightgray;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.normal-text {
|
||||
.flex(row, flex-start, center);
|
||||
font-size: 13px;
|
||||
color: gray;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.pre-text {
|
||||
.mono-font-family();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@media only screen and (max-width: 875px) {
|
||||
.dialog-wrapper.about-site-dialog {
|
||||
padding: 24px 16px;
|
||||
padding-top: 64px;
|
||||
}
|
||||
}
|
@ -0,0 +1,7 @@
|
||||
@import "./mixin.less";
|
||||
|
||||
#root {
|
||||
.flex(row, flex-start, flex-start);
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
@ -0,0 +1,104 @@
|
||||
@import "./mixin.less";
|
||||
|
||||
.change-password-dialog,
|
||||
.bind-wxid-dialog {
|
||||
> .dialog-container {
|
||||
width: 300px;
|
||||
border-radius: 8px;
|
||||
|
||||
> .dialog-content-container {
|
||||
.flex(column, flex-start, flex-start);
|
||||
width: 100%;
|
||||
|
||||
> .tip-text {
|
||||
background-color: @bg-gray;
|
||||
font-size: 12px;
|
||||
padding: 8px;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
> .form-label {
|
||||
.flex(column, flex-start, flex-start);
|
||||
position: relative;
|
||||
width: 100%;
|
||||
line-height: 1.6;
|
||||
|
||||
> .normal-text {
|
||||
position: absolute;
|
||||
left: 8px;
|
||||
padding-left: 4px;
|
||||
flex-shrink: 0;
|
||||
font-size: 13px;
|
||||
line-height: 38px;
|
||||
color: gray;
|
||||
transition: all 0.2s linear;
|
||||
cursor: text;
|
||||
|
||||
&.not-null {
|
||||
top: 2px;
|
||||
left: 8px;
|
||||
background-color: white;
|
||||
font-size: 13px;
|
||||
line-height: 18px;
|
||||
padding: 0 4px;
|
||||
border-radius: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
&.input-form-label {
|
||||
padding: 12px 0;
|
||||
padding-bottom: 4px;
|
||||
|
||||
> input {
|
||||
width: 100%;
|
||||
padding: 6px 8px;
|
||||
font-size: 13px;
|
||||
line-height: 24px;
|
||||
border-radius: 4px;
|
||||
border: 1px solid lightgray;
|
||||
background-color: transparent;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
> .btns-container {
|
||||
.flex(row, flex-end, center);
|
||||
margin-top: 8px;
|
||||
width: 100%;
|
||||
|
||||
> .btn {
|
||||
font-size: 14px;
|
||||
padding: 6px 12px;
|
||||
border-radius: 4px;
|
||||
margin-right: 8px;
|
||||
background-color: lightgray;
|
||||
|
||||
&:hover {
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
&.cancel-btn {
|
||||
background-color: unset;
|
||||
}
|
||||
|
||||
&.confirm-btn {
|
||||
background-color: @text-green;
|
||||
color: white;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@media only screen and (max-width: 875px) {
|
||||
.dialog-wrapper.change-password-dialog,
|
||||
.dialog-wrapper.bind-wxid-dialog {
|
||||
padding: 24px 16px;
|
||||
padding-top: 64px;
|
||||
|
||||
> .dialog-container {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,81 @@
|
||||
@import "../mixin.less";
|
||||
|
||||
.date-picker-wrapper {
|
||||
.flex(column, flex-start, flex-start);
|
||||
padding: 16px;
|
||||
|
||||
> .date-picker-header {
|
||||
.flex(row, center, center);
|
||||
width: 100%;
|
||||
|
||||
> .btn-text {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
|
||||
> .icon-img {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background-color: @bg-whitegray;
|
||||
}
|
||||
}
|
||||
|
||||
> .normal-text {
|
||||
margin: 0 4px;
|
||||
line-height: 24px;
|
||||
}
|
||||
}
|
||||
|
||||
> .date-picker-day-container {
|
||||
.flex(row, flex-start, flex-start);
|
||||
width: 280px;
|
||||
flex-wrap: wrap;
|
||||
|
||||
> .date-picker-day-header {
|
||||
.flex(row, space-around, center);
|
||||
width: 100%;
|
||||
|
||||
> .day-item {
|
||||
.flex(column, center, center);
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
user-select: none;
|
||||
color: gray;
|
||||
font-size: 13px;
|
||||
margin: 2px 0;
|
||||
}
|
||||
}
|
||||
|
||||
> .day-item {
|
||||
.flex(column, center, center);
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 50%;
|
||||
font-size: 14px;
|
||||
user-select: none;
|
||||
cursor: pointer;
|
||||
margin: 2px;
|
||||
|
||||
&:hover {
|
||||
background-color: @bg-whitegray;
|
||||
}
|
||||
|
||||
&.current {
|
||||
background-color: @bg-light-blue;
|
||||
font-size: 16px;
|
||||
color: @text-blue;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
&.null {
|
||||
background-color: unset;
|
||||
cursor: unset;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,87 @@
|
||||
@import "../mixin.less";
|
||||
|
||||
.selector-wrapper {
|
||||
.flex(column, flex-start, flex-start);
|
||||
position: relative;
|
||||
height: 28px;
|
||||
|
||||
> .current-value-container {
|
||||
.flex(row, space-between, center);
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border: 1px solid @bg-gray;
|
||||
border-radius: 4px;
|
||||
padding: 0 8px;
|
||||
padding-right: 4px;
|
||||
background-color: white;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
|
||||
&:hover,
|
||||
&.active {
|
||||
background-color: @bg-whitegray;
|
||||
}
|
||||
|
||||
> .value-text {
|
||||
margin-right: 0px;
|
||||
font-size: 13px;
|
||||
line-height: 32px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
width: calc(100% - 20px);
|
||||
}
|
||||
|
||||
> .arrow-text {
|
||||
.flex(row, center, center);
|
||||
width: 16px;
|
||||
flex-shrink: 0;
|
||||
|
||||
> .icon-img {
|
||||
width: 16px;
|
||||
height: auto;
|
||||
opacity: 0.6;
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
> .items-wrapper {
|
||||
.flex(column, flex-start, flex-start);
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
left: 0;
|
||||
width: auto;
|
||||
min-width: calc(100% + 16px);
|
||||
max-height: 256px;
|
||||
padding: 4px;
|
||||
overflow: auto;
|
||||
margin-top: 2px;
|
||||
margin-left: -8px;
|
||||
z-index: 1;
|
||||
background-color: white;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 0 8px 0 rgb(0 0 0 / 20%);
|
||||
.hide-scroll-bar();
|
||||
|
||||
> .item-container {
|
||||
.flex(column, flex-start, flex-start);
|
||||
width: 100%;
|
||||
padding-left: 12px;
|
||||
line-height: 30px;
|
||||
white-space: nowrap;
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
border-radius: 4px;
|
||||
user-select: none;
|
||||
|
||||
&:hover {
|
||||
background-color: @bg-whitegray;
|
||||
}
|
||||
|
||||
&.selected {
|
||||
color: @text-green;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,168 @@
|
||||
@import "./mixin.less";
|
||||
|
||||
.create-query-dialog {
|
||||
> .dialog-container {
|
||||
width: 420px;
|
||||
|
||||
> .dialog-content-container {
|
||||
.flex(column, flex-start, flex-start);
|
||||
|
||||
> .form-item-container {
|
||||
.flex(row, flex-start, flex-start);
|
||||
width: 100%;
|
||||
margin-top: 8px;
|
||||
padding: 4px 0;
|
||||
|
||||
> .normal-text {
|
||||
display: block;
|
||||
flex-shrink: 0;
|
||||
width: 40px;
|
||||
margin-right: 12px;
|
||||
text-align: right;
|
||||
color: gray;
|
||||
font-size: 13px;
|
||||
line-height: 32px;
|
||||
}
|
||||
|
||||
> .title-input {
|
||||
width: 100%;
|
||||
padding: 0 8px;
|
||||
font-size: 13px;
|
||||
line-height: 32px;
|
||||
border-radius: 4px;
|
||||
border: 1px solid @bg-gray;
|
||||
resize: none;
|
||||
}
|
||||
|
||||
> .filters-wrapper {
|
||||
width: calc(100% - 56px);
|
||||
.flex(column, flex-start, flex-start);
|
||||
|
||||
> .create-filter-btn {
|
||||
color: @text-green;
|
||||
font-size: 13px;
|
||||
line-height: 32px;
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
> .dialog-footer-container {
|
||||
.flex(row, space-between, center);
|
||||
width: 100%;
|
||||
margin-top: 0;
|
||||
|
||||
> .btns-container {
|
||||
.flex(row, flex-start, center);
|
||||
|
||||
> .tip-text {
|
||||
font-size: 13px;
|
||||
color: gray;
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
||||
> .btn {
|
||||
padding: 6px 16px;
|
||||
font-size: 13px;
|
||||
border-radius: 4px;
|
||||
|
||||
&:hover {
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
&.disabled {
|
||||
color: lightgray;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
&.save-btn {
|
||||
background-color: @text-green;
|
||||
color: white;
|
||||
|
||||
&.requesting {
|
||||
cursor: wait;
|
||||
opacity: 0.8;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.memo-filter-input-wrapper {
|
||||
.flex(row, flex-start, center);
|
||||
width: 100%;
|
||||
margin-top: 8px;
|
||||
flex-shrink: 0;
|
||||
|
||||
&:first-of-type {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
> .selector-wrapper {
|
||||
margin-right: 4px;
|
||||
height: 34px;
|
||||
flex-grow: 0;
|
||||
flex-shrink: 0;
|
||||
|
||||
&.relation-selector {
|
||||
width: 48px;
|
||||
margin-left: -52px;
|
||||
}
|
||||
|
||||
&.type-selector {
|
||||
width: 62px;
|
||||
}
|
||||
|
||||
&.operator-selector {
|
||||
width: 62px;
|
||||
}
|
||||
|
||||
&.value-selector {
|
||||
flex-grow: 1;
|
||||
max-width: calc(100% - 152px);
|
||||
}
|
||||
}
|
||||
|
||||
> input.value-inputer {
|
||||
max-width: calc(100% - 152px);
|
||||
height: 34px;
|
||||
padding: 0 8px;
|
||||
flex-shrink: 0;
|
||||
flex-grow: 1;
|
||||
margin-right: 4px;
|
||||
border-radius: 4px;
|
||||
border: 1px solid @bg-gray;
|
||||
background-color: transparent;
|
||||
|
||||
&:hover {
|
||||
background-color: @bg-whitegray;
|
||||
}
|
||||
}
|
||||
|
||||
> .remove-btn {
|
||||
width: 16px;
|
||||
height: auto;
|
||||
cursor: pointer;
|
||||
opacity: 0.8;
|
||||
|
||||
&:hover {
|
||||
opacity: 0.6;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@media only screen and (max-width: 875px) {
|
||||
.dialog-wrapper.create-query-dialog {
|
||||
padding: 24px 16px;
|
||||
padding-top: 64px;
|
||||
justify-content: unset;
|
||||
overflow-x: hidden;
|
||||
|
||||
&::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,164 @@
|
||||
@import "./mixin.less";
|
||||
|
||||
.daily-memo-diary-dialog {
|
||||
> .dialog-container {
|
||||
width: 440px;
|
||||
max-width: 100%;
|
||||
padding: 0;
|
||||
|
||||
> .dialog-header-container {
|
||||
.flex(column, center, center);
|
||||
position: relative;
|
||||
width: 100%;
|
||||
padding: 24px;
|
||||
margin-bottom: 0;
|
||||
padding-bottom: 0;
|
||||
|
||||
> .header-wrapper {
|
||||
.flex(row, space-between, center);
|
||||
width: 100%;
|
||||
|
||||
> .btns-container {
|
||||
.flex(row, flex-start, center);
|
||||
|
||||
> .btn-text {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
margin-right: 8px;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
|
||||
&:last-child {
|
||||
margin-right: 0;
|
||||
}
|
||||
|
||||
> .icon-img {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background-color: lightgray;
|
||||
}
|
||||
|
||||
&.share-btn {
|
||||
padding: 2px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
> .dialog-content-container {
|
||||
.flex(column, flex-start, flex-start);
|
||||
width: 440px;
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
padding: 24px 24px;
|
||||
|
||||
> .date-card-container {
|
||||
.flex(column, center, center);
|
||||
margin: auto;
|
||||
padding-bottom: 24px;
|
||||
z-index: 1;
|
||||
user-select: none;
|
||||
|
||||
> .year-text {
|
||||
margin: auto;
|
||||
font-weight: bold;
|
||||
color: gray;
|
||||
text-align: center;
|
||||
line-height: 24px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
> .date-container {
|
||||
.flex(column, center, center);
|
||||
margin: auto;
|
||||
width: 96px;
|
||||
height: 96px;
|
||||
border-radius: 32px;
|
||||
box-shadow: 0 0 8px 0 rgb(0 0 0 / 20%);
|
||||
border: 1px solid rgb(0 0 0 / 10%);
|
||||
text-align: center;
|
||||
z-index: 1;
|
||||
|
||||
> .month-text,
|
||||
> .day-text {
|
||||
.flex(column, center, center);
|
||||
width: 100%;
|
||||
height: 24px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
> .month-text {
|
||||
background-color: @bg-blue;
|
||||
color: white;
|
||||
border-top-left-radius: 32px;
|
||||
border-top-right-radius: 32px;
|
||||
}
|
||||
|
||||
> .date-text {
|
||||
.flex(column, center, center);
|
||||
width: 100%;
|
||||
padding-top: 4px;
|
||||
height: 48px;
|
||||
font-size: 44px;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
> .day-text {
|
||||
font-size: 12px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
> .date-picker {
|
||||
margin: 0 auto;
|
||||
border: 1px solid lightgray;
|
||||
border-radius: 8px;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
> .tip-container {
|
||||
margin: auto;
|
||||
padding: 16px 0;
|
||||
|
||||
> .tip-text {
|
||||
font-style: italic;
|
||||
}
|
||||
}
|
||||
|
||||
> .dailymemos-wrapper {
|
||||
.flex(column, flex-start, flex-start);
|
||||
margin-top: 8px;
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@media only screen and (max-width: 875px) {
|
||||
.dialog-wrapper.daily-memo-diary-dialog {
|
||||
padding: 0;
|
||||
.hide-scroll-bar();
|
||||
|
||||
> .dialog-container {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border-radius: 0;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
padding-bottom: 16px;
|
||||
|
||||
> .dialog-header-container {
|
||||
padding-top: 32px;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,72 @@
|
||||
@import "./mixin.less";
|
||||
|
||||
.daily-memo-wrapper {
|
||||
.flex(row, flex-start, flex-start);
|
||||
position: relative;
|
||||
width: calc(100% - 24px);
|
||||
margin-left: 24px;
|
||||
padding: 0;
|
||||
padding-bottom: 24px;
|
||||
border: none;
|
||||
border-left: 2px solid @bg-whitegray;
|
||||
|
||||
&:last-child {
|
||||
border-left: none;
|
||||
padding-bottom: 0;
|
||||
}
|
||||
|
||||
> .time-wrapper {
|
||||
.flex(column, center, center);
|
||||
position: relative;
|
||||
left: -24px;
|
||||
margin-top: -2px;
|
||||
flex-shrink: 0;
|
||||
width: 48px;
|
||||
height: 28px;
|
||||
border-radius: 6px;
|
||||
background-color: @bg-lightgray;
|
||||
color: @text-gray;
|
||||
border: 2px solid white;
|
||||
|
||||
> .normal-text {
|
||||
margin: 0 auto;
|
||||
font-size: 11px;
|
||||
line-height: 24px;
|
||||
}
|
||||
}
|
||||
|
||||
> .memo-content-container {
|
||||
.flex(column, flex-start, flex-start);
|
||||
width: 100%;
|
||||
margin-left: -12px;
|
||||
padding: 0;
|
||||
font-size: 16px;
|
||||
|
||||
> .memo-content-text {
|
||||
.tag-span {
|
||||
cursor: unset;
|
||||
|
||||
&:hover {
|
||||
color: @text-blue;
|
||||
background-color: @bg-light-blue;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
> .images-container {
|
||||
.flex(column, flex-start, flex-start);
|
||||
width: 100%;
|
||||
|
||||
> img {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
border-radius: 4px;
|
||||
margin-bottom: 8px;
|
||||
|
||||
&:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,83 @@
|
||||
@import "./mixin.less";
|
||||
|
||||
.dialog-wrapper {
|
||||
.flex(column, flex-start, center);
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background-color: transparent;
|
||||
z-index: 100;
|
||||
transition: all 0.2s ease;
|
||||
overflow-x: hidden;
|
||||
overflow-y: scroll;
|
||||
padding: 64px 0;
|
||||
.hide-scroll-bar();
|
||||
|
||||
&.showup {
|
||||
background-color: rgba(0, 0, 0, 0.6);
|
||||
}
|
||||
|
||||
&.showoff {
|
||||
display: none;
|
||||
}
|
||||
|
||||
> .dialog-container {
|
||||
.flex(column, flex-start, flex-start);
|
||||
background-color: white;
|
||||
padding: 16px;
|
||||
border-radius: 8px;
|
||||
|
||||
> .dialog-header-container {
|
||||
.flex(row, space-between, center);
|
||||
width: 100%;
|
||||
margin-bottom: 16px;
|
||||
|
||||
> .title-text {
|
||||
> .icon-text {
|
||||
margin-right: 6px;
|
||||
font-size: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
.btn {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
padding: 2px;
|
||||
border-radius: 4px;
|
||||
|
||||
> .icon-img {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background-color: lightgray;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
> .dialog-content-container {
|
||||
.flex(column, flex-start, flex-start);
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
> .dialog-footer-container {
|
||||
.flex(row, flex-end, center);
|
||||
width: 100%;
|
||||
margin-top: 16px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@media only screen and (max-width: 875px) {
|
||||
.dialog-wrapper {
|
||||
width: 100%;
|
||||
padding: 0 16px;
|
||||
|
||||
> .dialog-container {
|
||||
max-width: 100%;
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,87 @@
|
||||
@import "./mixin.less";
|
||||
|
||||
.common-editor-wrapper {
|
||||
.flex(column, flex-start, flex-start);
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: auto;
|
||||
background-color: white;
|
||||
|
||||
> .common-editor-inputer {
|
||||
display: inline-block;
|
||||
width: 100%;
|
||||
min-height: 24px;
|
||||
max-height: 300px;
|
||||
font-size: 15px;
|
||||
line-height: 24px;
|
||||
resize: none;
|
||||
overflow-x: hidden;
|
||||
overflow-y: scroll;
|
||||
background-color: transparent;
|
||||
z-index: 1;
|
||||
margin-bottom: 4px;
|
||||
white-space: pre-wrap;
|
||||
.hide-scroll-bar();
|
||||
|
||||
&::placeholder {
|
||||
padding-left: 2px;
|
||||
}
|
||||
|
||||
&:focus {
|
||||
&::placeholder {
|
||||
color: lightgray;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
> .common-tools-wrapper {
|
||||
.flex(row, space-between, center);
|
||||
width: 100%;
|
||||
|
||||
> .common-tools-container {
|
||||
.flex(row, flex-start, center);
|
||||
}
|
||||
|
||||
> .btns-container {
|
||||
.flex(row, flex-end, center);
|
||||
flex-grow: 0;
|
||||
flex-shrink: 0;
|
||||
|
||||
> .action-btn {
|
||||
border: none;
|
||||
user-select: none;
|
||||
cursor: pointer;
|
||||
padding: 6px 12px;
|
||||
border-radius: 6px;
|
||||
font-size: 13px;
|
||||
line-height: 32px;
|
||||
|
||||
&:hover {
|
||||
opacity: 0.8;
|
||||
}
|
||||
}
|
||||
|
||||
> .cancel-btn {
|
||||
color: gray;
|
||||
background-color: transparent;
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
||||
> .confirm-btn {
|
||||
cursor: pointer;
|
||||
padding: 0 12px;
|
||||
background-color: @text-green;
|
||||
color: white;
|
||||
|
||||
&:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
> .icon-text {
|
||||
margin-left: 4px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,113 @@
|
||||
@import "./mixin.less";
|
||||
|
||||
// ⚠️ This font is only free for personal use but not for commercial.
|
||||
@font-face {
|
||||
font-family: "DINPro";
|
||||
src: url("/fonts/DINPro-Regular.otf");
|
||||
font-weight: normal;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: "DINPro";
|
||||
src: url("/fonts/DINPro-Bold.otf");
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: "ubuntu-mono";
|
||||
src: url("/fonts/UbuntuMono.ttf");
|
||||
font-style: normal;
|
||||
}
|
||||
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
color: @text-black;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
font-family: "DINPro", ui-sans-serif, -apple-system, "system-ui", "Segoe UI", Helvetica, "Apple Color Emoji", Arial, sans-serif,
|
||||
"Segoe UI Emoji", "Segoe UI Symbol";
|
||||
}
|
||||
|
||||
body,
|
||||
html {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
code {
|
||||
.mono-font-family();
|
||||
background-color: pink;
|
||||
padding: 2px 4px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
pre {
|
||||
.mono-font-family();
|
||||
|
||||
* {
|
||||
.mono-font-family();
|
||||
}
|
||||
}
|
||||
|
||||
label,
|
||||
input,
|
||||
button,
|
||||
textarea,
|
||||
img {
|
||||
background-color: transparent;
|
||||
user-select: none;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
border: none;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
input:-webkit-autofill,
|
||||
input:-webkit-autofill:hover,
|
||||
input:-webkit-autofill:focus,
|
||||
input:-webkit-autofill:active {
|
||||
box-shadow: 0 0 0 30px white inset !important;
|
||||
}
|
||||
|
||||
li {
|
||||
list-style-type: none;
|
||||
|
||||
&::before {
|
||||
content: "•";
|
||||
font-weight: bold;
|
||||
margin-right: 4px;
|
||||
}
|
||||
}
|
||||
|
||||
a {
|
||||
cursor: pointer;
|
||||
color: @text-blue;
|
||||
text-underline-offset: 2px;
|
||||
|
||||
&:hover {
|
||||
background-color: @bg-gray;
|
||||
}
|
||||
}
|
||||
|
||||
.btn {
|
||||
border: unset;
|
||||
background-color: unset;
|
||||
text-align: unset;
|
||||
font-size: unset;
|
||||
user-select: none;
|
||||
cursor: pointer;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.hidden {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
@media only screen and (max-width: 875px) {
|
||||
body,
|
||||
html {
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
}
|
@ -0,0 +1,48 @@
|
||||
@import "./mixin.less";
|
||||
|
||||
#root {
|
||||
background-color: #f6f5f4;
|
||||
}
|
||||
|
||||
#page-wrapper {
|
||||
.flex(row, flex-start, flex-start);
|
||||
width: 848px;
|
||||
max-width: 100%;
|
||||
height: 100%;
|
||||
margin: auto;
|
||||
transform: translateX(-16px);
|
||||
|
||||
> .content-wrapper {
|
||||
.flex(column, flex-start, flex-start);
|
||||
position: relative;
|
||||
width: 600px;
|
||||
height: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
@media only screen and (max-width: 875px) {
|
||||
body.mobile-show-sidebar {
|
||||
#page-wrapper {
|
||||
> .content-wrapper {
|
||||
transform: translateX(320px);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#page-wrapper {
|
||||
.flex(column, flex-start, flex-start);
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
padding: 0;
|
||||
transform: translateX(0);
|
||||
|
||||
> .content-wrapper {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
margin-left: 0;
|
||||
padding-top: 0;
|
||||
transition: all 0.4s ease;
|
||||
transform: translateX(0);
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,16 @@
|
||||
@import "./mixin.less";
|
||||
|
||||
.image-container {
|
||||
width: 200px;
|
||||
height: auto;
|
||||
overflow-y: scroll;
|
||||
cursor: pointer;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
.pretty-scroll-bar(2px, 0);
|
||||
|
||||
> img {
|
||||
margin: auto;
|
||||
width: 100%;
|
||||
height: auto;
|
||||
}
|
||||
}
|
@ -0,0 +1,196 @@
|
||||
@import "./mixin.less";
|
||||
|
||||
.dialog-wrapper.memo-card-dialog {
|
||||
> .dialog-container {
|
||||
padding: 0;
|
||||
background-color: transparent;
|
||||
|
||||
> * {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
> .memo-card-container {
|
||||
position: relative;
|
||||
.flex(column, flex-start, flex-start);
|
||||
width: 512px;
|
||||
min-height: 64px;
|
||||
max-width: 100%;
|
||||
padding: 12px 24px;
|
||||
margin-bottom: 12px;
|
||||
border-radius: 8px;
|
||||
background-color: @bg-paper-yellow;
|
||||
|
||||
> * {
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
> .header-container {
|
||||
.flex(row, space-between, center);
|
||||
width: 100%;
|
||||
height: auto;
|
||||
padding-bottom: 0;
|
||||
margin-bottom: 0;
|
||||
margin-top: 0;
|
||||
|
||||
> .time-text {
|
||||
font-size: 14px;
|
||||
color: gray;
|
||||
.mono-font-family();
|
||||
}
|
||||
|
||||
> .btns-container {
|
||||
.flex(row, flex-end, center);
|
||||
|
||||
> .btn {
|
||||
.flex(row, center, center);
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
margin-left: 4px;
|
||||
border-radius: 4px;
|
||||
|
||||
&:hover {
|
||||
background-color: white;
|
||||
}
|
||||
|
||||
> .icon-img {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
> .memo-container {
|
||||
.flex(column, flex-start, flex-start);
|
||||
width: 100%;
|
||||
padding-top: 8px;
|
||||
|
||||
> .memo-content-text {
|
||||
width: 100%;
|
||||
font-size: 16px;
|
||||
line-height: 1.6;
|
||||
word-wrap: break-word;
|
||||
word-break: break-all;
|
||||
|
||||
.tag-span {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
font-size: 14px;
|
||||
color: @text-blue;
|
||||
background-color: unset;
|
||||
cursor: unset;
|
||||
}
|
||||
}
|
||||
|
||||
> .images-wrapper {
|
||||
.flex(row, flex-start, flex-start);
|
||||
margin-top: 8px;
|
||||
width: 100%;
|
||||
overflow-x: auto;
|
||||
overflow-y: hidden;
|
||||
padding-bottom: 2px;
|
||||
.pretty-scroll-bar(0, 2px);
|
||||
|
||||
> .memo-img {
|
||||
margin-right: 8px;
|
||||
width: auto;
|
||||
height: 128px;
|
||||
flex-shrink: 0;
|
||||
flex-grow: 0;
|
||||
overflow-y: hidden;
|
||||
.hide-scroll-bar();
|
||||
|
||||
&:hover {
|
||||
border-color: lightgray;
|
||||
}
|
||||
|
||||
&:last-child {
|
||||
margin-right: 0;
|
||||
}
|
||||
|
||||
> img {
|
||||
width: auto;
|
||||
max-height: 128px;
|
||||
border-radius: 8px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
> .normal-text {
|
||||
margin-top: 8px;
|
||||
font-size: 13px;
|
||||
color: gray;
|
||||
}
|
||||
|
||||
> .layer-container,
|
||||
> .background-layer-container {
|
||||
position: absolute;
|
||||
bottom: -3px;
|
||||
left: 3px;
|
||||
width: calc(100% - 6px);
|
||||
height: 100%;
|
||||
border-radius: 8px;
|
||||
z-index: -1;
|
||||
background-color: @bg-paper-yellow;
|
||||
border-bottom: 1px solid lightgray;
|
||||
}
|
||||
|
||||
> .layer-container {
|
||||
z-index: 0;
|
||||
background-color: @bg-paper-yellow;
|
||||
border: 1px solid lightgray;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
}
|
||||
}
|
||||
|
||||
> .linked-memos-wrapper {
|
||||
.flex(column, flex-start, flex-start);
|
||||
width: 512px;
|
||||
max-width: 100%;
|
||||
margin-top: 8px;
|
||||
padding: 12px 24px;
|
||||
border-radius: 8px;
|
||||
background-color: white;
|
||||
|
||||
&:last-child {
|
||||
margin-bottom: 36px;
|
||||
}
|
||||
|
||||
> .normal-text {
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
> .linked-memo-container {
|
||||
font-size: 13px;
|
||||
line-height: 24px;
|
||||
margin-top: 8px;
|
||||
cursor: pointer;
|
||||
max-width: 100%;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
|
||||
&:hover {
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
> .time-text {
|
||||
color: gray;
|
||||
.mono-font-family();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@media only screen and (max-width: 875px) {
|
||||
.dialog-wrapper.memo-card-dialog {
|
||||
padding: 24px 16px;
|
||||
padding-top: 64px;
|
||||
}
|
||||
}
|
@ -0,0 +1,87 @@
|
||||
@import "./mixin.less";
|
||||
|
||||
.memo-content-text {
|
||||
.flex(column, flex-start, flex-start);
|
||||
width: 100%;
|
||||
word-wrap: break-word;
|
||||
word-break: break-word;
|
||||
white-space: pre-wrap;
|
||||
|
||||
> p {
|
||||
display: inline-block;
|
||||
width: 100%;
|
||||
height: auto;
|
||||
margin-bottom: 4px;
|
||||
font-size: 15px;
|
||||
line-height: 24px;
|
||||
min-height: 24px;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.tag-span {
|
||||
display: inline-block;
|
||||
width: auto;
|
||||
padding: 0 6px;
|
||||
line-height: 24px;
|
||||
font-size: 13px;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
color: @text-blue;
|
||||
background-color: @bg-light-blue;
|
||||
cursor: pointer;
|
||||
vertical-align: bottom;
|
||||
|
||||
&:hover {
|
||||
background-color: @text-blue;
|
||||
color: white;
|
||||
}
|
||||
}
|
||||
|
||||
.memo-link-text {
|
||||
display: inline-block;
|
||||
color: @text-blue;
|
||||
font-weight: bold;
|
||||
border-bottom: none;
|
||||
text-decoration: none;
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
opacity: 0.8;
|
||||
}
|
||||
}
|
||||
|
||||
.counter-block,
|
||||
.todo-block {
|
||||
display: inline-block;
|
||||
text-align: center;
|
||||
width: 2rem;
|
||||
.mono-font-family();
|
||||
}
|
||||
|
||||
pre {
|
||||
width: 100%;
|
||||
margin: 4px 0;
|
||||
padding: 8px 12px;
|
||||
border-radius: 4px;
|
||||
font-size: 15px;
|
||||
line-height: 1.5;
|
||||
background-color: #f6f5f4;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
}
|
||||
|
||||
@media only screen and (max-width: 875px) {
|
||||
.memo-content-text {
|
||||
> p {
|
||||
font-size: 15px;
|
||||
line-height: 26px;
|
||||
min-height: 26px;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.tag-span {
|
||||
line-height: 26px;
|
||||
font-size: 14px;
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,37 @@
|
||||
@import "./mixin.less";
|
||||
|
||||
.memo-editor-wrapper {
|
||||
.flex(column, flex-start, flex-start);
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: auto;
|
||||
background-color: white;
|
||||
padding: 16px;
|
||||
border-radius: 8px;
|
||||
border: 2px solid @bg-gray;
|
||||
|
||||
&.edit-ing {
|
||||
border-color: @text-blue;
|
||||
}
|
||||
|
||||
> .tip-text {
|
||||
font-size: 12px;
|
||||
line-height: 20px;
|
||||
color: @text-lightgray;
|
||||
}
|
||||
|
||||
> .memo-editor {
|
||||
.flex(column, flex-start, flex-start);
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: auto;
|
||||
background-color: white;
|
||||
}
|
||||
}
|
||||
|
||||
@media only screen and (max-width: 875px) {
|
||||
.memo-editor-wrapper {
|
||||
width: calc(100% - 24px);
|
||||
margin: auto;
|
||||
}
|
||||
}
|
@ -0,0 +1,41 @@
|
||||
@import "./mixin.less";
|
||||
|
||||
.filter-query-container {
|
||||
.flex(row, flex-start, flex-start);
|
||||
width: 100%;
|
||||
flex-wrap: wrap;
|
||||
padding: 12px 12px;
|
||||
padding-bottom: 4px;
|
||||
font-size: 13px;
|
||||
line-height: 1.8;
|
||||
|
||||
> .tip-text {
|
||||
padding: 2px 0;
|
||||
}
|
||||
|
||||
> .filter-item-container {
|
||||
padding: 2px 8px;
|
||||
margin-right: 6px;
|
||||
cursor: pointer;
|
||||
background-color: @bg-gray;
|
||||
border-radius: 4px;
|
||||
max-width: 200px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
|
||||
> .icon-text {
|
||||
letter-spacing: 2px;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
text-decoration: line-through;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@media only screen and (max-width: 875px) {
|
||||
.filter-query-container {
|
||||
padding-left: 20px;
|
||||
}
|
||||
}
|
@ -0,0 +1,44 @@
|
||||
@import "./mixin.less";
|
||||
@import "./memos-header.less";
|
||||
|
||||
.memo-trash-wrapper {
|
||||
.flex(column, flex-start, flex-start);
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
flex-grow: 1;
|
||||
overflow-y: scroll;
|
||||
.hide-scroll-bar();
|
||||
|
||||
> .section-header-container {
|
||||
width: 100%;
|
||||
height: 40px;
|
||||
margin-bottom: 0;
|
||||
|
||||
> .title-text {
|
||||
font-weight: bold;
|
||||
font-size: 18px;
|
||||
color: @text-black;
|
||||
}
|
||||
}
|
||||
|
||||
> .tip-text-container {
|
||||
width: 100%;
|
||||
height: 128px;
|
||||
.flex(column, center, center);
|
||||
}
|
||||
|
||||
> .deleted-memos-container {
|
||||
.flex(column, flex-start, flex-start);
|
||||
flex-grow: 1;
|
||||
width: 100%;
|
||||
overflow-y: scroll;
|
||||
padding-bottom: 64px;
|
||||
.hide-scroll-bar();
|
||||
}
|
||||
}
|
||||
|
||||
@media only screen and (max-width: 875px) {
|
||||
.deleted-memos-container {
|
||||
padding: 0 12px;
|
||||
}
|
||||
}
|
@ -0,0 +1,153 @@
|
||||
@import "./mixin.less";
|
||||
@import "./memo-content.less";
|
||||
|
||||
.memo-wrapper {
|
||||
.flex(column, flex-start, flex-start);
|
||||
width: 100%;
|
||||
padding: 12px 18px;
|
||||
background-color: white;
|
||||
margin-top: 8px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid transparent;
|
||||
|
||||
&:hover {
|
||||
border-color: @bg-gray;
|
||||
}
|
||||
|
||||
> .memo-top-wrapper {
|
||||
.flex(row, space-between, center);
|
||||
width: 100%;
|
||||
height: 24px;
|
||||
margin-bottom: 4px;
|
||||
|
||||
> .time-text {
|
||||
font-size: 12px;
|
||||
line-height: 24px;
|
||||
color: gray;
|
||||
flex-shrink: 0;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
> .btns-container {
|
||||
.flex(row, flex-end, center);
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
|
||||
> .more-action-btns-wrapper {
|
||||
.flex(column, flex-start, center);
|
||||
position: absolute;
|
||||
flex-wrap: nowrap;
|
||||
top: calc(100% - 8px);
|
||||
right: -16px;
|
||||
width: auto;
|
||||
height: auto;
|
||||
padding: 12px;
|
||||
z-index: 1;
|
||||
display: none;
|
||||
|
||||
&:hover {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
> .more-action-btns-container {
|
||||
width: 112px;
|
||||
height: auto;
|
||||
padding: 4px;
|
||||
white-space: nowrap;
|
||||
border-radius: 8px;
|
||||
background-color: white;
|
||||
box-shadow: 0 0 8px 0 rgb(0 0 0 / 20%);
|
||||
z-index: 1;
|
||||
|
||||
> .btn {
|
||||
width: 100%;
|
||||
padding: 8px 0;
|
||||
padding-left: 24px;
|
||||
border-radius: 4px;
|
||||
height: unset;
|
||||
line-height: unset;
|
||||
justify-content: flex-start;
|
||||
|
||||
&.delete-btn {
|
||||
color: @text-red;
|
||||
|
||||
&.final-confirm {
|
||||
font-weight: bold;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.btn {
|
||||
.flex(row, center, center);
|
||||
width: 100%;
|
||||
height: 28px;
|
||||
font-size: 13px;
|
||||
border-radius: 4px;
|
||||
|
||||
&:hover {
|
||||
background-color: @bg-whitegray;
|
||||
}
|
||||
|
||||
&.more-action-btn {
|
||||
width: 28px;
|
||||
cursor: unset;
|
||||
margin-right: -6px;
|
||||
opacity: 0.8;
|
||||
|
||||
> .icon-img {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background-color: unset;
|
||||
|
||||
& + .more-action-btns-wrapper {
|
||||
display: flex;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
> .memo-content-text {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
> .images-wrapper {
|
||||
.flex(row, flex-start, flex-start);
|
||||
margin-top: 8px;
|
||||
width: 100%;
|
||||
overflow-x: auto;
|
||||
overflow-y: hidden;
|
||||
padding-bottom: 4px;
|
||||
.pretty-scroll-bar(0, 2px);
|
||||
|
||||
> .memo-img {
|
||||
margin-right: 8px;
|
||||
width: auto;
|
||||
height: 128px;
|
||||
flex-shrink: 0;
|
||||
flex-grow: 0;
|
||||
overflow-y: hidden;
|
||||
.hide-scroll-bar();
|
||||
|
||||
&:hover {
|
||||
border-color: lightgray;
|
||||
}
|
||||
|
||||
&:last-child {
|
||||
margin-right: 0;
|
||||
}
|
||||
|
||||
> img {
|
||||
width: auto;
|
||||
max-height: 128px;
|
||||
border-radius: 8px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,43 @@
|
||||
@import "./mixin.less";
|
||||
|
||||
.memolist-wrapper {
|
||||
.flex(column, flex-start, flex-start);
|
||||
flex-grow: 1;
|
||||
width: 100%;
|
||||
overflow-y: scroll;
|
||||
.hide-scroll-bar();
|
||||
|
||||
> .memo-edit {
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
> .status-text-container {
|
||||
.flex(column, flex-start, center);
|
||||
width: 100%;
|
||||
margin-top: 16px;
|
||||
margin-bottom: 16px;
|
||||
|
||||
&.completed {
|
||||
margin-bottom: 64px;
|
||||
}
|
||||
|
||||
&.invisible {
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
> .status-text {
|
||||
font-size: 13px;
|
||||
color: gray;
|
||||
}
|
||||
}
|
||||
|
||||
&.completed {
|
||||
padding-bottom: 80px;
|
||||
}
|
||||
}
|
||||
|
||||
@media only screen and (max-width: 875px) {
|
||||
.memolist-wrapper {
|
||||
padding: 0 12px;
|
||||
}
|
||||
}
|
@ -0,0 +1,54 @@
|
||||
@import "./mixin.less";
|
||||
|
||||
.section-header-container,
|
||||
.memos-header-container {
|
||||
.flex(row, space-between, center);
|
||||
width: 100%;
|
||||
height: 40px;
|
||||
flex-wrap: nowrap;
|
||||
margin-top: 16px;
|
||||
margin-bottom: 8px;
|
||||
flex-shrink: 0;
|
||||
|
||||
> .title-text {
|
||||
.flex(row, flex-start, center);
|
||||
font-weight: bold;
|
||||
font-size: 18px;
|
||||
line-height: 40px;
|
||||
color: @text-black;
|
||||
margin-right: 8px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
flex-shrink: 0;
|
||||
cursor: pointer;
|
||||
|
||||
> .action-btn {
|
||||
.flex(row, center, center);
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
margin-right: 4px;
|
||||
flex-shrink: 0;
|
||||
background-color: unset;
|
||||
|
||||
> .icon-img {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
> .btns-container {
|
||||
.flex(row, flex-end, center);
|
||||
}
|
||||
}
|
||||
|
||||
@media only screen and (max-width: 875px) {
|
||||
.section-header-container,
|
||||
.memos-header-container {
|
||||
height: auto;
|
||||
margin-top: 16px;
|
||||
margin-bottom: 0;
|
||||
padding: 0 12px;
|
||||
padding-bottom: 8px;
|
||||
}
|
||||
}
|
@ -0,0 +1,46 @@
|
||||
@import "./mixin.less";
|
||||
|
||||
.menu-btns-popup {
|
||||
.flex(column, flex-start, flex-start);
|
||||
position: absolute;
|
||||
margin-top: 4px;
|
||||
margin-left: 90px;
|
||||
padding: 4px;
|
||||
width: 180px;
|
||||
border-radius: 8px;
|
||||
z-index: 2;
|
||||
box-shadow: 0 0 8px 0 rgb(0 0 0 / 20%);
|
||||
background-color: white;
|
||||
|
||||
&:hover {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
> .btn {
|
||||
.flex(row, flex-start, center);
|
||||
width: 100%;
|
||||
padding: 8px 16px;
|
||||
font-size: 14px;
|
||||
line-height: 1.6;
|
||||
border-radius: 4px;
|
||||
text-align: left;
|
||||
|
||||
> .icon {
|
||||
display: block;
|
||||
width: 28px;
|
||||
text-align: center;
|
||||
margin-right: 4px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background-color: @bg-whitegray;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@media only screen and (max-width: 875px) {
|
||||
.menu-btns-popup {
|
||||
margin-left: 64px;
|
||||
}
|
||||
}
|
@ -0,0 +1,55 @@
|
||||
@text-black: #37352f;
|
||||
@text-gray: #52504b;
|
||||
@text-lightgray: #cac8c4;
|
||||
@text-blue: #5783f7;
|
||||
@text-green: #55bb8e;
|
||||
@text-red: #d28653;
|
||||
|
||||
@bg-black: #2f3437;
|
||||
@bg-gray: #e4e4e4;
|
||||
@bg-whitegray: #f8f8f8;
|
||||
@bg-lightgray: #eaeaea;
|
||||
@bg-blue: #1337a3;
|
||||
@bg-yellow: yellow;
|
||||
@bg-light-blue: #eef3fe;
|
||||
@bg-paper-yellow: #fbf4de;
|
||||
|
||||
.mono-font-family {
|
||||
font-family: "ubuntu-mono", SFMono-Regular, Menlo, Consolas, "PT Mono", "Liberation Mono", Courier, monospace;
|
||||
}
|
||||
|
||||
.hide-scroll-bar {
|
||||
.pretty-scroll-bar(0, 0);
|
||||
|
||||
&::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.pretty-scroll-bar(@width: 0px, @height: 0px) {
|
||||
scrollbar-width: none;
|
||||
|
||||
&::-webkit-scrollbar {
|
||||
width: @width;
|
||||
height: @height;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-thumb {
|
||||
width: @width;
|
||||
height: @height;
|
||||
border-radius: 8px;
|
||||
background-color: #d5d5d5;
|
||||
|
||||
&:hover {
|
||||
background-color: #ccc;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.flex(@direction, @justify, @align) {
|
||||
display: flex;
|
||||
flex-direction: @direction;
|
||||
justify-content: @justify;
|
||||
align-items: @align;
|
||||
}
|
@ -0,0 +1,107 @@
|
||||
@import "./mixin.less";
|
||||
|
||||
.account-section-container {
|
||||
> .form-label {
|
||||
height: 28px;
|
||||
|
||||
&.username-label {
|
||||
> input {
|
||||
flex-grow: 0 !important;
|
||||
width: 128px;
|
||||
padding: 0 8px;
|
||||
margin-right: 4px;
|
||||
font-size: 14px;
|
||||
border: 1px solid lightgray;
|
||||
border-radius: 0;
|
||||
border-radius: 4px;
|
||||
line-height: 26px;
|
||||
background-color: transparent;
|
||||
|
||||
&:focus {
|
||||
border-color: black;
|
||||
}
|
||||
}
|
||||
|
||||
> .btns-container {
|
||||
.flex(row, flex-start, center);
|
||||
margin-left: 4px;
|
||||
flex-shrink: 0;
|
||||
|
||||
> .btn {
|
||||
font-size: 12px;
|
||||
padding: 0 16px;
|
||||
border-radius: 4px;
|
||||
line-height: 28px;
|
||||
margin-right: 8px;
|
||||
background-color: lightgray;
|
||||
|
||||
&:hover {
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
&.cancel-btn {
|
||||
background-color: unset;
|
||||
}
|
||||
|
||||
&.confirm-btn {
|
||||
background-color: @text-green;
|
||||
color: white;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&.password-label {
|
||||
> .btn {
|
||||
color: @text-blue;
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
opacity: 0.8;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.connect-section-container {
|
||||
> .form-label {
|
||||
height: 28px;
|
||||
|
||||
> .value-text {
|
||||
max-width: 128px;
|
||||
min-height: 20px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
> .btn-text {
|
||||
padding: 0 8px;
|
||||
margin-left: 12px;
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
line-height: 28px;
|
||||
|
||||
&:hover {
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
&.bind-btn,
|
||||
&.link-btn {
|
||||
color: white;
|
||||
background-color: @text-green;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
&.unbind-btn {
|
||||
color: #d28653;
|
||||
background-color: @bg-lightgray;
|
||||
|
||||
&.final-confirm {
|
||||
font-weight: bold;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|