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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|