diff --git a/web/.eslintrc.json b/web/.eslintrc.json new file mode 100644 index 00000000..7b74b597 --- /dev/null +++ b/web/.eslintrc.json @@ -0,0 +1,3 @@ +{ + "extends": ["prettier"] +} diff --git a/web/.gitignore b/web/.gitignore new file mode 100644 index 00000000..f791607e --- /dev/null +++ b/web/.gitignore @@ -0,0 +1,7 @@ +node_modules +.DS_Store +dist +dist-ssr +*.local + +.yarn/* diff --git a/web/.prettierrc b/web/.prettierrc new file mode 100644 index 00000000..2af2c51a --- /dev/null +++ b/web/.prettierrc @@ -0,0 +1,6 @@ +{ + "printWidth": 140, + "useTabs": false, + "semi": true, + "singleQuote": false +} diff --git a/web/LICENSE b/web/LICENSE new file mode 100644 index 00000000..f288702d --- /dev/null +++ b/web/LICENSE @@ -0,0 +1,674 @@ + GNU GENERAL PUBLIC LICENSE + Version 3, 29 June 2007 + + Copyright (C) 2007 Free Software Foundation, Inc. + 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. + + + Copyright (C) + + 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 . + +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: + + Copyright (C) + 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 +. + + 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 +. diff --git a/web/README.md b/web/README.md new file mode 100644 index 00000000..36f1f597 --- /dev/null +++ b/web/README.md @@ -0,0 +1 @@ +# Memos web diff --git a/web/index.html b/web/index.html new file mode 100644 index 00000000..b6a27ea2 --- /dev/null +++ b/web/index.html @@ -0,0 +1,25 @@ + + + + + + + + Memos + + +
+ + + + + + diff --git a/web/package.json b/web/package.json new file mode 100644 index 00000000..170f989a --- /dev/null +++ b/web/package.json @@ -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" +} diff --git a/web/public/fonts/DINPro-Bold.otf b/web/public/fonts/DINPro-Bold.otf new file mode 100644 index 00000000..7c839536 Binary files /dev/null and b/web/public/fonts/DINPro-Bold.otf differ diff --git a/web/public/fonts/DINPro-Regular.otf b/web/public/fonts/DINPro-Regular.otf new file mode 100644 index 00000000..84d57abb Binary files /dev/null and b/web/public/fonts/DINPro-Regular.otf differ diff --git a/web/public/fonts/UbuntuMono.ttf b/web/public/fonts/UbuntuMono.ttf new file mode 100644 index 00000000..fdd309d7 Binary files /dev/null and b/web/public/fonts/UbuntuMono.ttf differ diff --git a/web/public/icons/arrow-left.svg b/web/public/icons/arrow-left.svg new file mode 100644 index 00000000..b38183a9 --- /dev/null +++ b/web/public/icons/arrow-left.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/web/public/icons/arrow-right-white.svg b/web/public/icons/arrow-right-white.svg new file mode 100644 index 00000000..a08961a4 --- /dev/null +++ b/web/public/icons/arrow-right-white.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/web/public/icons/arrow-right.svg b/web/public/icons/arrow-right.svg new file mode 100644 index 00000000..4710da89 --- /dev/null +++ b/web/public/icons/arrow-right.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/web/public/icons/checkbox-active.svg b/web/public/icons/checkbox-active.svg new file mode 100644 index 00000000..e9fccd6b --- /dev/null +++ b/web/public/icons/checkbox-active.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/web/public/icons/checkbox.svg b/web/public/icons/checkbox.svg new file mode 100644 index 00000000..a6fe5262 --- /dev/null +++ b/web/public/icons/checkbox.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/web/public/icons/close.svg b/web/public/icons/close.svg new file mode 100644 index 00000000..bcd7b76a --- /dev/null +++ b/web/public/icons/close.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/web/public/icons/edit-white.svg b/web/public/icons/edit-white.svg new file mode 100644 index 00000000..8d9c4ee5 --- /dev/null +++ b/web/public/icons/edit-white.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/web/public/icons/edit.svg b/web/public/icons/edit.svg new file mode 100644 index 00000000..156eeeca --- /dev/null +++ b/web/public/icons/edit.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/web/public/icons/menu.svg b/web/public/icons/menu.svg new file mode 100644 index 00000000..133160e5 --- /dev/null +++ b/web/public/icons/menu.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/web/public/icons/more-white.svg b/web/public/icons/more-white.svg new file mode 100644 index 00000000..23516572 --- /dev/null +++ b/web/public/icons/more-white.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/web/public/icons/more.svg b/web/public/icons/more.svg new file mode 100644 index 00000000..40cfa5c3 --- /dev/null +++ b/web/public/icons/more.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/web/public/icons/search.svg b/web/public/icons/search.svg new file mode 100644 index 00000000..55b78ea0 --- /dev/null +++ b/web/public/icons/search.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/web/public/icons/share.svg b/web/public/icons/share.svg new file mode 100644 index 00000000..d875437f --- /dev/null +++ b/web/public/icons/share.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/web/public/icons/tag.svg b/web/public/icons/tag.svg new file mode 100644 index 00000000..8cf477e7 --- /dev/null +++ b/web/public/icons/tag.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/web/public/logo.svg b/web/public/logo.svg new file mode 100644 index 00000000..2978542b --- /dev/null +++ b/web/public/logo.svg @@ -0,0 +1 @@ +✍️ \ No newline at end of file diff --git a/web/src/App.tsx b/web/src/App.tsx new file mode 100644 index 00000000..e448b141 --- /dev/null +++ b/web/src/App.tsx @@ -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; diff --git a/web/src/components/AboutSiteDialog.tsx b/web/src/components/AboutSiteDialog.tsx new file mode 100644 index 00000000..19f76548 --- /dev/null +++ b/web/src/components/AboutSiteDialog.tsx @@ -0,0 +1,67 @@ +import { showDialog } from "./Dialog"; +import "../less/about-site-dialog.less"; + +interface Props extends DialogProps {} + +const AboutSiteDialog: React.FC = ({ destroy }: Props) => { + const handleCloseBtnClick = () => { + destroy(); + }; + + return ( + <> +
+

+ 🤠关于 Memos +

+ +
+
+

一个碎片化知识记录工具。

+
+ 为何做这个? +
    +
  • + 实践 卢曼卡片盒笔记法; +
  • +
  • 用于记录:📅 每日/周计划、💡 突发奇想、📕 读后感...
  • +
  • 代替了我在微信上经常使用的“文件传输助手”;
  • +
  • 打造一个属于自己的轻量化“卡片”笔记簿;
  • +
+
+ 有何特点呢? +
    +
  • + ✨{" "} + + 开源项目 + +
  • +
  • 😋 精美且细节的视觉样式;
  • +
  • 📑 体验优良的交互逻辑;
  • +
+
+ + 🤔 问题反馈 + +
+

Enjoy it and have fun~

+
+

+ Last updated on 2021/11/26 16:17:44 🎉 +

+
+ + ); +}; + +export default function showAboutSiteDialog(): void { + showDialog( + { + className: "about-site-dialog", + }, + AboutSiteDialog + ); +} diff --git a/web/src/components/BindWxUserIdDialog.tsx b/web/src/components/BindWxUserIdDialog.tsx new file mode 100644 index 00000000..e8c74374 --- /dev/null +++ b/web/src/components/BindWxUserIdDialog.tsx @@ -0,0 +1,79 @@ +import { useEffect, useState } from "react"; +import { userService } from "../services"; +import { showDialog } from "./Dialog"; +import toastHelper from "./Toast"; +import "../less/change-password-dialog.less"; + +interface Props extends DialogProps {} + +const BindWxUserIdDialog: React.FC = ({ destroy }: Props) => { + const [wxUserId, setWxUserId] = useState(""); + + useEffect(() => { + // do nth + }, []); + + const handleCloseBtnClick = () => { + destroy(); + }; + + const handleWxUserIdChanged = (e: React.ChangeEvent) => { + const text = e.target.value as string; + setWxUserId(text); + }; + + const handleSaveBtnClick = async () => { + if (wxUserId === "") { + toastHelper.error("微信 id 不能为空"); + return; + } + + try { + await userService.updateWxUserId(wxUserId); + userService.doSignIn(); + toastHelper.info("绑定成功!"); + handleCloseBtnClick(); + } catch (error: any) { + toastHelper.error(error); + } + }; + + return ( + <> +
+

绑定微信 OpenID

+ +
+
+

+ 关注微信公众号“小谈闲事”,主动发送任意消息,即可获取 OpenID 。 +

+ +
+ + 取消 + + + 保存 + +
+
+ + ); +}; + +function showBindWxUserIdDialog() { + showDialog( + { + className: "bind-wxid-dialog", + }, + BindWxUserIdDialog + ); +} + +export default showBindWxUserIdDialog; diff --git a/web/src/components/ChangePasswordDialog.tsx b/web/src/components/ChangePasswordDialog.tsx new file mode 100644 index 00000000..e29d8fd6 --- /dev/null +++ b/web/src/components/ChangePasswordDialog.tsx @@ -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 = ({ 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) => { + const text = e.target.value as string; + setOldPassword(text); + }; + + const handleNewPasswordChanged = (e: React.ChangeEvent) => { + const text = e.target.value as string; + setNewPassword(text); + }; + + const handleNewPasswordAgainChanged = (e: React.ChangeEvent) => { + 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 ( + <> +
+

修改密码

+ +
+
+ + + +
+ + 取消 + + + 保存 + +
+
+ + ); +}; + +function showChangePasswordDialog() { + showDialog( + { + className: "change-password-dialog", + }, + ChangePasswordDialog + ); +} + +export default showChangePasswordDialog; diff --git a/web/src/components/CreateQueryDialog.tsx b/web/src/components/CreateQueryDialog.tsx new file mode 100644 index 00000000..6c4e2909 --- /dev/null +++ b/web/src/components/CreateQueryDialog.tsx @@ -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) => { + const { destroy, queryId } = props; + + const [title, setTitle] = useState(""); + const [filters, setFilters] = useState([]); + 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) => { + 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 ( + <> +
+

+ 🔖 + {queryId ? "编辑检索" : "创建检索"} +

+ +
+
+
+ 标题 + +
+
+ 过滤器 +
+ {filters.map((f, index) => { + return ( + + ); + })} +
+ 添加筛选条件 +
+
+
+
+
+
+
+ + 符合条件的 Memo 有 {shownMemoLength} 条 + + +
+
+ + ); +}; + +interface MemoFilterInputerProps { + index: number; + filter: Filter; + handleFilterChange: (index: number, filter: Filter) => void; + handleFilterRemove: (index: number) => void; +} + +const MemoFilterInputer: React.FC = memo((props: MemoFilterInputerProps) => { + const { index, filter, handleFilterChange, handleFilterRemove } = props; + const { type } = filter; + const [inputElements, setInputElements] = useState(<>); + + useEffect(() => { + let operatorElement = <>; + if (Object.keys(filterConsts).includes(type)) { + operatorElement = ( + + ); + } + + let valueElement = <>; + switch (type) { + case "TYPE": { + valueElement = ( + + ); + break; + } + case "TAG": { + valueElement = ( + { + return { text: t, value: t }; + })} + value={filter.value.value} + handleValueChanged={handleValueChange} + /> + ); + break; + } + case "TEXT": { + valueElement = ( + { + 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 ( +
+ {index > 0 ? ( + + ) : null} + + + {inputElements} + +
+ ); +}); + +export default function showCreateQueryDialog(queryId?: string): void { + showDialog( + { + className: "create-query-dialog", + }, + CreateQueryDialog, + { queryId } + ); +} diff --git a/web/src/components/DailyMemo.tsx b/web/src/components/DailyMemo.tsx new file mode 100644 index 00000000..9fb8efed --- /dev/null +++ b/web/src/components/DailyMemo.tsx @@ -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) => { + 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 ( +
+
+ {memo.timeStr} +
+
+
+ 0}> +
+ {imageUrls.map((imgUrl, idx) => ( + + ))} +
+
+
+
+ ); +}; + +export default DailyMemo; diff --git a/web/src/components/DailyMemoDiaryDialog.tsx b/web/src/components/DailyMemoDiaryDialog.tsx new file mode 100644 index 00000000..78d140d4 --- /dev/null +++ b/web/src/components/DailyMemoDiaryDialog.tsx @@ -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) => { + const loadingState = useLoading(); + const [memos, setMemos] = useState([]); + const [currentDateStamp, setCurrentDateStamp] = useState(utils.getDateStampByDate(utils.getDateString(props.currentDateStamp))); + const [showDatePicker, toggleShowDatePicker] = useToggle(false); + const memosElRef = useRef(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 ( + <> +
+
+

Daily Memos

+
+ setCurrentDateStamp(currentDateStamp - DAILY_TIMESTAMP)}> + + + setCurrentDateStamp(currentDateStamp + DAILY_TIMESTAMP)}> + + + + + + props.destroy()}> + + +
+
+
+
+
toggleShowDatePicker()}> +
{currentDate.getFullYear()}
+
+
{monthChineseStrArray[currentDate.getMonth()]}
+
{currentDate.getDate()}
+
{weekdayChineseStrArray[currentDate.getDay()]}
+
+
+ + {loadingState.isLoading ? ( +
+

努力加载中...

+
+ ) : memos.length === 0 ? ( +
+

空空如也

+
+ ) : ( +
+ {memos.map((memo) => ( + + ))} +
+ )} +
+ + ); +}; + +export default function showDailyMemoDiaryDialog(datestamp: DateStamp = Date.now()): void { + showDialog( + { + className: "daily-memo-diary-dialog", + }, + DailyMemoDiaryDialog, + { currentDateStamp: datestamp } + ); +} diff --git a/web/src/components/DeletedMemo.tsx b/web/src/components/DeletedMemo.tsx new file mode 100644 index 00000000..6d50f80a --- /dev/null +++ b/web/src/components/DeletedMemo.tsx @@ -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) => { + 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 ( +
+
+ 删除于 {memo.deletedAtStr} +
+ + + +
+
+ + 恢复 + + + {showConfirmDeleteBtn ? "确定删除!" : "完全删除"} + +
+
+
+
+
+ 0}> +
+ {imageUrls.map((imgUrl, idx) => ( + + ))} +
+
+
+ ); +}; + +export default DeletedMemo; diff --git a/web/src/components/Dialog.tsx b/web/src/components/Dialog.tsx new file mode 100644 index 00000000..d27d3fa5 --- /dev/null +++ b/web/src/components/Dialog.tsx @@ -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) => { + const { children, className, clickSpaceDestroy, destroy } = props; + + const handleSpaceClicked = () => { + if (clickSpaceDestroy) { + destroy(); + } + }; + + return ( +
+
e.stopPropagation()}> + {children} +
+
+ ); +}; + +export function showDialog( + config: DialogConfig, + DialogComponent: React.FC, + props?: Omit +): 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 = ( + + + + ); + + if (config.useAppContext) { + Fragment = ( + + {Fragment} + + ); + } + + ReactDOM.render(Fragment, tempDiv); + + return cbs; +} diff --git a/web/src/components/Editor/Editor.tsx b/web/src/components/Editor/Editor.tsx new file mode 100644 index 00000000..a79fb632 --- /dev/null +++ b/web/src/components/Editor/Editor.tsx @@ -0,0 +1,169 @@ +import { forwardRef, useCallback, useContext, useEffect, useImperativeHandle, useRef } from "react"; +import TinyUndo from "tiny-undo"; +import appContext from "../../stores/appContext"; +import { storage } from "../../helpers/storage"; +import useRefresh from "../../hooks/useRefresh"; +import Only from "../common/OnlyWhen"; +import "../../less/editor.less"; + +export interface EditorRefActions { + focus: FunctionType; + insertText: (text: string) => void; + setContent: (text: string) => void; + getContent: () => string; +} + +interface Props { + className: string; + initialContent: string; + placeholder: string; + showConfirmBtn: boolean; + showCancelBtn: boolean; + showTools: boolean; + onConfirmBtnClick: (content: string) => void; + onCancelBtnClick: () => void; + onContentChange: (content: string) => void; +} + +const Editor = forwardRef((props: Props, ref: React.ForwardedRef) => { + const { + globalState: { useTinyUndoHistoryCache }, + } = useContext(appContext); + const { + className, + initialContent, + placeholder, + showConfirmBtn, + showCancelBtn, + showTools, + onConfirmBtnClick: handleConfirmBtnClickCallback, + onCancelBtnClick: handleCancelBtnClickCallback, + onContentChange: handleContentChangeCallback, + } = props; + const editorRef = useRef(null); + const tinyUndoRef = useRef(null); + const refresh = useRefresh(); + + useEffect(() => { + if (initialContent) { + editorRef.current!.value = initialContent; + refresh(); + } + }, []); + + useEffect(() => { + if (useTinyUndoHistoryCache) { + const { tinyUndoActionsCache, tinyUndoIndexCache } = storage.get(["tinyUndoActionsCache", "tinyUndoIndexCache"]); + tinyUndoRef.current = new TinyUndo(editorRef.current!, { + interval: 5000, + initialActions: tinyUndoActionsCache, + initialIndex: tinyUndoIndexCache, + }); + + tinyUndoRef.current.subscribe((actions, index) => { + storage.set({ + tinyUndoActionsCache: actions, + tinyUndoIndexCache: index, + }); + }); + + return () => { + tinyUndoRef.current?.destroy(); + }; + } else { + tinyUndoRef.current?.destroy(); + tinyUndoRef.current = null; + storage.remove(["tinyUndoActionsCache", "tinyUndoIndexCache"]); + } + }, [useTinyUndoHistoryCache]); + + useEffect(() => { + if (editorRef.current) { + editorRef.current.style.height = "auto"; + editorRef.current.style.height = (editorRef.current.scrollHeight ?? 0) + "px"; + } + }, [editorRef.current?.value]); + + useImperativeHandle( + ref, + () => ({ + focus: () => { + editorRef.current!.focus(); + }, + insertText: (rawText: string) => { + const prevValue = editorRef.current!.value; + editorRef.current!.value = prevValue + rawText; + handleContentChangeCallback(editorRef.current!.value); + refresh(); + }, + setContent: (text: string) => { + editorRef.current!.value = text; + refresh(); + }, + getContent: (): string => { + return editorRef.current?.value ?? ""; + }, + }), + [] + ); + + const handleEditorInput = useCallback(() => { + handleContentChangeCallback(editorRef.current!.value); + refresh(); + }, []); + + const handleEditorKeyDown = useCallback((event: React.KeyboardEvent) => { + event.stopPropagation(); + + if (event.code === "Enter") { + if (event.metaKey || event.ctrlKey) { + handleCommonConfirmBtnClick(); + } + } + refresh(); + }, []); + + const handleCommonConfirmBtnClick = useCallback(() => { + handleConfirmBtnClickCallback(editorRef.current!.value); + editorRef.current!.value = ""; + refresh(); + // After confirm btn clicked, tiny-undo should reset state(clear actions and index) + tinyUndoRef.current?.resetState(); + }, []); + + const handleCommonCancelBtnClick = useCallback(() => { + handleCancelBtnClickCallback(); + }, []); + + return ( +
+ +
+ +
{/* nth */}
+
+
+ + + + + + +
+
+
+ ); +}); + +export default Editor; diff --git a/web/src/components/Image.tsx b/web/src/components/Image.tsx new file mode 100644 index 00000000..16df3e48 --- /dev/null +++ b/web/src/components/Image.tsx @@ -0,0 +1,23 @@ +import showPreviewImageDialog from "./PreviewImageDialog"; +import "../less/image.less"; + +interface Props { + imgUrl: string; + className?: string; +} + +const Image: React.FC = (props: Props) => { + const { className, imgUrl } = props; + + const handleImageClick = () => { + showPreviewImageDialog(imgUrl); + }; + + return ( +
+ +
+ ); +}; + +export default Image; diff --git a/web/src/components/Memo.tsx b/web/src/components/Memo.tsx new file mode 100644 index 00000000..65294137 --- /dev/null +++ b/web/src/components/Memo.tsx @@ -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) => { + 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 ( +
+
+ + {memo.createdAtStr} + +
+ + + +
+
+ + 查看详情 + + + Mark + + + 分享 + + + 编辑 + + + {showConfirmDeleteBtn ? "确定删除!" : "删除"} + +
+
+
+
+
+ 0}> +
+ {imageUrls.map((imgUrl, idx) => ( + + ))} +
+
+
+ ); +}; + +export function formatMemoContent(content: string) { + content = encodeHtml(content); + content = parseRawTextToHtml(content) + .split("
") + .map((t) => { + return `

${t !== "" ? t : "
"}

`; + }) + .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, "#$1") + .replace(LINK_REG, "$1") + .replace(MEMO_LINK_REG, "$1"); + + 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); diff --git a/web/src/components/MemoCardDialog.tsx b/web/src/components/MemoCardDialog.tsx new file mode 100644 index 00000000..58c94db6 --- /dev/null +++ b/web/src/components/MemoCardDialog.tsx @@ -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) => { + const [memo, setMemo] = useState({ + ...props.memo, + createdAtStr: utils.getDateTimeString(props.memo.createdAt), + }); + const [linkMemos, setLinkMemos] = useState([]); + const [linkedMemos, setLinkedMemos] = useState([]); + 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 ( + <> +
+
+

{memo.createdAtStr}

+
+ + +
+
+
+
+ 0}> +
+ {imageUrls.map((imgUrl, idx) => ( + + ))} +
+
+
+
+ {linkMemos.map((_, idx) => { + if (idx < 4) { + return ( +
+ ); + } else { + return null; + } + })} +
+ {linkMemos.length > 0 ? ( +
+

关联了 {linkMemos.length} 个 MEMO

+ {linkMemos.map((m) => { + const rawtext = parseHtmlToRawText(formatMemoContent(m.content)).replaceAll("\n", " "); + return ( +
handleLinkedMemoClick(m)}> + {m.dateStr} + {rawtext} +
+ ); + })} +
+ ) : null} + {linkedMemos.length > 0 ? ( +
+

{linkedMemos.length} 个链接至此的 MEMO

+ {linkedMemos.map((m) => { + const rawtext = parseHtmlToRawText(formatMemoContent(m.content)).replaceAll("\n", " "); + return ( +
handleLinkedMemoClick(m)}> + {m.dateStr} + {rawtext} +
+ ); + })} +
+ ) : null} + + ); +}; + +export default function showMemoCardDialog(memo: Model.Memo): void { + showDialog( + { + className: "memo-card-dialog", + }, + MemoCardDialog, + { memo } + ); +} diff --git a/web/src/components/MemoEditor.tsx b/web/src/components/MemoEditor.tsx new file mode 100644 index 00000000..9986ad18 --- /dev/null +++ b/web/src/components/MemoEditor.tsx @@ -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) => { + const { className, editMemoId } = props; + const { globalState } = useContext(appContext); + const editorRef = useRef(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 ( +
+

正在修改中...

+ +
+ ); +}; + +function getEditorContentCache(): string { + return storage.get(["editorContentCache"]).editorContentCache ?? ""; +} + +function setEditorContentCache(content: string) { + storage.set({ + editorContentCache: content, + }); +} + +export default MemoEditor; diff --git a/web/src/components/MemoFilter.tsx b/web/src/components/MemoFilter.tsx new file mode 100644 index 00000000..bc3c8916 --- /dev/null +++ b/web/src/components/MemoFilter.tsx @@ -0,0 +1,68 @@ +import { useContext } from "react"; +import appContext from "../stores/appContext"; +import { locationService, queryService } from "../services"; +import utils from "../helpers/utils"; +import { getTextWithMemoType } from "../helpers/filter"; +import "../less/memo-filter.less"; + +interface FilterProps {} + +const MemoFilter: React.FC = () => { + const { + locationState: { query }, + } = useContext(appContext); + + const { tag: tagQuery, duration, type: memoType, text: textQuery, filter } = query; + const queryFilter = queryService.getQueryById(filter); + const showFilter = Boolean(tagQuery || (duration && duration.from < duration.to) || memoType || textQuery || queryFilter); + + return ( +
+ 筛选: +
{ + locationService.setMemoFilter(""); + }} + > + 🔖 {queryFilter?.title} +
+
{ + locationService.setTagQuery(""); + }} + > + 🏷️ {tagQuery} +
+
{ + locationService.setMemoTypeQuery(""); + }} + > + 📦 {getTextWithMemoType(memoType as MemoSpecType)} +
+ {duration && duration.from < duration.to ? ( +
{ + locationService.setFromAndToQuery(0, 0); + }} + > + 🗓️ {utils.getDateString(duration.from)} 至 {utils.getDateString(duration.to)} +
+ ) : null} +
{ + locationService.setTextQuery(""); + }} + > + 🔍 {textQuery} +
+
+ ); +}; + +export default MemoFilter; diff --git a/web/src/components/MemoList.tsx b/web/src/components/MemoList.tsx new file mode 100644 index 00000000..7021baf7 --- /dev/null +++ b/web/src/components/MemoList.tsx @@ -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 = () => { + const { + locationState: { query }, + memoState: { memos }, + globalState, + } = useContext(appContext); + const [isFetching, setFetchStatus] = useState(true); + const wrapperElement = useRef(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 ( +
+ {shownMemos.map((memo) => + globalState.editMemoId === memo.id ? ( + + ) : ( + + ) + )} +
+

+ {isFetching ? "努力请求数据中..." : shownMemos.length === 0 ? "空空如也" : showMemoFilter ? "" : "所有数据加载完啦 🎉"} +

+
+
+ ); +}; + +export default MemoList; diff --git a/web/src/components/MemosHeader.tsx b/web/src/components/MemosHeader.tsx new file mode 100644 index 00000000..e71b8f2b --- /dev/null +++ b/web/src/components/MemosHeader.tsx @@ -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 = () => { + 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 ( +
+
+ + + + {titleText} +
+ +
+ ); +}; + +export default MemosHeader; diff --git a/web/src/components/MenuBtnsPopup.tsx b/web/src/components/MenuBtnsPopup.tsx new file mode 100644 index 00000000..383def72 --- /dev/null +++ b/web/src/components/MenuBtnsPopup.tsx @@ -0,0 +1,66 @@ +import { useEffect, useRef } from "react"; +import { locationService, userService } from "../services"; +import showAboutSiteDialog from "./AboutSiteDialog"; +import "../less/menu-btns-popup.less"; + +interface Props { + shownStatus: boolean; + setShownStatus: (status: boolean) => void; +} + +const MenuBtnsPopup: React.FC = (props: Props) => { + const { shownStatus, setShownStatus } = props; + + const popupElRef = useRef(null); + + useEffect(() => { + if (shownStatus) { + const handleClickOutside = (event: MouseEvent) => { + if (!popupElRef.current?.contains(event.target as Node)) { + event.stopPropagation(); + } + setShownStatus(false); + }; + window.addEventListener("click", handleClickOutside, { + capture: true, + once: true, + }); + } + }, [shownStatus]); + + const handleMyAccountBtnClick = () => { + locationService.pushHistory("/setting"); + }; + + const handleMemosTrashBtnClick = () => { + locationService.pushHistory("/recycle"); + }; + + const handleAboutBtnClick = () => { + showAboutSiteDialog(); + }; + + const handleSignOutBtnClick = async () => { + await userService.doSignOut(); + locationService.replaceHistory("/signin"); + }; + + return ( +
+ + + + +
+ ); +}; + +export default MenuBtnsPopup; diff --git a/web/src/components/MyAccountSection.tsx b/web/src/components/MyAccountSection.tsx new file mode 100644 index 00000000..54f37a7e --- /dev/null +++ b/web/src/components/MyAccountSection.tsx @@ -0,0 +1,218 @@ +import { useContext, useState } from "react"; +import appContext from "../stores/appContext"; +import { userService } from "../services"; +import utils from "../helpers/utils"; +import { validate, ValidatorConfig } from "../helpers/validator"; +import Only from "./common/OnlyWhen"; +import toastHelper from "./Toast"; +import showChangePasswordDialog from "./ChangePasswordDialog"; +import showBindWxUserIdDialog from "./BindWxUserIdDialog"; +import "../less/my-account-section.less"; + +const validateConfig: ValidatorConfig = { + minLength: 4, + maxLength: 24, + noSpace: true, + noChinese: true, +}; + +interface Props {} + +const MyAccountSection: React.FC = () => { + const { userState } = useContext(appContext); + const user = userState.user as Model.User; + const [username, setUsername] = useState(user.username); + const [showEditUsernameInputs, setShowEditUsernameInputs] = useState(false); + const [showConfirmUnbindGithubBtn, setShowConfirmUnbindGithubBtn] = useState(false); + const [showConfirmUnbindWxBtn, setShowConfirmUnbindWxBtn] = useState(false); + + const handleUsernameChanged = (e: React.ChangeEvent) => { + const nextUsername = e.target.value as string; + setUsername(nextUsername); + }; + + const handleConfirmEditUsernameBtnClick = async () => { + if (user.username === "guest") { + toastHelper.info("🈲 不要修改我的用户名"); + return; + } + + if (username === user.username) { + setShowEditUsernameInputs(false); + return; + } + + const usernameValidResult = validate(username, validateConfig); + if (!usernameValidResult.result) { + toastHelper.error("用户名 " + usernameValidResult.reason); + return; + } + + try { + const isUsable = await userService.checkUsernameUsable(username); + + if (!isUsable) { + toastHelper.error("用户名无法使用"); + return; + } + + await userService.updateUsername(username); + await userService.doSignIn(); + setShowEditUsernameInputs(false); + toastHelper.info("修改成功~"); + } catch (error: any) { + toastHelper.error(error.message); + } + }; + + const handleChangePasswordBtnClick = () => { + if (user.username === "guest") { + toastHelper.info("🈲 不要修改我的密码"); + return; + } + + showChangePasswordDialog(); + }; + + const handleUnbindGithubBtnClick = async () => { + if (showConfirmUnbindGithubBtn) { + try { + await userService.removeGithubName(); + await userService.doSignIn(); + } catch (error: any) { + toastHelper.error(error.message); + } + setShowConfirmUnbindGithubBtn(false); + } else { + setShowConfirmUnbindGithubBtn(true); + } + }; + + const handleUnbindWxBtnClick = async () => { + if (showConfirmUnbindWxBtn) { + try { + await userService.updateWxUserId(""); + await userService.doSignIn(); + } catch (error: any) { + toastHelper.error(error.message); + } + setShowConfirmUnbindWxBtn(false); + } else { + setShowConfirmUnbindWxBtn(true); + } + }; + + const handlePreventDefault = (e: React.MouseEvent) => { + e.preventDefault(); + }; + + return ( + <> +
+

基本信息

+ + + + +
+ {/* Account Binding Settings: only can use for domain: memos.justsven.top */} + +
+

关联账号

+ + +
+
+ + ); +}; + +export default MyAccountSection; diff --git a/web/src/components/PreferencesSection.tsx b/web/src/components/PreferencesSection.tsx new file mode 100644 index 00000000..82d8d601 --- /dev/null +++ b/web/src/components/PreferencesSection.tsx @@ -0,0 +1,112 @@ +import { useContext } from "react"; +import appContext from "../stores/appContext"; +import { globalStateService, memoService } from "../services"; +import { parseHtmlToRawText } from "../helpers/marked"; +import { formatMemoContent } from "./Memo"; +import "../less/preferences-section.less"; + +interface Props {} + +const PreferencesSection: React.FC = () => { + const { globalState } = useContext(appContext); + const { useTinyUndoHistoryCache, shouldHideImageUrl, shouldSplitMemoWord, shouldUseMarkdownParser } = globalState; + + const demoMemoContent = `👋 你好呀~\n我是一个demo:\n* 👏 欢迎使用memos;`; + + const handleOpenTinyUndoChanged = () => { + globalStateService.setAppSetting({ + useTinyUndoHistoryCache: !useTinyUndoHistoryCache, + }); + }; + + const handleSplitWordsValueChanged = () => { + globalStateService.setAppSetting({ + shouldSplitMemoWord: !shouldSplitMemoWord, + }); + }; + + const handleHideImageUrlValueChanged = () => { + globalStateService.setAppSetting({ + shouldHideImageUrl: !shouldHideImageUrl, + }); + }; + + const handleUseMarkdownParserChanged = () => { + globalStateService.setAppSetting({ + shouldUseMarkdownParser: !shouldUseMarkdownParser, + }); + }; + + const handleExportBtnClick = async () => { + const formatedMemos = memoService.getState().memos.map((m) => { + return { + ...m, + }; + }); + + const jsonStr = JSON.stringify(formatedMemos); + const element = document.createElement("a"); + element.setAttribute("href", "data:text/plain;charset=utf-8," + encodeURIComponent(jsonStr)); + element.setAttribute("download", "data.json"); + element.style.display = "none"; + document.body.appendChild(element); + element.click(); + document.body.removeChild(element); + }; + + const handleFormatMemosBtnClick = async () => { + const memos = memoService.getState().memos; + for (const m of memos) { + memoService.updateMemo(m.id, parseHtmlToRawText(m.content)); + } + }; + + return ( + <> +
+

Memo 显示相关

+
+ + + +
+
+

编辑器

+ +
+
+

其他

+
+ + +
+
+ + ); +}; + +export default PreferencesSection; diff --git a/web/src/components/PreviewImageDialog.tsx b/web/src/components/PreviewImageDialog.tsx new file mode 100644 index 00000000..190b7c20 --- /dev/null +++ b/web/src/components/PreviewImageDialog.tsx @@ -0,0 +1,73 @@ +import { useEffect, useRef, useState } from "react"; +import utils from "../helpers/utils"; +import { showDialog } from "./Dialog"; +import "../less/preview-image-dialog.less"; + +interface Props extends DialogProps { + imgUrl: string; +} + +const PreviewImageDialog: React.FC = ({ destroy, imgUrl }: Props) => { + const imgRef = useRef(null); + const [imgWidth, setImgWidth] = useState(-1); + + useEffect(() => { + utils.getImageSize(imgUrl).then(({ width }) => { + if (width !== 0) { + setImgWidth(80); + } else { + setImgWidth(0); + } + }); + }, []); + + const handleCloseBtnClick = () => { + destroy(); + }; + + const handleDecreaseImageSize = () => { + if (imgWidth > 30) { + setImgWidth(imgWidth - 10); + } + }; + + const handleIncreaseImageSize = () => { + setImgWidth(imgWidth + 10); + }; + + return ( + <> + + +
+ + 图片加载中... + 😟 图片加载失败,可能是无效的链接 +
+ +
+ + + +
+ + ); +}; + +export default function showPreviewImageDialog(imgUrl: string): void { + showDialog( + { + className: "preview-image-dialog", + }, + PreviewImageDialog, + { imgUrl } + ); +} diff --git a/web/src/components/QueryList.tsx b/web/src/components/QueryList.tsx new file mode 100644 index 00000000..521f465f --- /dev/null +++ b/web/src/components/QueryList.tsx @@ -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 = () => { + 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 ( +
+

+ 快速检索 + showCreateQueryDialog()}> + + + +

+ +
+ showCreateQueryDialog()}> + 创建检索 + +
+
+
+ {sortedQueries.map((q) => { + return ; + })} +
+
+ ); +}; + +interface QueryItemContainerProps { + query: Model.Query; + isActive: boolean; +} + +const QueryItemContainer: React.FC = (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 ( + <> +
+
+ # + {query.title} +
+
+ + + +
+
+ + {query.pinnedAt ? "取消置顶" : "置顶"} + + + 编辑 + + + {showConfirmDeleteBtn ? "确定删除!" : "删除"} + +
+
+
+
+ + ); +}; + +export default QueryList; diff --git a/web/src/components/SearchBar.tsx b/web/src/components/SearchBar.tsx new file mode 100644 index 00000000..9b631e69 --- /dev/null +++ b/web/src/components/SearchBar.tsx @@ -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 = () => { + 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) => { + const text = event.currentTarget.value; + locationService.setTextQuery(text); + }; + + return ( +
+
+ + +
+
+
+

QUICKLY FILTER

+
+ 类型: +
+ {memoSpecialTypes.map((t, idx) => { + return ( +
+ { + handleMemoTypeItemClick(t.value as MemoSpecType); + }} + > + {t.text} + + {idx + 1 < memoSpecialTypes.length ? / : null} +
+ ); + })} +
+
+
+
+
+ ); +}; + +export default SearchBar; diff --git a/web/src/components/ShareMemoImageDialog.tsx b/web/src/components/ShareMemoImageDialog.tsx new file mode 100644 index 00000000..c4bf1d77 --- /dev/null +++ b/web/src/components/ShareMemoImageDialog.tsx @@ -0,0 +1,117 @@ +import { useEffect, useRef, useState } from "react"; +import { userService } from "../services"; +import toImage from "../labs/html2image"; +import { ANIMATION_DURATION, IMAGE_URL_REG } from "../helpers/consts"; +import utils from "../helpers/utils"; +import { showDialog } from "./Dialog"; +import { formatMemoContent } from "./Memo"; +import Only from "./common/OnlyWhen"; +import toastHelper from "./Toast"; +import "../less/share-memo-image-dialog.less"; + +interface Props extends DialogProps { + memo: Model.Memo; +} + +const ShareMemoImageDialog: React.FC = (props: Props) => { + const { memo: propsMemo, destroy } = props; + const { user: userinfo } = userService.getState(); + const memo: FormattedMemo = { + ...propsMemo, + createdAtStr: utils.getDateTimeString(propsMemo.createdAt), + }; + const memoImgUrls = Array.from(memo.content.match(IMAGE_URL_REG) ?? []); + + const [shortcutImgUrl, setShortcutImgUrl] = useState(""); + const [imgAmount, setImgAmount] = useState(memoImgUrls.length); + const memoElRef = useRef(null); + + useEffect(() => { + if (imgAmount > 0) { + return; + } + + setTimeout(() => { + if (!memoElRef.current) { + return; + } + + toImage(memoElRef.current, { + backgroundColor: "#eaeaea", + pixelRatio: window.devicePixelRatio * 2, + }) + .then((url) => { + setShortcutImgUrl(url); + }) + .catch(() => { + // do nth + }); + }, ANIMATION_DURATION); + }, [imgAmount]); + + const handleCloseBtnClick = () => { + destroy(); + }; + + const handleImageOnLoad = (ev: React.SyntheticEvent) => { + if (ev.type === "error") { + toastHelper.error("有个图片加载失败了😟"); + (ev.target as HTMLImageElement).remove(); + } + setImgAmount(imgAmount - 1); + }; + + return ( + <> +
+

+ 🥰分享 Memo 图片 +

+ +
+
+
+

{shortcutImgUrl ? "右键或长按即可保存图片 👇" : "图片生成中..."}

+
+
+ + + + {memo.createdAtStr} +
+ 0}> +
+ {memoImgUrls.map((imgUrl, idx) => ( + + ))} +
+
+
+ + ✍️ by {userinfo?.username} + +
+
+
+ + ); +}; + +export default function showShareMemoImageDialog(memo: Model.Memo): void { + showDialog( + { + className: "share-memo-image-dialog", + }, + ShareMemoImageDialog, + { memo } + ); +} diff --git a/web/src/components/Sidebar.tsx b/web/src/components/Sidebar.tsx new file mode 100644 index 00000000..58d06790 --- /dev/null +++ b/web/src/components/Sidebar.tsx @@ -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 = () => { + const { + locationState, + globalState: { isMobileView, showSiderbarInMobileView }, + } = useContext(appContext); + const wrapperElRef = useRef(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 ( + + ); +}; + +export default Sidebar; diff --git a/web/src/components/TagList.tsx b/web/src/components/TagList.tsx new file mode 100644 index 00000000..a5d14c66 --- /dev/null +++ b/web/src/components/TagList.tsx @@ -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 = () => { + const { + locationState: { + query: { tag: tagQuery }, + }, + memoState: { tags: tagsText, memos }, + } = useContext(appContext); + const [tags, setTags] = useState([]); + + useEffect(() => { + memoService.updateTagsState(); + }, [memos]); + + useEffect(() => { + const sortedTags = Array.from(tagsText).sort(); + const root: KVObject = { + 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 ( +
+

常用标签

+
+ {tags.map((t, idx) => ( + + ))} + +

+ 输入# Tag 来创建标签吧~ +

+
+
+
+ ); +}; + +interface TagItemContainerProps { + tag: Tag; + tagQuery: string; +} + +const TagItemContainer: React.FC = (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 ( + <> +
+
+ # + {tag.key} +
+
+ {hasSubTags ? ( + + + + ) : null} +
+
+ + {hasSubTags ? ( +
+ {tag.subTags.map((st, idx) => ( + + ))} +
+ ) : null} + + ); +}; + +export default TagList; diff --git a/web/src/components/Toast.tsx b/web/src/components/Toast.tsx new file mode 100644 index 00000000..024240e4 --- /dev/null +++ b/web/src/components/Toast.tsx @@ -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 = (props: ToastItemProps) => { + const { destory, duration } = props; + + useEffect(() => { + if (duration > 0) { + setTimeout(() => { + destory(); + }, duration); + } + }, []); + + return ( +
+

{props.content}

+
+ ); +}; + +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(, tempDiv); + + return cbs; + }; +} + +const toastHelper = new ToastHelper(); + +export default toastHelper; diff --git a/web/src/components/UsageHeatMap.tsx b/web/src/components/UsageHeatMap.tsx new file mode 100644 index 00000000..25255be9 --- /dev/null +++ b/web/src/components/UsageHeatMap.tsx @@ -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 = () => { + 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(getInitialUsageStat(usedDaysAmount, beginDayTimestemp)); + const [popupStat, setPopupStat] = useState(null); + const [currentStat, setCurrentStat] = useState(null); + const containerElRef = useRef(null); + const popupRef = useRef(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 ( +
+
+ Mon + + Wed + + Fri + + Sun +
+ + {/* popup */} +
+ {popupStat?.count} memos on {new Date(popupStat?.timestamp as number).toDateString()} +
+ +
+ {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 ( + handleUsageStatItemMouseEnter(e, v)} + onMouseLeave={handleUsageStatItemMouseLeave} + onClick={() => handleUsageStatItemClick(v)} + > + ); + })} + {nullCell.map((v, i) => ( + + ))} +
+
+ ); +}; + +export default UsageHeatMap; diff --git a/web/src/components/UserBanner.tsx b/web/src/components/UserBanner.tsx new file mode 100644 index 00000000..eebb7547 --- /dev/null +++ b/web/src/components/UserBanner.tsx @@ -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 = () => { + 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 ( +
+
+

+ {username} +

+ + + + +
+
+
+ {memos.length} + MEMO +
+
+ {tags.length} + TAG +
+
showDailyMemoDiaryDialog()}> + {createdDays} + DAY +
+
+
+ ); +}; + +export default UserBanner; diff --git a/web/src/components/common/DatePicker.tsx b/web/src/components/common/DatePicker.tsx new file mode 100644 index 00000000..3e336f8e --- /dev/null +++ b/web/src/components/common/DatePicker.tsx @@ -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 = (props: DatePickerProps) => { + const { className, datestamp, handleDateStampChange } = props; + const [currentDateStamp, setCurrentDateStamp] = useState(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 ( +
+
+ handleChangeMonthBtnClick(-1)}> + + + + {firstDate.getFullYear()} 年 {firstDate.getMonth() + 1} 月 + + handleChangeMonthBtnClick(1)}> + + +
+
+
+ 周一 + 周二 + 周三 + 周四 + 周五 + 周六 + 周日 +
+ + {dayList.map((d) => { + if (d.date === 0) { + return ( + + {""} + + ); + } else { + return ( + handleDateItemClick(d.datestamp)} + > + {d.date} + + ); + } + })} +
+
+ ); +}; + +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; diff --git a/web/src/components/common/OnlyWhen.tsx b/web/src/components/common/OnlyWhen.tsx new file mode 100644 index 00000000..74ec8d19 --- /dev/null +++ b/web/src/components/common/OnlyWhen.tsx @@ -0,0 +1,13 @@ +interface OnlyWhenProps { + children: React.ReactElement; + when: boolean; +} + +const OnlyWhen: React.FC = (props: OnlyWhenProps) => { + const { children, when } = props; + return when ? <>{children} : null; +}; + +const Only = OnlyWhen; + +export default Only; diff --git a/web/src/components/common/Selector.tsx b/web/src/components/common/Selector.tsx new file mode 100644 index 00000000..9de70077 --- /dev/null +++ b/web/src/components/common/Selector.tsx @@ -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) => { + const { className, dataSource, handleValueChanged, value } = props; + const [showSelector, toggleSelectorStatus] = useToggle(false); + + const seletorElRef = useRef(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 ( +
+
+ {currentItem.text} + + + +
+ +
+ {dataSource.map((d) => { + return ( +
{ + handleItemClick(d); + }} + > + {d.text} +
+ ); + })} +
+
+ ); +}; + +export default memo(Selector); diff --git a/web/src/helpers/api.ts b/web/src/helpers/api.ts new file mode 100644 index 00000000..d012294b --- /dev/null +++ b/web/src/helpers/api.ts @@ -0,0 +1,144 @@ +type ResponseType = { + succeed: boolean; + status: number; + message: string; + data: T; +}; + +async function get(url: string): Promise> { + const response = await fetch(url, { + method: "GET", + }); + const resData = (await response.json()) as ResponseType; + + if (!resData.succeed) { + throw resData; + } + + return resData; +} + +async function post(url: string, data?: BasicType): Promise> { + const response = await fetch(url, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify(data), + }); + + const resData = (await response.json()) as ResponseType; + + if (!resData.succeed) { + throw resData; + } + + return resData; +} + +namespace api { + export function getUserInfo() { + return get("/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("/api/user/checkusername?username=" + username); + } + + export function checkPasswordValid(password: string) { + return post("/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("/api/memo/all"); + } + + export function getMyDeletedMemos() { + return get("/api/memo/deleted"); + } + + export function createMemo(content: string) { + return post("/api/memo/new", { content }); + } + + export function getMemoById(id: string) { + return get("/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("/api/memo/update", { memoId, content }); + } + + export function getLinkedMemos(memoId: string) { + return get("/api/memo/linked?memoId=" + memoId); + } + + export function removeGithubName() { + return post("/api/user/updategh", { githubName: "" }); + } + + export function getMyQueries() { + return get("/api/query/all"); + } + + export function createQuery(title: string, querystring: string) { + return post("/api/query/new", { title, querystring }); + } + + export function updateQuery(queryId: string, title: string, querystring: string) { + return post("/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; diff --git a/web/src/helpers/consts.ts b/web/src/helpers/consts.ts new file mode 100644 index 00000000..55d1047d --- /dev/null +++ b/web/src/helpers/consts.ts @@ -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; diff --git a/web/src/helpers/filter.ts b/web/src/helpers/filter.ts new file mode 100644 index 00000000..f193b3af --- /dev/null +++ b/web/src/helpers/filter.ts @@ -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; +}; diff --git a/web/src/helpers/marked.ts b/web/src/helpers/marked.ts new file mode 100644 index 00000000..3b4cacae --- /dev/null +++ b/web/src/helpers/marked.ts @@ -0,0 +1,86 @@ +/** + * 实现一个简易版的 markdown 解析 + * - 列表解析; + * - 代码块; + * - 加粗/斜体; + * - TODO; + */ +import Prism from "prismjs"; + +const CODE_BLOCK_REG = /```([\s\S]*?)```/g; +const BOLD_TEXT_REG = /\*\*(.+?)\*\*/g; +const EM_TEXT_REG = /\*(.+?)\*/g; +const TODO_BLOCK_REG = /\[ \] /g; +const DONE_BLOCK_REG = /\[x\] /g; +const DOT_LI_REG = /[*] /g; +const NUM_LI_REG = /(\d+)\. /g; + +const getCodeLanguage = (codeStr: string): string => { + const execRes = /^\w+/g.exec(codeStr); + + if (execRes !== null) { + return execRes[0]; + } + + return "javascript"; +}; + +const parseCodeToPrism = (codeStr: string): string => { + return codeStr.replace(CODE_BLOCK_REG, (_, matchedStr): string => { + const lang = getCodeLanguage(matchedStr); + let convertedStr = matchedStr + .replace(lang, "") + .replace(/

/g, "") + .replace(/<\/p>/g, "\r\n") + .replace(/
/g, "\r\n") + .replace(/ /g, " "); + + // 特定语言处理 + switch (lang) { + case "html": + convertedStr = convertedStr.replace(/</g, "<").replace(/>/g, ">"); + } + + try { + const resultStr = Prism.highlight(convertedStr, Prism.languages[lang], lang); + return `

${resultStr}
`; + } catch (error) { + // do nth + } + + return `
${codeStr}
`; + }); +}; + +const parseMarkedToHtml = (markedStr: string): string => { + const htmlText = parseCodeToPrism(markedStr) + .replace(DOT_LI_REG, "") + .replace(NUM_LI_REG, "$1.") + .replace(TODO_BLOCK_REG, "") + .replace(DONE_BLOCK_REG, "") + .replace(BOLD_TEXT_REG, "$1") + .replace(EM_TEXT_REG, "$1"); + + return htmlText; +}; + +const parseHtmlToRawText = (htmlStr: string): string => { + const tempEl = document.createElement("div"); + tempEl.className = "memo-content-text"; + tempEl.innerHTML = htmlStr; + const text = tempEl.innerText; + return text; +}; + +const parseRawTextToHtml = (rawTextStr: string): string => { + const htmlText = rawTextStr.replace(/\n/g, "
"); + return htmlText; +}; + +const encodeHtml = (htmlStr: string): string => { + const t = document.createElement("div"); + t.textContent = htmlStr; + return t.innerHTML; +}; + +export { encodeHtml, parseMarkedToHtml, parseHtmlToRawText, parseRawTextToHtml }; diff --git a/web/src/helpers/polyfill.ts b/web/src/helpers/polyfill.ts new file mode 100644 index 00000000..1f9f53c2 --- /dev/null +++ b/web/src/helpers/polyfill.ts @@ -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; diff --git a/web/src/helpers/storage.ts b/web/src/helpers/storage.ts new file mode 100644 index 00000000..542c13c9 --- /dev/null +++ b/web/src/helpers/storage.ts @@ -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 { + const data: Partial = {}; + + 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) { + 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(); + } +} diff --git a/web/src/helpers/utils.ts b/web/src/helpers/utils.ts new file mode 100644 index 00000000..64e0530e --- /dev/null +++ b/web/src/helpers/utils.ts @@ -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(data: T[]): T[] { + return Array.from(new Set(data)); + } + + export function dedupeObjectWithId(data: T[]): T[] { + const idSet = new Set(); + 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; diff --git a/web/src/helpers/validator.ts b/web/src/helpers/validator.ts new file mode 100644 index 00000000..cedd1dd3 --- /dev/null +++ b/web/src/helpers/validator.ts @@ -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): { 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, + }; +} diff --git a/web/src/hooks/useDebounce.ts b/web/src/hooks/useDebounce.ts new file mode 100644 index 00000000..7d841820 --- /dev/null +++ b/web/src/hooks/useDebounce.ts @@ -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 any>(func: T, delay: number, deps: any[] = []): T { + const timer = useRef(); + + 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; +} diff --git a/web/src/hooks/useLoading.ts b/web/src/hooks/useLoading.ts new file mode 100644 index 00000000..9fb15017 --- /dev/null +++ b/web/src/hooks/useLoading.ts @@ -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; diff --git a/web/src/hooks/useRefresh.ts b/web/src/hooks/useRefresh.ts new file mode 100644 index 00000000..6cb2a182 --- /dev/null +++ b/web/src/hooks/useRefresh.ts @@ -0,0 +1,15 @@ +import { useCallback, useState } from "react"; + +function useRefresh() { + const [_, setBoolean] = useState(false); + + const refresh = useCallback(() => { + setBoolean((ps) => { + return !ps; + }); + }, []); + + return refresh; +} + +export default useRefresh; diff --git a/web/src/hooks/useToggle.ts b/web/src/hooks/useToggle.ts new file mode 100644 index 00000000..349a92dd --- /dev/null +++ b/web/src/hooks/useToggle.ts @@ -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]; +} diff --git a/web/src/labs/Provider.tsx b/web/src/labs/Provider.tsx new file mode 100644 index 00000000..7355236f --- /dev/null +++ b/web/src/labs/Provider.tsx @@ -0,0 +1,31 @@ +import { useEffect, useState } from "react"; +import { Store } from "./createStore"; + +interface Props { + children: React.ReactElement; + store: Store; + context: React.Context; +} + +/** + * Toy-Redux Provider + * Just for debug with the app store + */ +const Provider: React.FC = (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 {children}; +}; + +export default Provider; diff --git a/web/src/labs/combineReducers.ts b/web/src/labs/combineReducers.ts new file mode 100644 index 00000000..8fcbcd0f --- /dev/null +++ b/web/src/labs/combineReducers.ts @@ -0,0 +1,36 @@ +import { Action, Reducer, State } from "./createStore"; + +interface ReducersMapObject { + [key: string]: Reducer; +} + +type StateFromReducersMapObject = M extends ReducersMapObject + ? { [P in keyof M]: M[P] extends Reducer ? S : never } + : never; + +function combineReducers(reducers: ReducersMapObject): Reducer { + 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 = {}, action: A) => { + let hasChanged = false; + const nextState: StateFromReducersMapObject = {}; + + 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; +} + +export default combineReducers; diff --git a/web/src/labs/createStore.ts b/web/src/labs/createStore.ts new file mode 100644 index 00000000..956f67c0 --- /dev/null +++ b/web/src/labs/createStore.ts @@ -0,0 +1,63 @@ +export type State = Readonly; +export type Action = { + type: string; + payload: any; +}; + +export type Reducer = (s: S, a: A) => S; +type Listener = (ns: S, ps?: S) => void; +type Unsubscribe = () => void; + +export interface Store { + dispatch: (a: A) => void; + getState: () => S; + subscribe: (listener: Listener) => Unsubscribe; +} + +/** + * 简单实现的 Redux + * @param preloadedState 初始 state + * @param reducer reducer pure function + * @returns store + */ +function createStore(preloadedState: S, reducer: Reducer): Store, A> { + const listeners: Listener[] = []; + 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) => { + 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; diff --git a/web/src/labs/html2image/convertResourceToDataURL.ts b/web/src/labs/html2image/convertResourceToDataURL.ts new file mode 100644 index 00000000..2a5729a7 --- /dev/null +++ b/web/src/labs/html2image/convertResourceToDataURL.ts @@ -0,0 +1,21 @@ +const cachedResource = new Map(); + +function convertResourceToDataURL(url: string, useCache = true): Promise { + 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; diff --git a/web/src/labs/html2image/getCloneStyledElement.ts b/web/src/labs/html2image/getCloneStyledElement.ts new file mode 100644 index 00000000..b979ad7f --- /dev/null +++ b/web/src/labs/html2image/getCloneStyledElement.ts @@ -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; diff --git a/web/src/labs/html2image/index.ts b/web/src/labs/html2image/index.ts new file mode 100644 index 00000000..a4957318 --- /dev/null +++ b/web/src/labs/html2image/index.ts @@ -0,0 +1,142 @@ +/** + * HTML to Image + * + * References: + * 1. html-to-image: https://github.com/bubkoo/html-to-image + * 2. : 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 { + 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; diff --git a/web/src/labs/useSelector.ts b/web/src/labs/useSelector.ts new file mode 100644 index 00000000..3986062e --- /dev/null +++ b/web/src/labs/useSelector.ts @@ -0,0 +1,29 @@ +import { useEffect, useState } from "react"; + +type State = Readonly; +interface Action { + type: string; +} +type Listener = (ns: S, ps?: S) => void; + +interface Store { + dispatch: (a: A) => void; + getState: () => S; + subscribe: (listener: Listener) => () => void; +} + +export default function useSelector(store: Store): S { + const [state, setState] = useState(store.getState()); + + useEffect(() => { + const unsubscribe = store.subscribe((ns) => { + setState(ns); + }); + + return () => { + unsubscribe(); + }; + }, []); + + return state; +} diff --git a/web/src/less/about-site-dialog.less b/web/src/less/about-site-dialog.less new file mode 100644 index 00000000..608976df --- /dev/null +++ b/web/src/less/about-site-dialog.less @@ -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; + } +} diff --git a/web/src/less/app.less b/web/src/less/app.less new file mode 100644 index 00000000..48dd0a8c --- /dev/null +++ b/web/src/less/app.less @@ -0,0 +1,7 @@ +@import "./mixin.less"; + +#root { + .flex(row, flex-start, flex-start); + width: 100%; + height: 100%; +} diff --git a/web/src/less/change-password-dialog.less b/web/src/less/change-password-dialog.less new file mode 100644 index 00000000..96a97501 --- /dev/null +++ b/web/src/less/change-password-dialog.less @@ -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%; + } + } +} diff --git a/web/src/less/common/date-picker.less b/web/src/less/common/date-picker.less new file mode 100644 index 00000000..69daa1cd --- /dev/null +++ b/web/src/less/common/date-picker.less @@ -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; + } + } + } +} diff --git a/web/src/less/common/selector.less b/web/src/less/common/selector.less new file mode 100644 index 00000000..57c055b3 --- /dev/null +++ b/web/src/less/common/selector.less @@ -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; + } + } + } +} diff --git a/web/src/less/create-query-dialog.less b/web/src/less/create-query-dialog.less new file mode 100644 index 00000000..fb56f17b --- /dev/null +++ b/web/src/less/create-query-dialog.less @@ -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; + } + } +} diff --git a/web/src/less/daily-memo-diary-dialog.less b/web/src/less/daily-memo-diary-dialog.less new file mode 100644 index 00000000..24abb4bd --- /dev/null +++ b/web/src/less/daily-memo-diary-dialog.less @@ -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; + } + } + } +} diff --git a/web/src/less/daily-memo.less b/web/src/less/daily-memo.less new file mode 100644 index 00000000..5babf7f4 --- /dev/null +++ b/web/src/less/daily-memo.less @@ -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; + } + } + } + } +} diff --git a/web/src/less/dialog.less b/web/src/less/dialog.less new file mode 100644 index 00000000..500ff3ca --- /dev/null +++ b/web/src/less/dialog.less @@ -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%; + } + } +} diff --git a/web/src/less/editor.less b/web/src/less/editor.less new file mode 100644 index 00000000..e9d351f8 --- /dev/null +++ b/web/src/less/editor.less @@ -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; + } + } + } + } +} diff --git a/web/src/less/global.less b/web/src/less/global.less new file mode 100644 index 00000000..7b6bc87a --- /dev/null +++ b/web/src/less/global.less @@ -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; + } +} diff --git a/web/src/less/home.less b/web/src/less/home.less new file mode 100644 index 00000000..866a0e37 --- /dev/null +++ b/web/src/less/home.less @@ -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); + } + } +} diff --git a/web/src/less/image.less b/web/src/less/image.less new file mode 100644 index 00000000..609539b4 --- /dev/null +++ b/web/src/less/image.less @@ -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; + } +} diff --git a/web/src/less/memo-card-dialog.less b/web/src/less/memo-card-dialog.less new file mode 100644 index 00000000..cd490b8b --- /dev/null +++ b/web/src/less/memo-card-dialog.less @@ -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; + } +} diff --git a/web/src/less/memo-content.less b/web/src/less/memo-content.less new file mode 100644 index 00000000..2a3ecdfe --- /dev/null +++ b/web/src/less/memo-content.less @@ -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; + } + } +} diff --git a/web/src/less/memo-editor.less b/web/src/less/memo-editor.less new file mode 100644 index 00000000..a3074c9f --- /dev/null +++ b/web/src/less/memo-editor.less @@ -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; + } +} diff --git a/web/src/less/memo-filter.less b/web/src/less/memo-filter.less new file mode 100644 index 00000000..4da21f43 --- /dev/null +++ b/web/src/less/memo-filter.less @@ -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; + } +} diff --git a/web/src/less/memo-trash.less b/web/src/less/memo-trash.less new file mode 100644 index 00000000..b068f74b --- /dev/null +++ b/web/src/less/memo-trash.less @@ -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; + } +} diff --git a/web/src/less/memo.less b/web/src/less/memo.less new file mode 100644 index 00000000..2e5ebcd3 --- /dev/null +++ b/web/src/less/memo.less @@ -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; + } + } + } +} diff --git a/web/src/less/memolist.less b/web/src/less/memolist.less new file mode 100644 index 00000000..b2cb3301 --- /dev/null +++ b/web/src/less/memolist.less @@ -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; + } +} diff --git a/web/src/less/memos-header.less b/web/src/less/memos-header.less new file mode 100644 index 00000000..cfe16675 --- /dev/null +++ b/web/src/less/memos-header.less @@ -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; + } +} diff --git a/web/src/less/menu-btns-popup.less b/web/src/less/menu-btns-popup.less new file mode 100644 index 00000000..c8856b5c --- /dev/null +++ b/web/src/less/menu-btns-popup.less @@ -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; + } +} diff --git a/web/src/less/mixin.less b/web/src/less/mixin.less new file mode 100644 index 00000000..9e5bbc35 --- /dev/null +++ b/web/src/less/mixin.less @@ -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; +} diff --git a/web/src/less/my-account-section.less b/web/src/less/my-account-section.less new file mode 100644 index 00000000..c74fac02 --- /dev/null +++ b/web/src/less/my-account-section.less @@ -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; + } + } + } + } +} diff --git a/web/src/less/preferences-section.less b/web/src/less/preferences-section.less new file mode 100644 index 00000000..dc646e32 --- /dev/null +++ b/web/src/less/preferences-section.less @@ -0,0 +1,44 @@ +@import "./mixin.less"; + +.preferences-section-container { + > .demo-content-container { + padding: 16px; + border-radius: 8px; + border: 2px solid @bg-gray; + margin: 12px 0; + } + + > .form-label { + height: 28px; + cursor: pointer; + + > .icon-img { + width: 18px; + height: 18px; + margin: 0 8px; + } + + &:hover { + opacity: 0.8; + } + } + + > .btn-container { + .flex(row, flex-start, center); + width: 100%; + margin: 4px 0; + + .btn { + height: 28px; + padding: 0 12px; + margin-right: 8px; + border: 1px solid gray; + border-radius: 8px; + cursor: pointer; + + &:hover { + opacity: 0.8; + } + } + } +} diff --git a/web/src/less/preview-image-dialog.less b/web/src/less/preview-image-dialog.less new file mode 100644 index 00000000..cfba5c23 --- /dev/null +++ b/web/src/less/preview-image-dialog.less @@ -0,0 +1,102 @@ +@import "./mixin.less"; + +.preview-image-dialog { + padding: 0; + + > .dialog-container { + .flex(column, center, center); + position: relative; + width: 100%; + height: 100%; + background-color: unset; + padding: 0; + + > .close-btn { + position: fixed; + top: 36px; + right: 36px; + width: 36px; + height: 36px; + padding: 4px; + cursor: pointer; + border-radius: 4px; + background-color: lightgray; + z-index: 1; + + > .icon-img { + width: 28px; + height: 28px; + } + + &:hover { + opacity: 0.8; + } + } + + > .img-container { + width: 100%; + height: 100%; + display: flex; + flex-direction: column; + overflow: auto; + .hide-scroll-bar(); + + > img { + padding: 16px; + height: auto; + margin: auto; + } + + > .loading-text { + color: white; + font-size: 24px; + margin: auto; + border-bottom: 2px solid white; + padding: 8px 4px; + } + } + + > .action-btns-container { + .flex(row, center, center); + position: fixed; + bottom: 36px; + z-index: 1; + + > .btn { + .flex(row, center, center); + width: 40px; + height: 40px; + font-size: 20px; + margin-right: 16px; + border-radius: 4px; + background-color: lightgray; + box-shadow: 0 0 8px 0 rgb(0 0 0 / 20%); + + &:last-child { + margin-right: 0; + } + + &:hover, + &:active { + opacity: 0.8; + } + } + } + } +} + +@media only screen and (max-width: 875px) { + .preview-image-dialog { + padding: 0; + + > .dialog-container { + max-width: 100%; + + > .img-container { + > img { + padding: 6px; + } + } + } + } +} diff --git a/web/src/less/query-list.less b/web/src/less/query-list.less new file mode 100644 index 00000000..cbe432bf --- /dev/null +++ b/web/src/less/query-list.less @@ -0,0 +1,199 @@ +@import "./mixin.less"; + +.queries-wrapper { + .flex(column, flex-start, flex-start); + width: 100%; + padding: 0 8px; + height: auto; + flex-wrap: nowrap; + .hide-scroll-bar(); + + > .title-text { + .flex(row, space-between, center); + width: 100%; + padding: 4px 16px; + + > * { + font-size: 12px; + line-height: 24px; + color: @text-black; + opacity: 0.5; + font-weight: bold; + } + + > .btn { + display: none; + padding: 0 4px; + font-size: 18px; + } + + &:hover, + &:active { + > .btn { + display: block; + } + } + } + + > .create-query-btn-container { + .flex(row, center, center); + width: 100%; + margin-top: 8px; + margin-bottom: 12px; + + > .btn { + display: flex; + padding: 4px 8px; + border: 1px dashed @bg-blue; + border-radius: 8px; + font-size: 13px; + + &:hover { + background-color: @bg-blue; + color: white; + } + } + } + + > .queries-container { + .flex(column, flex-start, flex-start); + position: relative; + width: 100%; + height: auto; + flex-wrap: nowrap; + margin-bottom: 8px; + + > .query-item-container { + .flex(row, space-between, center); + width: 100%; + height: 40px; + padding: 0 16px; + margin-top: 4px; + border-radius: 8px; + font-size: 14px; + cursor: pointer; + flex-shrink: 0; + user-select: none; + + &:hover { + background-color: @bg-gray; + + > .btns-container { + display: flex; + } + } + + &.active { + background-color: @text-green !important; + + > .query-text-container { + font-weight: bold; + + > * { + color: white; + } + } + } + + > .query-text-container { + .flex(row, flex-start, center); + max-width: calc(100% - 24px); + color: @text-black; + overflow: hidden; + text-overflow: ellipsis; + flex-shrink: 0; + line-height: 20px; + + > .icon-text { + display: block; + width: 16px; + flex-shrink: 0; + } + + > .query-text { + flex-shrink: 0; + } + } + + > .btns-container { + .flex(row, flex-end, center); + display: none; + + > .action-btn { + .flex(row, center, center); + width: 24px; + height: 24px; + flex-shrink: 0; + + > .icon-img { + width: 18px; + height: auto; + } + } + + > .action-btns-wrapper { + .flex(column, flex-start, flex-start); + position: absolute; + right: 0; + width: auto; + height: auto; + padding: 8px; + transform: translateY(60px); + z-index: 1; + + > .action-btns-container { + .flex(column, flex-start, flex-start); + width: 86px; + height: auto; + white-space: nowrap; + border-radius: 6px; + padding: 4px; + background-color: white; + box-shadow: 0 0 8px 0 rgb(0 0 0 / 20%); + + > .btn { + width: 100%; + padding: 6px 0; + padding-left: 12px; + border-radius: 4px; + font-size: 13px; + height: unset; + line-height: unset; + text-align: left; + + &:hover { + background-color: @bg-whitegray; + } + + &.delete-btn { + color: @text-red; + + &.final-confirm { + font-weight: bold; + } + } + } + } + } + } + } + } +} + +@media only screen and (max-width: 875px) { + .queries-container { + height: auto; + + &:last-child { + flex-grow: 1; + } + + > .title-text { + font-size: 13px; + } + + > .query-item-container { + font-size: 15px; + } + } +} diff --git a/web/src/less/search-bar.less b/web/src/less/search-bar.less new file mode 100644 index 00000000..cb92746b --- /dev/null +++ b/web/src/less/search-bar.less @@ -0,0 +1,110 @@ +@import "./mixin.less"; + +.search-bar-container { + width: 160px; + + > .search-bar-inputer { + .flex(row, flex-start, center); + background-color: @bg-lightgray; + width: 100%; + padding: 8px 16px; + border-radius: 8px; + + > .icon-img { + margin-right: 8px; + width: 14px; + height: auto; + opacity: 0.6; + } + + > .text-input { + width: 100%; + font-size: 15px; + } + + &:hover { + + .quickly-action-wrapper { + display: flex; + } + } + } + + > .quickly-action-wrapper { + display: none; + position: absolute; + top: 52px; + right: -8px; + z-index: 2; + padding: 8px; + width: 320px; + + > .quickly-action-container { + .flex(column, flex-start, flex-start); + width: 100%; + background-color: white; + padding: 12px 16px; + border-radius: 8px; + box-shadow: 0 0 8px 0 rgb(0 0 0 / 20%); + + > .title-text { + color: gray; + font-size: 12px; + } + + > .types-container { + .flex(row, flex-start, flex-start); + width: 100%; + font-size: 13px; + margin-top: 8px; + + > .section-text { + color: gray; + margin-right: 4px; + flex-shrink: 0; + line-height: 26px; + } + + > .values-container { + .flex(row, flex-start, flex-start); + flex-wrap: wrap; + user-select: none; + + > div { + .flex(row, flex-start, center); + line-height: 26px; + + .type-item { + cursor: pointer; + padding: 0 4px; + border-radius: 6px; + + &:hover { + background-color: @bg-whitegray; + } + + &.selected { + background-color: @text-green; + color: white; + } + } + + .split-text { + color: lightgray; + margin: 0 2px; + } + } + } + } + } + + &:hover { + display: flex; + } + } +} + +@media only screen and (max-width: 875px) { + .search-bar-container > .quickly-action-wrapper { + right: 4px; + } +} diff --git a/web/src/less/setting.less b/web/src/less/setting.less new file mode 100644 index 00000000..bcc9b55e --- /dev/null +++ b/web/src/less/setting.less @@ -0,0 +1,72 @@ +@import "./mixin.less"; +@import "./memos-header.less"; + +.preference-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); + } + + > .sections-wrapper { + .flex(column, flex-start, flex-start); + flex-grow: 1; + width: 100%; + overflow-y: scroll; + padding-bottom: 64px; + .hide-scroll-bar(); + + > .section-container { + .flex(column, flex-start, flex-start); + width: 100%; + background-color: white; + margin: 8px 0; + padding: 16px; + padding-bottom: 8px; + border-radius: 8px; + + > .title-text { + font-size: 15px; + color: @text-black; + font-weight: bold; + margin-bottom: 8px; + } + + > .form-label { + .flex(row, flex-start, center); + width: 100%; + font-size: 14px; + margin-bottom: 8px; + + > .normal-text { + flex-shrink: 0; + } + } + } + } +} + +@media only screen and (max-width: 875px) { + .sections-wrapper { + padding: 0 12px; + } +} diff --git a/web/src/less/share-memo-image-dialog.less b/web/src/less/share-memo-image-dialog.less new file mode 100644 index 00000000..1280f745 --- /dev/null +++ b/web/src/less/share-memo-image-dialog.less @@ -0,0 +1,148 @@ +@import "./mixin.less"; + +.share-memo-image-dialog { + > .dialog-container { + width: 380px; + padding: 0; + background-color: @bg-lightgray; + + > .dialog-header-container { + padding: 8px 16px; + padding-left: 24px; + margin-bottom: 0; + background-color: white; + border-top-left-radius: 8px; + border-top-right-radius: 8px; + } + + > .dialog-content-container { + .flex(column, flex-start, flex-start); + position: relative; + width: 100%; + min-height: 128px; + + > .tip-words-container { + .flex(column, center, flex-start); + width: 100%; + border-bottom: 1px solid lightgray; + background-color: white; + padding: 0 24px; + padding-bottom: 8px; + + > .tip-text { + color: gray; + font-size: 13px; + line-height: 24px; + } + + &.loading { + > .tip-text { + animation: 1s linear 1s infinite alternate breathing; + } + } + + @keyframes breathing { + from { + opacity: 1; + } + + to { + opacity: 0.4; + } + } + } + + > .memo-container { + .flex(column, flex-start, flex-start); + width: 380px; + max-width: 100%; + height: auto; + user-select: none; + position: relative; + + > .memo-shortcut-img { + position: absolute; + top: 0; + left: 0; + z-index: 1; + width: 100%; + height: auto; + border-bottom-left-radius: 8px; + border-bottom-right-radius: 8px; + } + + > .time-text { + width: 100%; + padding: 0 24px; + padding-top: 20px; + font-size: 12px; + color: gray; + background-color: white; + } + + > .memo-content-text { + padding: 12px 24px; + width: 100%; + font-size: 15px; + background-color: white; + } + + > .images-container { + .flex(column, flex-start, flex-start); + width: 100%; + height: auto; + padding: 0 24px; + padding-bottom: 8px; + background-color: white; + .hide-scroll-bar(); + + > img { + width: 100%; + height: auto; + margin-bottom: 8px; + border-radius: 4px; + } + } + + > .watermark-container { + .flex(row, flex-start, center); + flex-wrap: nowrap; + width: 100%; + padding: 16px 26px; + + > .normal-text { + .flex(row, flex-start, center); + width: 100%; + font-size: 12px; + line-height: 20px; + color: gray; + + > .name-text { + font-size: 13px; + color: @text-black; + margin-left: 4px; + line-height: 20px; + } + + > .icon-text { + font-size: 15px; + margin-right: 6px; + } + } + } + } + } + } +} + +@media only screen and (max-width: 875px) { + .dialog-wrapper.share-memo-image-dialog { + padding: 24px 16px; + padding-top: 64px; + justify-content: unset; + + &::-webkit-scrollbar { + display: none; + } + } +} diff --git a/web/src/less/siderbar.less b/web/src/less/siderbar.less new file mode 100644 index 00000000..33e0c1ee --- /dev/null +++ b/web/src/less/siderbar.less @@ -0,0 +1,48 @@ +@import "./mixin.less"; + +.sidebar-wrapper { + .flex(column, flex-start, flex-start); + width: 240px; + height: 100%; + padding: 16px 0; + overflow-x: hidden; + overflow-y: auto; + flex-shrink: 0; + .hide-scroll-bar(); + + > * { + flex-shrink: 0; + } +} + +@media only screen and (max-width: 875px) { + body.mobile-show-sidebar { + #page-wrapper { + > .sidebar-wrapper { + transform: translateX(0); + box-shadow: 0 0 8px 0 rgb(0 0 0 / 20%); + } + } + } + + .sidebar-wrapper { + .flex(column, flex-start, center); + z-index: 99; + position: absolute; + top: 0; + left: 0; + width: 320px; + height: 100%; + padding: 0; + background-color: white; + transition: all 0.4s ease; + transform: translateX(-320px); + + > * { + width: 320px; + max-width: 95%; + flex-shrink: 0; + padding-left: 32px; + } + } +} diff --git a/web/src/less/signin.less b/web/src/less/signin.less new file mode 100644 index 00000000..0212eeba --- /dev/null +++ b/web/src/less/signin.less @@ -0,0 +1,177 @@ +@import "./mixin.less"; + +.page-wrapper.signin { + .flex(row, center, center); + width: 100%; + height: 100%; + background-color: white; + + > .page-container { + width: 360px; + max-width: 100%; + padding: 16px; + margin-top: -64px; + + > .page-header-container { + .flex(row, space-between, center); + width: 100%; + margin-bottom: 16px; + + > .title-text { + font-size: 24px; + } + } + + > .page-content-container { + .flex(column, flex-start, flex-start); + flex-wrap: nowrap; + + > .form-item-container { + .flex(column, flex-start, flex-start); + position: relative; + width: 100%; + line-height: 1.6; + margin-top: 8px; + + > .normal-text { + position: absolute; + top: 18px; + left: 12px; + flex-shrink: 0; + font-size: 13px; + line-height: 2; + color: gray; + cursor: text; + padding: 0 4px; + background-color: transparent; + border-radius: 50%; + transition: all 0.3s ease; + + &.not-null { + top: -6px; + left: 12px; + background-color: white; + } + } + + &.input-form-container { + padding: 8px 0; + + > input { + width: 100%; + padding: 8px 12px; + font-size: 15px; + line-height: 2; + border-radius: 8px; + border: 1px solid lightgray; + } + } + + &:hover { + opacity: 0.8; + } + } + } + + > .page-footer-container { + .flex(row, space-between, center); + width: 100%; + margin: 12px 0; + + > .btns-container { + .flex(row, flex-start, center); + + > .btn { + padding: 0 4px; + font-size: 13px; + line-height: 32px; + border-radius: 4px; + + &:hover { + opacity: 0.8; + } + + &.disabled { + color: lightgray; + cursor: not-allowed; + } + + &.signin-btn { + background-color: @text-green; + color: white; + padding: 0 12px; + + &.requesting { + cursor: wait; + opacity: 0.8; + } + } + } + + > .btn-text { + font-size: 13px; + } + + > .split-text { + color: lightgray; + margin: 0 8px; + } + } + } + + > .tip-text { + .flex(row, flex-start, center); + border-top: 2px solid lightgray; + color: gray; + padding-top: 16px; + width: 100%; + font-size: 12px; + line-height: 30px; + + > .btn { + width: 86px; + color: @text-green; + border-radius: 4px; + background-color: @bg-lightgray; + + > .icon-text { + margin-right: 4px; + } + + &:hover { + opacity: 0.8; + } + } + } + + > .quickly-btns-container { + .flex(column, flex-start, flex-start); + width: 100%; + margin-top: 24px; + + > .btn { + margin-bottom: 24px; + line-height: 40px; + border: 2px solid lightgray; + border-radius: 22px; + padding: 0 16px; + font-size: 15px; + + &:hover { + opacity: 0.8; + } + + &.guest-signin { + color: @text-green; + border-color: @text-green; + font-weight: bold; + } + + &.requesting { + cursor: wait; + opacity: 0.8; + } + } + } + } +} diff --git a/web/src/less/tag-list.less b/web/src/less/tag-list.less new file mode 100644 index 00000000..803d42ba --- /dev/null +++ b/web/src/less/tag-list.less @@ -0,0 +1,203 @@ +@import "./mixin.less"; + +.tags-wrapper { + .flex(column, flex-start, flex-start); + width: 100%; + padding: 0 8px; + height: auto; + flex-wrap: nowrap; + padding-bottom: 16px; + flex-grow: 1; + .hide-scroll-bar(); + + > .title-text { + width: 100%; + padding: 4px 16px; + font-size: 12px; + line-height: 24px; + color: @text-black; + opacity: 0.5; + } + + > .tags-container { + .flex(column, flex-start, flex-start); + position: relative; + width: 100%; + height: auto; + flex-wrap: nowrap; + margin-bottom: 8px; + + .subtags-container { + .flex(column, flex-start, flex-start); + width: calc(100% - 18px); + min-width: 80px; + height: auto; + margin-top: 4px; + margin-left: 18px; + border-left: 2px solid @bg-gray; + padding-left: 6px; + + > .tag-item-container { + &:first-child { + margin-top: 0; + } + } + } + + .tag-item-container { + .flex(row, space-between, center); + width: 100%; + height: 40px; + padding: 0 16px; + margin-top: 4px; + border-radius: 8px; + font-size: 14px; + cursor: pointer; + flex-shrink: 0; + user-select: none; + + &:hover { + background-color: @bg-gray; + } + + &.active { + > .tag-text-container { + > * { + color: @text-green; + font-weight: bold; + } + } + } + + > .tag-text-container { + .flex(row, flex-start, center); + max-width: calc(100% - 24px); + color: @text-black; + overflow: hidden; + text-overflow: ellipsis; + flex-shrink: 0; + line-height: 20px; + + > .icon-text { + display: block; + width: 16px; + flex-shrink: 0; + flex-shrink: 0; + } + + > .tag-text { + flex-shrink: 0; + } + } + + > .btns-container { + .flex(row, flex-end, center); + + > .action-btn { + .flex(row, center, center); + width: 24px; + height: 24px; + flex-shrink: 0; + transition: all 0.1s linear; + transform: rotate(0); + + > .icon-img { + width: 18px; + height: 18px; + opacity: 0.8; + } + + &.shown { + transform: rotate(90deg); + } + } + } + } + + > .tag-tip-container { + width: 100%; + margin-top: 8px; + padding-left: 16px; + font-size: 12px; + line-height: 1.6; + color: gray; + + > .code-text { + color: @text-blue; + padding: 4px; + margin: 0 2px; + white-space: pre-line; + background-color: @bg-light-blue; + border-radius: 4px; + } + } + } +} + +.rename-tag-dialog { + > .dialog-container { + width: 320px; + + > .dialog-content-container { + .flex(column, flex-start, flex-start); + + > .tag-text { + margin-bottom: 8px; + font-size: 14px; + } + + > .text-input { + width: 100%; + padding: 8px 12px; + border: 1px solid lightgray; + border-radius: 4px; + font-size: 14px; + margin-bottom: 12px; + } + + > .btns-container { + .flex(row, flex-end, center); + width: 100%; + + > .btn-text { + font-size: 14px; + margin-left: 12px; + cursor: pointer; + + &:hover { + opacity: 0.8; + } + + &.cancel-btn { + color: @text-gray; + } + + &.confirm-btn { + background-color: @text-green; + color: white; + padding: 4px 12px; + border-radius: 4px; + } + } + } + } + } +} + +@media only screen and (max-width: 875px) { + .tags-wrapper { + background-color: white; + + > .tags-container { + height: auto; + + &:last-child { + flex-grow: 1; + } + } + } + + .rename-tag-dialog { + padding-top: 64px; + } +} diff --git a/web/src/less/toast.less b/web/src/less/toast.less new file mode 100644 index 00000000..c21cbdaf --- /dev/null +++ b/web/src/less/toast.less @@ -0,0 +1,55 @@ +@import "./mixin.less"; + +.toast-list-container { + .flex(column, flex-start, flex-end); + position: fixed; + top: 8px; + right: 16px; + z-index: 1000; + max-height: 100%; + + > .toast-wrapper { + .flex(column, flex-start, flex-start); + position: relative; + left: 100%; + visibility: hidden; + min-width: 6em; + min-height: 40px; + margin-top: 24px; + padding: 8px 16px; + background-color: white; + border-radius: 4px; + box-shadow: 0 0 8px 0 rgb(0 0 0 / 20%); + font-size: 13px; + cursor: pointer; + transition: all 0.4s ease; + + &.showup { + left: 0; + visibility: visible; + } + + &.destory { + left: calc(100% + 32px); + visibility: hidden; + } + + > .toast-container { + > .content-text { + line-height: 24px; + max-width: 160px; + word-wrap: break-word; + } + } + + &::before { + content: ""; + position: absolute; + top: 12px; + right: -8px; + border-left: 8px solid white; + border-top: 8px solid transparent; + border-bottom: 8px solid transparent; + } + } +} diff --git a/web/src/less/usage-heat-map.less b/web/src/less/usage-heat-map.less new file mode 100644 index 00000000..27437ada --- /dev/null +++ b/web/src/less/usage-heat-map.less @@ -0,0 +1,155 @@ +@import "./mixin.less"; + +@stat-day-L1-bg: #9be9a8; +@stat-day-L2-bg: #40c463; +@stat-day-L3-bg: #30a14e; +@stat-day-L4-bg: #216e39; + +.usage-heat-map-wrapper { + .flex(row, flex-start, center); + width: 100%; + height: 122px; + flex-wrap: wrap; + padding-right: 24px; + padding-bottom: 12px; + + &:hover { + > .day-tip-text-container { + visibility: visible; + } + } + + > .day-tip-text-container { + .flex(column, space-between, center); + width: 24px; + height: 100%; + padding-bottom: 2px; + flex-wrap: wrap; + visibility: hidden; + + > .tip-text { + font-size: 10px; + line-height: 16px; + padding-right: 2px; + width: 100%; + text-align: right; + color: gray; + .mono-font-family(); + } + } + + > .usage-heat-map { + width: 192px; + height: 100%; + flex-wrap: wrap; + display: grid; + grid-template-rows: repeat(7, 1fr); + grid-template-columns: repeat(12, 1fr); + grid-auto-flow: column; + + > .stat-container { + display: block; + width: 13px; + height: 13px; + background-color: @bg-lightgray; + border-radius: 2px; + margin-bottom: 2px; + + &.null { + background-color: transparent; + } + + &.stat-day-L1-bg { + background-color: @stat-day-L1-bg !important; + } + + &.stat-day-L2-bg { + background-color: @stat-day-L2-bg !important; + } + + &.stat-day-L3-bg { + background-color: @stat-day-L3-bg !important; + } + + &.stat-day-L4-bg { + background-color: @stat-day-L4-bg !important; + } + + &.today { + border: 1px solid black; + } + } + } + + > .usage-detail-container { + position: absolute; + left: 0; + top: 0; + margin-left: 9px; + transform: translateX(-50%); + margin-top: -36px; + background-color: rgba(0, 0, 0, 0.8); + color: white; + padding: 6px 8px; + border-radius: 4px; + font-size: 12px; + line-height: 1.6; + z-index: 2; + user-select: none; + white-space: nowrap; + + > .date-text { + color: lightgray; + } + + &::before { + content: ""; + position: absolute; + bottom: -4px; + left: calc(50% - 6px); + border-left: 4px solid transparent; + border-right: 4px solid transparent; + border-top: 4px solid rgba(0, 0, 0, 0.8); + } + } +} + +@media only screen and (max-width: 875px) { + .usage-heat-map-wrapper { + height: 160px; + padding: 8px 0 !important; + padding-top: 12px !important; + + > .day-tip-text-container { + visibility: visible; + width: 48px; + padding-bottom: 4px; + + > .tip-text { + padding-right: 6px; + font-size: 12px; + line-height: unset !important; + } + } + + > .usage-heat-map { + width: 240px; + + > .stat-container { + width: 16px; + height: 16px; + margin-bottom: 4px; + } + } + + > .usage-detail-container { + margin-top: -36px; + margin-left: 8px; + font-size: 12px; + + &::before { + left: calc(50% - 4px); + } + } + } +} diff --git a/web/src/less/user-banner.less b/web/src/less/user-banner.less new file mode 100644 index 00000000..ac3962f7 --- /dev/null +++ b/web/src/less/user-banner.less @@ -0,0 +1,102 @@ +@import "./mixin.less"; + +.user-banner-container { + .flex(column, flex-start, flex-start); + width: 100%; + height: 128px; + + > .userinfo-header-container { + .flex(row, space-between, center); + width: 100%; + padding: 0 24px; + flex-wrap: nowrap; + margin-bottom: 4px; + + > .username-text { + max-width: calc(100% - 32px); + font-weight: bold; + font-size: 18px; + line-height: 36px; + color: @text-black; + overflow: hidden; + text-overflow: ellipsis; + cursor: pointer; + margin-right: auto; + flex-shrink: 0; + } + + > .action-btn { + flex-shrink: 0; + user-select: none; + border: none; + background-color: unset; + + &.menu-popup-btn { + .flex(column, center, center); + width: 36px; + height: 40px; + margin-right: -8px; + cursor: pointer; + + > .icon-img { + width: 20px; + height: auto; + } + } + } + } + + > .status-text-container { + .flex(row, space-between, flex-start); + padding: 0 24px; + width: 100%; + user-select: none; + + > .status-text { + .flex(column, flex-start, flex-start); + + > .amount-text { + font-weight: bold; + font-size: 28px; + line-height: 1.8; + color: @text-black; + opacity: 0.8; + } + + > .type-text { + color: gray; + font-size: 12px; + .mono-font-family(); + } + } + } +} + +@media only screen and (max-width: 875px) { + .user-banner-container { + height: 154px; + z-index: 1; + padding-top: 16px !important; + + > .userinfo-header-container { + padding: 0 16px; + + > .username-text { + font-size: 22px; + } + } + + > .status-text-container { + padding: 0 16px; + + > .status-text { + > .amount-text { + font-size: 32px; + } + > .type-text { + font-size: 14px; + } + } + } + } +} diff --git a/web/src/main.tsx b/web/src/main.tsx new file mode 100644 index 00000000..e9c3961c --- /dev/null +++ b/web/src/main.tsx @@ -0,0 +1,17 @@ +import React from "react"; +import ReactDOM from "react-dom"; +import Provider from "./labs/Provider"; +import appContext from "./stores/appContext"; +import appStore from "./stores/appStore"; +import App from "./App"; +import "./helpers/polyfill"; +import "./less/global.less"; + +ReactDOM.render( + + + + + , + document.getElementById("root") +); diff --git a/web/src/pages/Home.tsx b/web/src/pages/Home.tsx new file mode 100644 index 00000000..03039235 --- /dev/null +++ b/web/src/pages/Home.tsx @@ -0,0 +1,47 @@ +import { useContext, useEffect } from "react"; +import { locationService, userService } from "../services"; +import { homeRouterSwitch } from "../routers"; +import appContext from "../stores/appContext"; +import Sidebar from "../components/Sidebar"; +import useLoading from "../hooks/useLoading"; +import "../less/home.less"; + +function Home() { + const { + locationState: { pathname }, + } = useContext(appContext); + const loadingState = useLoading(); + + useEffect(() => { + const { user } = userService.getState(); + if (!user) { + userService + .doSignIn() + .catch(() => { + // do nth + }) + .finally(() => { + if (userService.getState().user) { + loadingState.setFinish(); + } else { + locationService.replaceHistory("/signin"); + } + }); + } else { + loadingState.setFinish(); + } + }, []); + + return ( + <> + {loadingState.isLoading ? null : ( +
+ +
{homeRouterSwitch(pathname)}
+
+ )} + + ); +} + +export default Home; diff --git a/web/src/pages/MemoTrash.tsx b/web/src/pages/MemoTrash.tsx new file mode 100644 index 00000000..c54d6548 --- /dev/null +++ b/web/src/pages/MemoTrash.tsx @@ -0,0 +1,127 @@ +import { useCallback, useContext, useEffect, useState } from "react"; +import appContext from "../stores/appContext"; +import useLoading from "../hooks/useLoading"; +import { globalStateService, 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 Only from "../components/common/OnlyWhen"; +import toastHelper from "../components/Toast"; +import DeletedMemo from "../components/DeletedMemo"; +import MemoFilter from "../components/MemoFilter"; +import "../less/memo-trash.less"; + +interface Props {} + +const MemoTrash: React.FC = () => { + const { + locationState: { query }, + globalState: { isMobileView }, + } = useContext(appContext); + const loadingState = useLoading(); + const [deletedMemos, setDeletedMemos] = useState([]); + + const { tag: tagQuery, duration, type: memoType, text: textQuery, filter: queryId } = query; + const queryFilter = queryService.getQueryById(queryId); + const showMemoFilter = Boolean(tagQuery || (duration && duration.from < duration.to) || memoType || textQuery || queryFilter); + + const shownMemos = + showMemoFilter || queryFilter + ? deletedMemos.filter((memo) => { + let shouldShow = true; + + if (queryFilter) { + const filters = JSON.parse(queryFilter.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; + }) + : deletedMemos; + + useEffect(() => { + memoService.fetchAllMemos(); + memoService + .fetchDeletedMemos() + .then((result) => { + if (result !== false) { + setDeletedMemos(result); + } + }) + .catch((error) => { + toastHelper.error("Failed to fetch deleted memos: ", error); + }) + .finally(() => { + loadingState.setFinish(); + }); + locationService.clearQuery(); + }, []); + + const handleDeletedMemoAction = useCallback((memoId: string) => { + setDeletedMemos((deletedMemos) => deletedMemos.filter((memo) => memo.id !== memoId)); + }, []); + + const handleShowSidebarBtnClick = useCallback(() => { + globalStateService.setShowSiderbarInMobileView(true); + }, []); + + return ( +
+
+
+ + + + 回收站 +
+
+ + {loadingState.isLoading ? ( +
+

努力请求数据中...

+
+ ) : deletedMemos.length === 0 ? ( +
+

Here is No Zettels.

+
+ ) : ( +
+ {shownMemos.map((memo) => ( + + ))} +
+ )} +
+ ); +}; + +export default MemoTrash; diff --git a/web/src/pages/Memos.tsx b/web/src/pages/Memos.tsx new file mode 100644 index 00000000..8d0beec4 --- /dev/null +++ b/web/src/pages/Memos.tsx @@ -0,0 +1,17 @@ +import MemoEditor from "../components/MemoEditor"; +import MemosHeader from "../components/MemosHeader"; +import MemoFilter from "../components/MemoFilter"; +import MemoList from "../components/MemoList"; + +function Memos() { + return ( + <> + + + + + + ); +} + +export default Memos; diff --git a/web/src/pages/Setting.tsx b/web/src/pages/Setting.tsx new file mode 100644 index 00000000..99f6039e --- /dev/null +++ b/web/src/pages/Setting.tsx @@ -0,0 +1,45 @@ +import { useCallback, useContext, useEffect } from "react"; +import appContext from "../stores/appContext"; +import { globalStateService, memoService } from "../services"; +import MyAccountSection from "../components/MyAccountSection"; +import PreferencesSection from "../components/PreferencesSection"; +import Only from "../components/common/OnlyWhen"; +import "../less/setting.less"; + +interface Props {} + +const Setting: React.FC = () => { + const { + globalState: { isMobileView }, + } = useContext(appContext); + + useEffect(() => { + memoService.fetchAllMemos(); + }, []); + + const handleShowSidebarBtnClick = useCallback(() => { + globalStateService.setShowSiderbarInMobileView(true); + }, []); + + return ( +
+
+
+ + + + 账号与设置 +
+
+ +
+ + +
+
+ ); +}; + +export default Setting; diff --git a/web/src/pages/Signin.tsx b/web/src/pages/Signin.tsx new file mode 100644 index 00000000..70ce7f66 --- /dev/null +++ b/web/src/pages/Signin.tsx @@ -0,0 +1,217 @@ +import { useEffect, useRef, useState } from "react"; +import api from "../helpers/api"; +import { validate, ValidatorConfig } from "../helpers/validator"; +import useLoading from "../hooks/useLoading"; +import { locationService, userService } from "../services"; +import Only from "../components/common/OnlyWhen"; +import showAboutSiteDialog from "../components/AboutSiteDialog"; +import toastHelper from "../components/Toast"; +import "../less/signin.less"; + +interface Props {} + +const validateConfig: ValidatorConfig = { + minLength: 4, + maxLength: 24, + noSpace: true, + noChinese: true, +}; + +const Signin: React.FC = () => { + const [username, setUsername] = useState(""); + const [password, setPassword] = useState(""); + const [showAutoSigninAsGuest, setShowAutoSigninAsGuest] = useState(true); + const signinBtnClickLoadingState = useLoading(false); + const autoSigninAsGuestBtn = useRef(null); + const signinBtn = useRef(null); + + useEffect(() => { + const handleKeyPress = (e: KeyboardEvent) => { + if (e.key === "Enter") { + autoSigninAsGuestBtn.current?.click(); + signinBtn.current?.click(); + } + }; + + document.body.addEventListener("keypress", handleKeyPress); + + return () => { + document.body.removeEventListener("keypress", handleKeyPress); + }; + }, []); + + const handleUsernameInputChanged = (e: React.ChangeEvent) => { + const text = e.target.value as string; + setUsername(text); + }; + + const handlePasswordInputChanged = (e: React.ChangeEvent) => { + const text = e.target.value as string; + setPassword(text); + }; + + const handleAboutBtnClick = () => { + showAboutSiteDialog(); + }; + + const handleSignInBtnClick = async () => { + if (signinBtnClickLoadingState.isLoading) { + return; + } + + const usernameValidResult = validate(username, validateConfig); + if (!usernameValidResult.result) { + toastHelper.error("用户名 " + usernameValidResult.reason); + return; + } + + const passwordValidResult = validate(password, validateConfig); + if (!passwordValidResult.result) { + toastHelper.error("密码 " + passwordValidResult.reason); + return; + } + + try { + signinBtnClickLoadingState.setLoading(); + const actionFunc = api.signin; + const { succeed, message } = await actionFunc(username, password); + + if (!succeed && message) { + toastHelper.error("😟 " + message); + return; + } + + const user = await userService.doSignIn(); + if (user) { + locationService.replaceHistory("/"); + } else { + toastHelper.error("😟 登录失败"); + } + } catch (error: any) { + console.error(error); + toastHelper.error("😟 " + error.message); + } + signinBtnClickLoadingState.setFinish(); + }; + + const handleSwitchAccountSigninBtnClick = () => { + if (signinBtnClickLoadingState.isLoading) { + return; + } + + setShowAutoSigninAsGuest(false); + }; + + const handleAutoSigninAsGuestBtnClick = async () => { + if (signinBtnClickLoadingState.isLoading) { + return; + } + + try { + signinBtnClickLoadingState.setLoading(); + const { succeed, message } = await api.signin("guest", "123456"); + + if (!succeed && message) { + toastHelper.error("😟 " + message); + return; + } + + const user = await userService.doSignIn(); + if (user) { + locationService.replaceHistory("/"); + } else { + toastHelper.error("😟 登录失败"); + } + } catch (error: any) { + console.error(error); + toastHelper.error("😟 " + error.message); + } + signinBtnClickLoadingState.setFinish(); + }; + + return ( +
+
+
+

+ 登录 Memos ✍️ +

+
+ {showAutoSigninAsGuest ? ( + <> +
+
+ 👉 快速登录进行体验 +
+
+ 已有账号,我要自己登录 +
+
+

+ 仅用于作品展示。 +
+ + 🤠 + 关于本站 + +

+ + ) : ( + <> +
+
+ 账号 + +
+
+ 密码 + +
+
+
+ +
+ + / + + / + +
+
+ + )} +
+
+ ); +}; + +export default Signin; diff --git a/web/src/routers/appRouter.tsx b/web/src/routers/appRouter.tsx new file mode 100644 index 00000000..8e4be8a5 --- /dev/null +++ b/web/src/routers/appRouter.tsx @@ -0,0 +1,9 @@ +import Home from "../pages/Home"; +import Signin from "../pages/Signin"; + +const appRouter = { + "/signin": , + "*": , +}; + +export default appRouter; diff --git a/web/src/routers/homeRouter.tsx b/web/src/routers/homeRouter.tsx new file mode 100644 index 00000000..7588e4e5 --- /dev/null +++ b/web/src/routers/homeRouter.tsx @@ -0,0 +1,11 @@ +import Memos from "../pages/Memos"; +import MemoTrash from "../pages/MemoTrash"; +import Setting from "../pages/Setting"; + +const homeRouter = { + "/recycle": , + "/setting": , + "*": , +}; + +export default homeRouter; diff --git a/web/src/routers/index.ts b/web/src/routers/index.ts new file mode 100644 index 00000000..7075095a --- /dev/null +++ b/web/src/routers/index.ts @@ -0,0 +1,22 @@ +import appRouter from "./appRouter"; +import homeRouter from "./homeRouter"; + +// just like React-Router +interface Router { + [key: string]: JSX.Element | null; + "*": JSX.Element | null; +} + +const routerSwitch = (router: Router) => { + return (pathname: string) => { + for (const key of Object.keys(router)) { + if (key === pathname) { + return router[key]; + } + } + return router["*"]; + }; +}; + +export const appRouterSwitch = routerSwitch(appRouter); +export const homeRouterSwitch = routerSwitch(homeRouter); diff --git a/web/src/services/README.md b/web/src/services/README.md new file mode 100644 index 00000000..8fd208d5 --- /dev/null +++ b/web/src/services/README.md @@ -0,0 +1,7 @@ +# Services + +What should service do? + +- request data api and throw error; +- dispatch state actions; +- should be a class; diff --git a/web/src/services/globalStateService.ts b/web/src/services/globalStateService.ts new file mode 100644 index 00000000..f3896436 --- /dev/null +++ b/web/src/services/globalStateService.ts @@ -0,0 +1,69 @@ +import { storage } from "../helpers/storage"; +import appStore from "../stores/appStore"; +import { AppSetting } from "../stores/globalStateStore"; + +class GlobalStateService { + constructor() { + const cachedSetting = storage.get(["shouldSplitMemoWord", "shouldHideImageUrl", "shouldUseMarkdownParser", "useTinyUndoHistoryCache"]); + const defaultAppSetting = { + shouldSplitMemoWord: cachedSetting.shouldSplitMemoWord ?? true, + shouldHideImageUrl: cachedSetting.shouldHideImageUrl ?? true, + shouldUseMarkdownParser: cachedSetting.shouldUseMarkdownParser ?? true, + useTinyUndoHistoryCache: cachedSetting.useTinyUndoHistoryCache ?? false, + }; + + this.setAppSetting(defaultAppSetting); + } + + public getState = () => { + return appStore.getState().globalState; + }; + + public setEditMemoId = (editMemoId: string) => { + appStore.dispatch({ + type: "SET_EDIT_MEMO_ID", + payload: { + editMemoId, + }, + }); + }; + + public setMarkMemoId = (markMemoId: string) => { + appStore.dispatch({ + type: "SET_MARK_MEMO_ID", + payload: { + markMemoId, + }, + }); + }; + + public setIsMobileView = (isMobileView: boolean) => { + appStore.dispatch({ + type: "SET_MOBILE_VIEW", + payload: { + isMobileView, + }, + }); + }; + + public setShowSiderbarInMobileView = (showSiderbarInMobileView: boolean) => { + appStore.dispatch({ + type: "SET_SHOW_SIDEBAR_IN_MOBILE_VIEW", + payload: { + showSiderbarInMobileView, + }, + }); + }; + + public setAppSetting = (appSetting: Partial) => { + appStore.dispatch({ + type: "SET_APP_SETTING", + payload: appSetting, + }); + storage.set(appSetting); + }; +} + +const globalStateService = new GlobalStateService(); + +export default globalStateService; diff --git a/web/src/services/index.ts b/web/src/services/index.ts new file mode 100644 index 00000000..3e4d91fc --- /dev/null +++ b/web/src/services/index.ts @@ -0,0 +1,7 @@ +import globalStateService from "./globalStateService"; +import locationService from "./locationService"; +import memoService from "./memoService"; +import queryService from "./queryService"; +import userService from "./userService"; + +export { globalStateService, locationService, memoService, userService, queryService }; diff --git a/web/src/services/locationService.ts b/web/src/services/locationService.ts new file mode 100644 index 00000000..b6c0e0d2 --- /dev/null +++ b/web/src/services/locationService.ts @@ -0,0 +1,198 @@ +import utils from "../helpers/utils"; +import appStore from "../stores/appStore"; + +const updateLocationUrl = (method: "replace" | "push" = "replace") => { + const { query, pathname, hash } = appStore.getState().locationState; + let queryString = utils.transformObjectToParamsString(query); + if (queryString) { + queryString = "?" + queryString; + } else { + queryString = ""; + } + + if (method === "replace") { + window.history.replaceState(null, "", pathname + hash + queryString); + } else { + window.history.pushState(null, "", pathname + hash + queryString); + } +}; + +class LocationService { + constructor() { + this.updateStateWithLocation(); + window.onpopstate = () => { + this.updateStateWithLocation(); + }; + } + + public updateStateWithLocation = () => { + const { pathname, search, hash } = window.location; + const urlParams = new URLSearchParams(search); + const state: AppLocation = { + pathname: "/", + hash: "", + query: { + tag: "", + duration: null, + text: "", + type: "", + filter: "", + }, + }; + state.query.tag = urlParams.get("tag") ?? ""; + state.query.type = (urlParams.get("type") ?? "") as MemoSpecType; + state.query.text = urlParams.get("text") ?? ""; + state.query.filter = urlParams.get("filter") ?? ""; + const from = parseInt(urlParams.get("from") ?? "0"); + const to = parseInt(urlParams.get("to") ?? "0"); + if (to > from && to !== 0) { + state.query.duration = { + from, + to, + }; + } + state.hash = hash; + state.pathname = this.getValidPathname(pathname); + appStore.dispatch({ + type: "SET_LOCATION", + payload: state, + }); + }; + + public getState = () => { + return appStore.getState().locationState; + }; + + public clearQuery = () => { + appStore.dispatch({ + type: "SET_QUERY", + payload: { + tag: "", + duration: null, + text: "", + type: "", + filter: "", + }, + }); + + updateLocationUrl(); + }; + + public setQuery = (query: Query) => { + appStore.dispatch({ + type: "SET_QUERY", + payload: query, + }); + + updateLocationUrl(); + }; + + public setHash = (hash: string) => { + appStore.dispatch({ + type: "SET_HASH", + payload: { + hash, + }, + }); + + updateLocationUrl(); + }; + + public setPathname = (pathname: string) => { + appStore.dispatch({ + type: "SET_PATHNAME", + payload: { + pathname, + }, + }); + + updateLocationUrl(); + }; + + public pushHistory = (pathname: string) => { + appStore.dispatch({ + type: "SET_PATHNAME", + payload: { + pathname, + }, + }); + + updateLocationUrl("push"); + }; + + public replaceHistory = (pathname: string) => { + appStore.dispatch({ + type: "SET_PATHNAME", + payload: { + pathname, + }, + }); + + updateLocationUrl("replace"); + }; + + public setMemoTypeQuery = (type: MemoSpecType | "" = "") => { + appStore.dispatch({ + type: "SET_TYPE", + payload: { + type, + }, + }); + + updateLocationUrl(); + }; + + public setMemoFilter = (filterId: string) => { + appStore.dispatch({ + type: "SET_QUERY_FILTER", + payload: filterId, + }); + + updateLocationUrl(); + }; + + public setTextQuery = (text: string) => { + appStore.dispatch({ + type: "SET_TEXT", + payload: { + text, + }, + }); + + updateLocationUrl(); + }; + + public setTagQuery = (tag: string) => { + appStore.dispatch({ + type: "SET_TAG_QUERY", + payload: { + tag, + }, + }); + + updateLocationUrl(); + }; + + public setFromAndToQuery = (from: number, to: number) => { + appStore.dispatch({ + type: "SET_DURATION_QUERY", + payload: { + duration: { from, to }, + }, + }); + + updateLocationUrl(); + }; + + public getValidPathname = (pathname: string): AppRouter => { + if (["/", "/signin", "/recycle", "/setting"].includes(pathname)) { + return pathname as AppRouter; + } else { + return "/"; + } + }; +} + +const locationService = new LocationService(); + +export default locationService; diff --git a/web/src/services/memoService.ts b/web/src/services/memoService.ts new file mode 100644 index 00000000..df71cd95 --- /dev/null +++ b/web/src/services/memoService.ts @@ -0,0 +1,138 @@ +import api from "../helpers/api"; +import { TAG_REG } from "../helpers/consts"; +import appStore from "../stores/appStore"; +import userService from "./userService"; + +class MemoService { + public initialized = false; + + public getState() { + return appStore.getState().memoState; + } + + public async fetchAllMemos() { + if (!userService.getState().user) { + return false; + } + + const { data } = await api.getMyMemos(); + const memos = []; + for (const m of data) { + memos.push(m); + } + appStore.dispatch({ + type: "SET_MEMOS", + payload: { + memos, + }, + }); + + if (!this.initialized) { + this.initialized = true; + } + + return memos; + } + + public async fetchDeletedMemos() { + if (!userService.getState().user) { + return false; + } + + const { data } = await api.getMyDeletedMemos(); + return data; + } + + public pushMemo(memo: Model.Memo) { + appStore.dispatch({ + type: "INSERT_MEMO", + payload: { + memo: { + ...memo, + }, + }, + }); + } + + public getMemoById(id: string) { + for (const m of this.getState().memos) { + if (m.id === id) { + return m; + } + } + + return null; + } + + public async hideMemoById(id: string) { + await api.hideMemo(id); + appStore.dispatch({ + type: "DELETE_MEMO_BY_ID", + payload: { + id: id, + }, + }); + } + + public async restoreMemoById(id: string) { + await api.restoreMemo(id); + memoService.clearMemos(); + memoService.fetchAllMemos(); + } + + public async deleteMemoById(id: string) { + await api.deleteMemo(id); + } + + public editMemo(memo: Model.Memo) { + appStore.dispatch({ + type: "EDIT_MEMO", + payload: memo, + }); + } + + public updateTagsState() { + const { memos } = this.getState(); + const tagsSet = new Set(); + for (const m of memos) { + for (const t of Array.from(m.content.match(TAG_REG) ?? [])) { + tagsSet.add(t.replace(TAG_REG, "$1").trim()); + } + } + + appStore.dispatch({ + type: "SET_TAGS", + payload: { + tags: Array.from(tagsSet), + }, + }); + } + + public clearMemos() { + appStore.dispatch({ + type: "SET_MEMOS", + payload: { + memos: [], + }, + }); + } + + public async createMemo(text: string): Promise { + const { data: memo } = await api.createMemo(text); + return memo; + } + + public async updateMemo(memoId: string, text: string): Promise { + const { data: memo } = await api.updateMemo(memoId, text); + return memo; + } + + public async getLinkedMemos(memoId: string): Promise { + const { data } = await api.getLinkedMemos(memoId); + return data; + } +} + +const memoService = new MemoService(); + +export default memoService; diff --git a/web/src/services/queryService.ts b/web/src/services/queryService.ts new file mode 100644 index 00000000..288c59c7 --- /dev/null +++ b/web/src/services/queryService.ts @@ -0,0 +1,82 @@ +import userService from "./userService"; +import api from "../helpers/api"; +import appStore from "../stores/appStore"; + +class QueryService { + public getState() { + return appStore.getState().queryState; + } + + public async getMyAllQueries() { + if (!userService.getState().user) { + return false; + } + + const { data } = await api.getMyQueries(); + appStore.dispatch({ + type: "SET_QUERIES", + payload: { + queries: data, + }, + }); + return data; + } + + public getQueryById(id: string) { + for (const q of this.getState().queries) { + if (q.id === id) { + return q; + } + } + } + + public pushQuery(query: Model.Query) { + appStore.dispatch({ + type: "INSERT_QUERY", + payload: { + query: { + ...query, + }, + }, + }); + } + + public editQuery(query: Model.Query) { + appStore.dispatch({ + type: "UPDATE_QUERY", + payload: query, + }); + } + + public async deleteQuery(queryId: string) { + await api.deleteQueryById(queryId); + appStore.dispatch({ + type: "DELETE_QUERY_BY_ID", + payload: { + id: queryId, + }, + }); + } + + public async createQuery(title: string, querystring: string) { + const { data } = await api.createQuery(title, querystring); + return data; + } + + public async updateQuery(queryId: string, title: string, querystring: string) { + const { data } = await api.updateQuery(queryId, title, querystring); + return data; + } + + public async pinQuery(queryId: string) { + await api.pinQuery(queryId); + } + + public async unpinQuery(queryId: string) { + await api.unpinQuery(queryId); + } +} + +const queryService = new QueryService(); + +export default queryService; diff --git a/web/src/services/userService.ts b/web/src/services/userService.ts new file mode 100644 index 00000000..25e439db --- /dev/null +++ b/web/src/services/userService.ts @@ -0,0 +1,61 @@ +import api from "../helpers/api"; +import appStore from "../stores/appStore"; + +class UserService { + public getState() { + return appStore.getState().userState; + } + + public async doSignIn() { + const { data: user } = await api.getUserInfo(); + if (user) { + appStore.dispatch({ + type: "SIGN_IN", + payload: { user }, + }); + } else { + userService.doSignOut(); + } + return user; + } + + public async doSignOut() { + appStore.dispatch({ + type: "SIGN_OUT", + payload: null, + }); + api.signout().catch(() => { + // do nth + }); + } + + public async checkUsernameUsable(username: string): Promise { + const { data: isUsable } = await api.checkUsernameUsable(username); + return isUsable; + } + + public async updateUsername(username: string): Promise { + await api.updateUserinfo(username); + } + + public async removeGithubName(): Promise { + await api.updateUserinfo(undefined, undefined, ""); + } + + public async checkPasswordValid(password: string): Promise { + const { data: isValid } = await api.checkPasswordValid(password); + return isValid; + } + + public async updatePassword(password: string): Promise { + await api.updateUserinfo(undefined, password); + } + + public async updateWxUserId(wxUserId: string): Promise { + await api.updateUserinfo(undefined, undefined, undefined, wxUserId); + } +} + +const userService = new UserService(); + +export default userService; diff --git a/web/src/stores/appContext.ts b/web/src/stores/appContext.ts new file mode 100644 index 00000000..2027308d --- /dev/null +++ b/web/src/stores/appContext.ts @@ -0,0 +1,6 @@ +import { createContext } from "react"; +import appStore from "./appStore"; + +const appContext = createContext(appStore.getState()); + +export default appContext; diff --git a/web/src/stores/appStore.ts b/web/src/stores/appStore.ts new file mode 100644 index 00000000..6a5a22f2 --- /dev/null +++ b/web/src/stores/appStore.ts @@ -0,0 +1,36 @@ +import combineReducers from "../labs/combineReducers"; +import createStore from "../labs/createStore"; +import * as globalStore from "./globalStateStore"; +import * as locationStore from "./locationStore"; +import * as memoStore from "./memoStore"; +import * as userStore from "./userStore"; +import * as queryStore from "./queryStore"; + +interface AppState { + globalState: globalStore.State; + locationState: locationStore.State; + memoState: memoStore.State; + userState: userStore.State; + queryState: queryStore.State; +} + +type AppStateActions = globalStore.Actions | locationStore.Actions | memoStore.Actions | userStore.Actions | queryStore.Actions; + +const appStore = createStore( + { + globalState: globalStore.defaultState, + locationState: locationStore.defaultState, + memoState: memoStore.defaultState, + userState: userStore.defaultState, + queryState: queryStore.defaultState, + }, + combineReducers({ + globalState: globalStore.reducer, + locationState: locationStore.reducer, + memoState: memoStore.reducer, + userState: userStore.reducer, + queryState: queryStore.reducer, + }) +); + +export default appStore; diff --git a/web/src/stores/globalStateStore.ts b/web/src/stores/globalStateStore.ts new file mode 100644 index 00000000..88b0e6ea --- /dev/null +++ b/web/src/stores/globalStateStore.ts @@ -0,0 +1,113 @@ +export interface AppSetting { + shouldSplitMemoWord: boolean; + shouldHideImageUrl: boolean; + shouldUseMarkdownParser: boolean; + useTinyUndoHistoryCache: boolean; +} + +export interface State extends AppSetting { + markMemoId: string; + editMemoId: string; + isMobileView: boolean; + showSiderbarInMobileView: boolean; +} + +interface SetMarkMemoIdAction { + type: "SET_MARK_MEMO_ID"; + payload: { + markMemoId: string; + }; +} + +interface SetEditMemoIdAction { + type: "SET_EDIT_MEMO_ID"; + payload: { + editMemoId: string; + }; +} + +interface SetMobileViewAction { + type: "SET_MOBILE_VIEW"; + payload: { + isMobileView: boolean; + }; +} + +interface SetShowSidebarAction { + type: "SET_SHOW_SIDEBAR_IN_MOBILE_VIEW"; + payload: { + showSiderbarInMobileView: boolean; + }; +} + +interface SetAppSettingAction { + type: "SET_APP_SETTING"; + payload: Partial; +} + +export type Actions = SetMobileViewAction | SetShowSidebarAction | SetEditMemoIdAction | SetMarkMemoIdAction | SetAppSettingAction; + +export function reducer(state: State, action: Actions) { + switch (action.type) { + case "SET_MARK_MEMO_ID": { + if (action.payload.markMemoId === state.markMemoId) { + return state; + } + + return { + ...state, + markMemoId: action.payload.markMemoId, + }; + } + case "SET_EDIT_MEMO_ID": { + if (action.payload.editMemoId === state.editMemoId) { + return state; + } + + return { + ...state, + editMemoId: action.payload.editMemoId, + }; + } + case "SET_MOBILE_VIEW": { + if (action.payload.isMobileView === state.isMobileView) { + return state; + } + + return { + ...state, + isMobileView: action.payload.isMobileView, + }; + } + case "SET_SHOW_SIDEBAR_IN_MOBILE_VIEW": { + if (action.payload.showSiderbarInMobileView === state.showSiderbarInMobileView) { + return state; + } + + return { + ...state, + showSiderbarInMobileView: action.payload.showSiderbarInMobileView, + }; + } + case "SET_APP_SETTING": { + return { + ...state, + ...action.payload, + }; + } + default: { + return state; + } + } +} + +export const defaultState: State = { + markMemoId: "", + editMemoId: "", + shouldSplitMemoWord: true, + shouldHideImageUrl: true, + shouldUseMarkdownParser: true, + useTinyUndoHistoryCache: false, + isMobileView: false, + showSiderbarInMobileView: false, +}; diff --git a/web/src/stores/locationStore.ts b/web/src/stores/locationStore.ts new file mode 100644 index 00000000..c41461c5 --- /dev/null +++ b/web/src/stores/locationStore.ts @@ -0,0 +1,188 @@ +export type State = AppLocation; + +interface SetLocation { + type: "SET_LOCATION"; + payload: State; +} + +interface SetPathnameAction { + type: "SET_PATHNAME"; + payload: { + pathname: string; + }; +} + +interface SetQuery { + type: "SET_QUERY"; + payload: Query; +} + +interface SetQueryFilterAction { + type: "SET_QUERY_FILTER"; + payload: string; +} + +interface SetTagQueryAction { + type: "SET_TAG_QUERY"; + payload: { + tag: string; + }; +} + +interface SetFromAndToQueryAction { + type: "SET_DURATION_QUERY"; + payload: { + duration: Duration | null; + }; +} + +interface SetTypeAction { + type: "SET_TYPE"; + payload: { + type: MemoSpecType | ""; + }; +} + +interface SetTextAction { + type: "SET_TEXT"; + payload: { + text: string; + }; +} + +interface SetHashAction { + type: "SET_HASH"; + payload: { + hash: string; + }; +} + +export type Actions = + | SetLocation + | SetPathnameAction + | SetQuery + | SetTagQueryAction + | SetFromAndToQueryAction + | SetTypeAction + | SetTextAction + | SetQueryFilterAction + | SetHashAction; + +export function reducer(state: State, action: Actions) { + switch (action.type) { + case "SET_LOCATION": { + return action.payload; + } + case "SET_PATHNAME": { + if (action.payload.pathname === state.pathname) { + return state; + } + + return { + ...state, + pathname: action.payload.pathname, + }; + } + case "SET_HASH": { + if (action.payload.hash === state.hash) { + return state; + } + + return { + ...state, + hash: action.payload.hash, + }; + } + case "SET_QUERY": { + return { + ...state, + query: { + ...action.payload, + }, + }; + } + case "SET_TAG_QUERY": { + if (action.payload.tag === state.query.tag) { + return state; + } + + return { + ...state, + query: { + ...state.query, + tag: action.payload.tag, + }, + }; + } + case "SET_DURATION_QUERY": { + if (action.payload.duration === state.query.duration) { + return state; + } + + return { + ...state, + query: { + ...state.query, + duration: { + ...state.query.duration, + ...action.payload.duration, + }, + }, + }; + } + case "SET_TYPE": { + if (action.payload.type === state.query.type) { + return state; + } + + return { + ...state, + query: { + ...state.query, + type: action.payload.type, + }, + }; + } + case "SET_TEXT": { + if (action.payload.text === state.query.text) { + return state; + } + + return { + ...state, + query: { + ...state.query, + text: action.payload.text, + }, + }; + } + case "SET_QUERY_FILTER": { + if (action.payload === state.query.filter) { + return state; + } + + return { + ...state, + query: { + ...state.query, + filter: action.payload, + }, + }; + } + default: { + return state; + } + } +} + +export const defaultState: State = { + pathname: "/", + hash: "", + query: { + tag: "", + duration: null, + type: "", + text: "", + filter: "", + }, +}; diff --git a/web/src/stores/memoStore.ts b/web/src/stores/memoStore.ts new file mode 100644 index 00000000..13aae349 --- /dev/null +++ b/web/src/stores/memoStore.ts @@ -0,0 +1,103 @@ +import utils from "../helpers/utils"; + +export interface State { + memos: Model.Memo[]; + tags: string[]; +} + +interface SetMemosAction { + type: "SET_MEMOS"; + payload: { + memos: Model.Memo[]; + }; +} + +interface SetTagsAction { + type: "SET_TAGS"; + payload: { + tags: string[]; + }; +} + +interface InsertMemoAction { + type: "INSERT_MEMO"; + payload: { + memo: Model.Memo; + }; +} + +interface DeleteMemoByIdAction { + type: "DELETE_MEMO_BY_ID"; + payload: { + id: string; + }; +} + +interface EditMemoByIdAction { + type: "EDIT_MEMO"; + payload: Model.Memo; +} + +export type Actions = SetMemosAction | SetTagsAction | InsertMemoAction | DeleteMemoByIdAction | EditMemoByIdAction; + +export function reducer(state: State, action: Actions): State { + switch (action.type) { + case "SET_MEMOS": { + const memos = utils.dedupeObjectWithId( + action.payload.memos.sort((a, b) => utils.getTimeStampByDate(b.createdAt) - utils.getTimeStampByDate(a.createdAt)) + ); + + return { + ...state, + memos: [...memos], + }; + } + case "SET_TAGS": { + return { + ...state, + tags: action.payload.tags, + }; + } + case "INSERT_MEMO": { + const memos = utils.dedupeObjectWithId( + [action.payload.memo, ...state.memos].sort((a, b) => utils.getTimeStampByDate(b.createdAt) - utils.getTimeStampByDate(a.createdAt)) + ); + + return { + ...state, + memos, + }; + } + case "DELETE_MEMO_BY_ID": { + return { + ...state, + memos: [...state.memos].filter((memo) => memo.id !== action.payload.id), + }; + } + case "EDIT_MEMO": { + const memos = state.memos.map((m) => { + if (m.id === action.payload.id) { + return { + ...m, + ...action.payload, + }; + } else { + return m; + } + }); + + return { + ...state, + memos, + }; + } + default: { + return state; + } + } +} + +export const defaultState: State = { + memos: [], + tags: [], +}; diff --git a/web/src/stores/queryStore.ts b/web/src/stores/queryStore.ts new file mode 100644 index 00000000..e6e3c7fc --- /dev/null +++ b/web/src/stores/queryStore.ts @@ -0,0 +1,92 @@ +import utils from "../helpers/utils"; + +export interface State { + queries: Model.Query[]; +} + +interface SetQueries { + type: "SET_QUERIES"; + payload: { + queries: Model.Query[]; + }; +} + +interface InsertQueryAction { + type: "INSERT_QUERY"; + payload: { + query: Model.Query; + }; +} + +interface DeleteQueryByIdAction { + type: "DELETE_QUERY_BY_ID"; + payload: { + id: string; + }; +} + +interface UpdateQueryAction { + type: "UPDATE_QUERY"; + payload: Model.Query; +} + +export type Actions = SetQueries | InsertQueryAction | DeleteQueryByIdAction | UpdateQueryAction; + +export function reducer(state: State, action: Actions): State { + switch (action.type) { + case "SET_QUERIES": { + const queries = utils.dedupeObjectWithId( + action.payload.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)) + ); + + return { + ...state, + queries, + }; + } + case "INSERT_QUERY": { + const queries = utils.dedupeObjectWithId( + [action.payload.query, ...state.queries].sort( + (a, b) => utils.getTimeStampByDate(b.createdAt) - utils.getTimeStampByDate(a.createdAt) + ) + ); + + return { + ...state, + queries, + }; + } + case "DELETE_QUERY_BY_ID": { + return { + ...state, + queries: [...state.queries].filter((query) => query.id !== action.payload.id), + }; + } + case "UPDATE_QUERY": { + const queries = state.queries.map((m) => { + if (m.id === action.payload.id) { + return { + ...m, + ...action.payload, + }; + } else { + return m; + } + }); + + return { + ...state, + queries, + }; + } + default: { + return state; + } + } +} + +export const defaultState: State = { + queries: [], +}; diff --git a/web/src/stores/userStore.ts b/web/src/stores/userStore.ts new file mode 100644 index 00000000..49ecea7d --- /dev/null +++ b/web/src/stores/userStore.ts @@ -0,0 +1,35 @@ +export interface State { + user: Model.User | null; +} + +interface SignInAction { + type: "SIGN_IN"; + payload: State; +} + +interface SignOutAction { + type: "SIGN_OUT"; + payload: null; +} + +export type Actions = SignInAction | SignOutAction; + +export function reducer(state: State, action: Actions): State { + switch (action.type) { + case "SIGN_IN": { + return { + user: action.payload.user, + }; + } + case "SIGN_OUT": { + return { + user: null, + }; + } + default: { + return state; + } + } +} + +export const defaultState: State = { user: null }; diff --git a/web/src/types/api.d.ts b/web/src/types/api.d.ts new file mode 100644 index 00000000..e8e80525 --- /dev/null +++ b/web/src/types/api.d.ts @@ -0,0 +1,6 @@ +declare namespace Api { + interface MemosStat { + timestamp: string; + amount: number; + } +} diff --git a/web/src/types/basic.d.ts b/web/src/types/basic.d.ts new file mode 100644 index 00000000..f767bc01 --- /dev/null +++ b/web/src/types/basic.d.ts @@ -0,0 +1,13 @@ +type BasicType = undefined | null | boolean | number | string | Record | Array; + +// 日期戳 +type DateStamp = number; + +// 时间戳 +type TimeStamp = number; + +type FunctionType = (...args: unknown[]) => unknown; + +interface KVObject { + [key: string]: T; +} diff --git a/web/src/types/filter.d.ts b/web/src/types/filter.d.ts new file mode 100644 index 00000000..9c71f14c --- /dev/null +++ b/web/src/types/filter.d.ts @@ -0,0 +1,38 @@ +type MemoFilterRalation = "AND" | "OR"; + +interface BaseFilter { + type: FilterType; + value: { + operator: string; + value: string; + }; + relation: MemoFilterRalation; +} + +interface TagFilter extends BaseFilter { + type: "TAG"; + value: { + operator: "CONTAIN" | "NOT_CONTAIN"; + value: string; + }; +} + +interface TypeFilter extends BaseFilter { + type: "TYPE"; + value: { + operator: "IS" | "IS_NOT"; + value: MemoSpecType; + }; +} + +interface TextFilter extends BaseFilter { + type: "TEXT"; + value: { + operator: "CONTAIN" | "NOT_CONTAIN"; + value: string; + }; +} + +type FilterType = "TEXT" | "TYPE" | "TAG"; + +type Filter = BaseFilter | TagFilter | TypeFilter | TextFilter; diff --git a/web/src/types/location.d.ts b/web/src/types/location.d.ts new file mode 100644 index 00000000..aa76662f --- /dev/null +++ b/web/src/types/location.d.ts @@ -0,0 +1,20 @@ +interface Duration { + from: number; + to: number; +} + +interface Query { + tag: string; + duration: Duration | null; + type: MemoSpecType | ""; + text: string; + filter: string; +} + +type AppRouter = "/" | "/signin" | "/recycle" | "/setting"; + +interface AppLocation { + pathname: AppRouter; + hash: string; + query: Query; +} diff --git a/web/src/types/memo.d.ts b/web/src/types/memo.d.ts new file mode 100644 index 00000000..feb495b1 --- /dev/null +++ b/web/src/types/memo.d.ts @@ -0,0 +1 @@ +type MemoSpecType = "NOT_TAGGED" | "LINKED" | "IMAGED" | "CONNECTED"; diff --git a/web/src/types/models.d.ts b/web/src/types/models.d.ts new file mode 100644 index 00000000..070ab5af --- /dev/null +++ b/web/src/types/models.d.ts @@ -0,0 +1,24 @@ +declare namespace Model { + interface BaseModel { + id: string; + createdAt: string; + updatedAt: string; + } + + interface User extends BaseModel { + username: string; + githubName?: string; + wxUserId?: string; + } + + interface Memo extends BaseModel { + content: string; + deletedAt?: string; + } + + interface Query extends BaseModel { + title: string; + querystring: string; + pinnedAt?: string; + } +} diff --git a/web/src/types/view.d.ts b/web/src/types/view.d.ts new file mode 100644 index 00000000..6309a030 --- /dev/null +++ b/web/src/types/view.d.ts @@ -0,0 +1,12 @@ +interface DialogProps { + destroy: FunctionType; +} + +interface DialogCallback { + destroy: FunctionType; +} + +interface FormattedMemo extends Model.Memo { + createdAtStr: string; + deletedAtStr?: string; +} diff --git a/web/tsconfig.json b/web/tsconfig.json new file mode 100644 index 00000000..10156802 --- /dev/null +++ b/web/tsconfig.json @@ -0,0 +1,20 @@ +{ + "compilerOptions": { + "target": "ESNext", + "lib": ["DOM", "DOM.Iterable", "ESNext"], + "types": ["vite/client"], + "allowJs": false, + "skipLibCheck": false, + "esModuleInterop": false, + "allowSyntheticDefaultImports": true, + "strict": true, + "forceConsistentCasingInFileNames": true, + "module": "ESNext", + "moduleResolution": "Node", + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true, + "jsx": "react-jsx" + }, + "include": ["./src", "./public"] +} diff --git a/web/vite.config.ts b/web/vite.config.ts new file mode 100644 index 00000000..dfc20051 --- /dev/null +++ b/web/vite.config.ts @@ -0,0 +1,17 @@ +import { defineConfig } from "vite"; +import react from "@vitejs/plugin-react"; + +// https://vitejs.dev/config/ +export default defineConfig({ + plugins: [react()], + server: { + cors: true, + proxy: { + "/api": { + // target: "http://localhost:8080/", + target: "https://memos.justsven.top/", + changeOrigin: true, + }, + }, + }, +}); diff --git a/web/yarn.lock b/web/yarn.lock new file mode 100644 index 00000000..1c7dfe12 --- /dev/null +++ b/web/yarn.lock @@ -0,0 +1,840 @@ +# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. +# yarn lockfile v1 + + +"@babel/code-frame@^7.16.0": + version "7.16.0" + resolved "https://registry.yarnpkg.com/@babel/code-frame/-/code-frame-7.16.0.tgz#0dfc80309beec8411e65e706461c408b0bb9b431" + integrity sha512-IF4EOMEV+bfYwOmNxGzSnjR2EmQod7f1UXOpZM3l4i4o4QNwzjtJAu/HxdjHq0aYBvdqMuQEY1eg0nqW9ZPORA== + dependencies: + "@babel/highlight" "^7.16.0" + +"@babel/compat-data@^7.16.0": + version "7.16.4" + resolved "https://registry.yarnpkg.com/@babel/compat-data/-/compat-data-7.16.4.tgz#081d6bbc336ec5c2435c6346b2ae1fb98b5ac68e" + integrity sha512-1o/jo7D+kC9ZjHX5v+EHrdjl3PhxMrLSOTGsOdHJ+KL8HCaEK6ehrVL2RS6oHDZp+L7xLirLrPmQtEng769J/Q== + +"@babel/core@^7.15.5": + version "7.16.0" + resolved "https://registry.yarnpkg.com/@babel/core/-/core-7.16.0.tgz#c4ff44046f5fe310525cc9eb4ef5147f0c5374d4" + integrity sha512-mYZEvshBRHGsIAiyH5PzCFTCfbWfoYbO/jcSdXQSUQu1/pW0xDZAUP7KEc32heqWTAfAHhV9j1vH8Sav7l+JNQ== + dependencies: + "@babel/code-frame" "^7.16.0" + "@babel/generator" "^7.16.0" + "@babel/helper-compilation-targets" "^7.16.0" + "@babel/helper-module-transforms" "^7.16.0" + "@babel/helpers" "^7.16.0" + "@babel/parser" "^7.16.0" + "@babel/template" "^7.16.0" + "@babel/traverse" "^7.16.0" + "@babel/types" "^7.16.0" + convert-source-map "^1.7.0" + debug "^4.1.0" + gensync "^1.0.0-beta.2" + json5 "^2.1.2" + semver "^6.3.0" + source-map "^0.5.0" + +"@babel/generator@^7.16.0": + version "7.16.0" + resolved "https://registry.yarnpkg.com/@babel/generator/-/generator-7.16.0.tgz#d40f3d1d5075e62d3500bccb67f3daa8a95265b2" + integrity sha512-RR8hUCfRQn9j9RPKEVXo9LiwoxLPYn6hNZlvUOR8tSnaxlD0p0+la00ZP9/SnRt6HchKr+X0fO2r8vrETiJGew== + dependencies: + "@babel/types" "^7.16.0" + jsesc "^2.5.1" + source-map "^0.5.0" + +"@babel/helper-annotate-as-pure@^7.16.0": + version "7.16.0" + resolved "https://registry.yarnpkg.com/@babel/helper-annotate-as-pure/-/helper-annotate-as-pure-7.16.0.tgz#9a1f0ebcda53d9a2d00108c4ceace6a5d5f1f08d" + integrity sha512-ItmYF9vR4zA8cByDocY05o0LGUkp1zhbTQOH1NFyl5xXEqlTJQCEJjieriw+aFpxo16swMxUnUiKS7a/r4vtHg== + dependencies: + "@babel/types" "^7.16.0" + +"@babel/helper-compilation-targets@^7.16.0": + version "7.16.3" + resolved "https://registry.yarnpkg.com/@babel/helper-compilation-targets/-/helper-compilation-targets-7.16.3.tgz#5b480cd13f68363df6ec4dc8ac8e2da11363cbf0" + integrity sha512-vKsoSQAyBmxS35JUOOt+07cLc6Nk/2ljLIHwmq2/NM6hdioUaqEXq/S+nXvbvXbZkNDlWOymPanJGOc4CBjSJA== + dependencies: + "@babel/compat-data" "^7.16.0" + "@babel/helper-validator-option" "^7.14.5" + browserslist "^4.17.5" + semver "^6.3.0" + +"@babel/helper-function-name@^7.16.0": + version "7.16.0" + resolved "https://registry.yarnpkg.com/@babel/helper-function-name/-/helper-function-name-7.16.0.tgz#b7dd0797d00bbfee4f07e9c4ea5b0e30c8bb1481" + integrity sha512-BZh4mEk1xi2h4HFjWUXRQX5AEx4rvaZxHgax9gcjdLWdkjsY7MKt5p0otjsg5noXw+pB+clMCjw+aEVYADMjog== + dependencies: + "@babel/helper-get-function-arity" "^7.16.0" + "@babel/template" "^7.16.0" + "@babel/types" "^7.16.0" + +"@babel/helper-get-function-arity@^7.16.0": + version "7.16.0" + resolved "https://registry.yarnpkg.com/@babel/helper-get-function-arity/-/helper-get-function-arity-7.16.0.tgz#0088c7486b29a9cb5d948b1a1de46db66e089cfa" + integrity sha512-ASCquNcywC1NkYh/z7Cgp3w31YW8aojjYIlNg4VeJiHkqyP4AzIvr4qx7pYDb4/s8YcsZWqqOSxgkvjUz1kpDQ== + dependencies: + "@babel/types" "^7.16.0" + +"@babel/helper-hoist-variables@^7.16.0": + version "7.16.0" + resolved "https://registry.yarnpkg.com/@babel/helper-hoist-variables/-/helper-hoist-variables-7.16.0.tgz#4c9023c2f1def7e28ff46fc1dbcd36a39beaa81a" + integrity sha512-1AZlpazjUR0EQZQv3sgRNfM9mEVWPK3M6vlalczA+EECcPz3XPh6VplbErL5UoMpChhSck5wAJHthlj1bYpcmg== + dependencies: + "@babel/types" "^7.16.0" + +"@babel/helper-member-expression-to-functions@^7.16.0": + version "7.16.0" + resolved "https://registry.yarnpkg.com/@babel/helper-member-expression-to-functions/-/helper-member-expression-to-functions-7.16.0.tgz#29287040efd197c77636ef75188e81da8bccd5a4" + integrity sha512-bsjlBFPuWT6IWhl28EdrQ+gTvSvj5tqVP5Xeftp07SEuz5pLnsXZuDkDD3Rfcxy0IsHmbZ+7B2/9SHzxO0T+sQ== + dependencies: + "@babel/types" "^7.16.0" + +"@babel/helper-module-imports@^7.16.0": + version "7.16.0" + resolved "https://registry.yarnpkg.com/@babel/helper-module-imports/-/helper-module-imports-7.16.0.tgz#90538e60b672ecf1b448f5f4f5433d37e79a3ec3" + integrity sha512-kkH7sWzKPq0xt3H1n+ghb4xEMP8k0U7XV3kkB+ZGy69kDk2ySFW1qPi06sjKzFY3t1j6XbJSqr4mF9L7CYVyhg== + dependencies: + "@babel/types" "^7.16.0" + +"@babel/helper-module-transforms@^7.16.0": + version "7.16.0" + resolved "https://registry.yarnpkg.com/@babel/helper-module-transforms/-/helper-module-transforms-7.16.0.tgz#1c82a8dd4cb34577502ebd2909699b194c3e9bb5" + integrity sha512-My4cr9ATcaBbmaEa8M0dZNA74cfI6gitvUAskgDtAFmAqyFKDSHQo5YstxPbN+lzHl2D9l/YOEFqb2mtUh4gfA== + dependencies: + "@babel/helper-module-imports" "^7.16.0" + "@babel/helper-replace-supers" "^7.16.0" + "@babel/helper-simple-access" "^7.16.0" + "@babel/helper-split-export-declaration" "^7.16.0" + "@babel/helper-validator-identifier" "^7.15.7" + "@babel/template" "^7.16.0" + "@babel/traverse" "^7.16.0" + "@babel/types" "^7.16.0" + +"@babel/helper-optimise-call-expression@^7.16.0": + version "7.16.0" + resolved "https://registry.yarnpkg.com/@babel/helper-optimise-call-expression/-/helper-optimise-call-expression-7.16.0.tgz#cecdb145d70c54096b1564f8e9f10cd7d193b338" + integrity sha512-SuI467Gi2V8fkofm2JPnZzB/SUuXoJA5zXe/xzyPP2M04686RzFKFHPK6HDVN6JvWBIEW8tt9hPR7fXdn2Lgpw== + dependencies: + "@babel/types" "^7.16.0" + +"@babel/helper-plugin-utils@^7.14.5": + version "7.14.5" + resolved "https://registry.yarnpkg.com/@babel/helper-plugin-utils/-/helper-plugin-utils-7.14.5.tgz#5ac822ce97eec46741ab70a517971e443a70c5a9" + integrity sha512-/37qQCE3K0vvZKwoK4XU/irIJQdIfCJuhU5eKnNxpFDsOkgFaUAwbv+RYw6eYgsC0E4hS7r5KqGULUogqui0fQ== + +"@babel/helper-replace-supers@^7.16.0": + version "7.16.0" + resolved "https://registry.yarnpkg.com/@babel/helper-replace-supers/-/helper-replace-supers-7.16.0.tgz#73055e8d3cf9bcba8ddb55cad93fedc860f68f17" + integrity sha512-TQxuQfSCdoha7cpRNJvfaYxxxzmbxXw/+6cS7V02eeDYyhxderSoMVALvwupA54/pZcOTtVeJ0xccp1nGWladA== + dependencies: + "@babel/helper-member-expression-to-functions" "^7.16.0" + "@babel/helper-optimise-call-expression" "^7.16.0" + "@babel/traverse" "^7.16.0" + "@babel/types" "^7.16.0" + +"@babel/helper-simple-access@^7.16.0": + version "7.16.0" + resolved "https://registry.yarnpkg.com/@babel/helper-simple-access/-/helper-simple-access-7.16.0.tgz#21d6a27620e383e37534cf6c10bba019a6f90517" + integrity sha512-o1rjBT/gppAqKsYfUdfHq5Rk03lMQrkPHG1OWzHWpLgVXRH4HnMM9Et9CVdIqwkCQlobnGHEJMsgWP/jE1zUiw== + dependencies: + "@babel/types" "^7.16.0" + +"@babel/helper-split-export-declaration@^7.16.0": + version "7.16.0" + resolved "https://registry.yarnpkg.com/@babel/helper-split-export-declaration/-/helper-split-export-declaration-7.16.0.tgz#29672f43663e936df370aaeb22beddb3baec7438" + integrity sha512-0YMMRpuDFNGTHNRiiqJX19GjNXA4H0E8jZ2ibccfSxaCogbm3am5WN/2nQNj0YnQwGWM1J06GOcQ2qnh3+0paw== + dependencies: + "@babel/types" "^7.16.0" + +"@babel/helper-validator-identifier@^7.15.7": + version "7.15.7" + resolved "https://registry.yarnpkg.com/@babel/helper-validator-identifier/-/helper-validator-identifier-7.15.7.tgz#220df993bfe904a4a6b02ab4f3385a5ebf6e2389" + integrity sha512-K4JvCtQqad9OY2+yTU8w+E82ywk/fe+ELNlt1G8z3bVGlZfn/hOcQQsUhGhW/N+tb3fxK800wLtKOE/aM0m72w== + +"@babel/helper-validator-option@^7.14.5": + version "7.14.5" + resolved "https://registry.yarnpkg.com/@babel/helper-validator-option/-/helper-validator-option-7.14.5.tgz#6e72a1fff18d5dfcb878e1e62f1a021c4b72d5a3" + integrity sha512-OX8D5eeX4XwcroVW45NMvoYaIuFI+GQpA2a8Gi+X/U/cDUIRsV37qQfF905F0htTRCREQIB4KqPeaveRJUl3Ow== + +"@babel/helpers@^7.16.0": + version "7.16.3" + resolved "https://registry.yarnpkg.com/@babel/helpers/-/helpers-7.16.3.tgz#27fc64f40b996e7074dc73128c3e5c3e7f55c43c" + integrity sha512-Xn8IhDlBPhvYTvgewPKawhADichOsbkZuzN7qz2BusOM0brChsyXMDJvldWaYMMUNiCQdQzNEioXTp3sC8Nt8w== + dependencies: + "@babel/template" "^7.16.0" + "@babel/traverse" "^7.16.3" + "@babel/types" "^7.16.0" + +"@babel/highlight@^7.16.0": + version "7.16.0" + resolved "https://registry.yarnpkg.com/@babel/highlight/-/highlight-7.16.0.tgz#6ceb32b2ca4b8f5f361fb7fd821e3fddf4a1725a" + integrity sha512-t8MH41kUQylBtu2+4IQA3atqevA2lRgqA2wyVB/YiWmsDSuylZZuXOUy9ric30hfzauEFfdsuk/eXTRrGrfd0g== + dependencies: + "@babel/helper-validator-identifier" "^7.15.7" + chalk "^2.0.0" + js-tokens "^4.0.0" + +"@babel/parser@^7.16.0", "@babel/parser@^7.16.3": + version "7.16.4" + resolved "https://registry.yarnpkg.com/@babel/parser/-/parser-7.16.4.tgz#d5f92f57cf2c74ffe9b37981c0e72fee7311372e" + integrity sha512-6V0qdPUaiVHH3RtZeLIsc+6pDhbYzHR8ogA8w+f+Wc77DuXto19g2QUwveINoS34Uw+W8/hQDGJCx+i4n7xcng== + +"@babel/plugin-syntax-jsx@^7.16.0": + version "7.16.0" + resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-jsx/-/plugin-syntax-jsx-7.16.0.tgz#f9624394317365a9a88c82358d3f8471154698f1" + integrity sha512-8zv2+xiPHwly31RK4RmnEYY5zziuF3O7W2kIDW+07ewWDh6Oi0dRq8kwvulRkFgt6DB97RlKs5c1y068iPlCUg== + dependencies: + "@babel/helper-plugin-utils" "^7.14.5" + +"@babel/plugin-transform-react-jsx-development@^7.14.5": + version "7.16.0" + resolved "https://registry.yarnpkg.com/@babel/plugin-transform-react-jsx-development/-/plugin-transform-react-jsx-development-7.16.0.tgz#1cb52874678d23ab11d0d16488d54730807303ef" + integrity sha512-qq65iSqBRq0Hr3wq57YG2AmW0H6wgTnIzpffTphrUWUgLCOK+zf1f7G0vuOiXrp7dU1qq+fQBoqZ3wCDAkhFzw== + dependencies: + "@babel/plugin-transform-react-jsx" "^7.16.0" + +"@babel/plugin-transform-react-jsx-self@^7.14.9": + version "7.16.0" + resolved "https://registry.yarnpkg.com/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.16.0.tgz#09202158abbc716a08330f392bfb98d6b9acfa0c" + integrity sha512-97yCFY+2GvniqOThOSjPor8xUoDiQ0STVWAQMl3pjhJoFVe5DuXDLZCRSZxu9clx+oRCbTiXGgKEG/Yoyo6Y+w== + dependencies: + "@babel/helper-plugin-utils" "^7.14.5" + +"@babel/plugin-transform-react-jsx-source@^7.14.5": + version "7.16.0" + resolved "https://registry.yarnpkg.com/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.16.0.tgz#d40c959d7803aae38224594585748693e84c0a22" + integrity sha512-8yvbGGrHOeb/oyPc9tzNoe9/lmIjz3HLa9Nc5dMGDyNpGjfFrk8D2KdEq9NRkftZzeoQEW6yPQ29TMZtrLiUUA== + dependencies: + "@babel/helper-plugin-utils" "^7.14.5" + +"@babel/plugin-transform-react-jsx@^7.14.9", "@babel/plugin-transform-react-jsx@^7.16.0": + version "7.16.0" + resolved "https://registry.yarnpkg.com/@babel/plugin-transform-react-jsx/-/plugin-transform-react-jsx-7.16.0.tgz#55b797d4960c3de04e07ad1c0476e2bc6a4889f1" + integrity sha512-rqDgIbukZ44pqq7NIRPGPGNklshPkvlmvqjdx3OZcGPk4zGIenYkxDTvl3LsSL8gqcc3ZzGmXPE6hR/u/voNOw== + dependencies: + "@babel/helper-annotate-as-pure" "^7.16.0" + "@babel/helper-module-imports" "^7.16.0" + "@babel/helper-plugin-utils" "^7.14.5" + "@babel/plugin-syntax-jsx" "^7.16.0" + "@babel/types" "^7.16.0" + +"@babel/template@^7.16.0": + version "7.16.0" + resolved "https://registry.yarnpkg.com/@babel/template/-/template-7.16.0.tgz#d16a35ebf4cd74e202083356fab21dd89363ddd6" + integrity sha512-MnZdpFD/ZdYhXwiunMqqgyZyucaYsbL0IrjoGjaVhGilz+x8YB++kRfygSOIj1yOtWKPlx7NBp+9I1RQSgsd5A== + dependencies: + "@babel/code-frame" "^7.16.0" + "@babel/parser" "^7.16.0" + "@babel/types" "^7.16.0" + +"@babel/traverse@^7.16.0", "@babel/traverse@^7.16.3": + version "7.16.3" + resolved "https://registry.yarnpkg.com/@babel/traverse/-/traverse-7.16.3.tgz#f63e8a938cc1b780f66d9ed3c54f532ca2d14787" + integrity sha512-eolumr1vVMjqevCpwVO99yN/LoGL0EyHiLO5I043aYQvwOJ9eR5UsZSClHVCzfhBduMAsSzgA/6AyqPjNayJag== + dependencies: + "@babel/code-frame" "^7.16.0" + "@babel/generator" "^7.16.0" + "@babel/helper-function-name" "^7.16.0" + "@babel/helper-hoist-variables" "^7.16.0" + "@babel/helper-split-export-declaration" "^7.16.0" + "@babel/parser" "^7.16.3" + "@babel/types" "^7.16.0" + debug "^4.1.0" + globals "^11.1.0" + +"@babel/types@^7.16.0": + version "7.16.0" + resolved "https://registry.yarnpkg.com/@babel/types/-/types-7.16.0.tgz#db3b313804f96aadd0b776c4823e127ad67289ba" + integrity sha512-PJgg/k3SdLsGb3hhisFvtLOw5ts113klrpLuIPtCJIU+BB24fqq6lf8RWqKJEjzqXR9AEH1rIb5XTqwBHB+kQg== + dependencies: + "@babel/helper-validator-identifier" "^7.15.7" + to-fast-properties "^2.0.0" + +"@rollup/pluginutils@^4.1.1": + version "4.1.1" + resolved "https://registry.yarnpkg.com/@rollup/pluginutils/-/pluginutils-4.1.1.tgz#1d4da86dd4eded15656a57d933fda2b9a08d47ec" + integrity sha512-clDjivHqWGXi7u+0d2r2sBi4Ie6VLEAzWMIkvJLnDmxoOhBYOTfzGbOQBA32THHm11/LiJbd01tJUpJsbshSWQ== + dependencies: + estree-walker "^2.0.1" + picomatch "^2.2.2" + +"@types/prismjs@^1.16.6": + version "1.16.6" + resolved "http://npm.corp.ebay.com/@types/prismjs/-/prismjs-1.16.6.tgz#377054f72f671b36dbe78c517ce2b279d83ecc40" + integrity sha1-N3BU9y9nGzbb54xRfOKyedg+zEA= + +"@types/prop-types@*": + version "15.7.4" + resolved "https://registry.yarnpkg.com/@types/prop-types/-/prop-types-15.7.4.tgz#fcf7205c25dff795ee79af1e30da2c9790808f11" + integrity sha512-rZ5drC/jWjrArrS8BR6SIr4cWpW09RNTYt9AMZo3Jwwif+iacXAqgVjm0B0Bv/S1jhDXKHqRVNCbACkJ89RAnQ== + +"@types/react-dom@^17.0.2": + version "17.0.11" + resolved "https://registry.yarnpkg.com/@types/react-dom/-/react-dom-17.0.11.tgz#e1eadc3c5e86bdb5f7684e00274ae228e7bcc466" + integrity sha512-f96K3k+24RaLGVu/Y2Ng3e1EbZ8/cVJvypZWd7cy0ofCBaf2lcM46xNhycMZ2xGwbBjRql7hOlZ+e2WlJ5MH3Q== + dependencies: + "@types/react" "*" + +"@types/react@*", "@types/react@^17.0.2": + version "17.0.35" + resolved "https://registry.yarnpkg.com/@types/react/-/react-17.0.35.tgz#217164cf830267d56cd1aec09dcf25a541eedd4c" + integrity sha512-r3C8/TJuri/SLZiiwwxQoLAoavaczARfT9up9b4Jr65+ErAUX3MIkU0oMOQnrpfgHme8zIqZLX7O5nnjm5Wayw== + dependencies: + "@types/prop-types" "*" + "@types/scheduler" "*" + csstype "^3.0.2" + +"@types/scheduler@*": + version "0.16.2" + resolved "https://registry.yarnpkg.com/@types/scheduler/-/scheduler-0.16.2.tgz#1a62f89525723dde24ba1b01b092bf5df8ad4d39" + integrity sha512-hppQEBDmlwhFAXKJX2KnWLYu5yMfi91yazPb2l+lbJiwW+wdo1gNeRA+3RgNSO39WYX2euey41KEwnqesU2Jew== + +"@vitejs/plugin-react@^1.0.0": + version "1.0.9" + resolved "https://registry.yarnpkg.com/@vitejs/plugin-react/-/plugin-react-1.0.9.tgz#3166e82cc986512c2e0411138305468488704e86" + integrity sha512-1iTS/c3z4QWj8aXIItp6zFMI08UQEz5+fGvnahSCFOSIfazKDlCTEUUQJP23zoxFjeKOF+M3/WA0ZatcHUVEqg== + dependencies: + "@babel/core" "^7.15.5" + "@babel/plugin-transform-react-jsx" "^7.14.9" + "@babel/plugin-transform-react-jsx-development" "^7.14.5" + "@babel/plugin-transform-react-jsx-self" "^7.14.9" + "@babel/plugin-transform-react-jsx-source" "^7.14.5" + "@rollup/pluginutils" "^4.1.1" + react-refresh "^0.10.0" + resolve "^1.20.0" + +ansi-styles@^3.2.1: + version "3.2.1" + resolved "https://registry.yarnpkg.com/ansi-styles/-/ansi-styles-3.2.1.tgz#41fbb20243e50b12be0f04b8dedbf07520ce841d" + integrity sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA== + dependencies: + color-convert "^1.9.0" + +browserslist@^4.17.5: + version "4.18.1" + resolved "https://registry.yarnpkg.com/browserslist/-/browserslist-4.18.1.tgz#60d3920f25b6860eb917c6c7b185576f4d8b017f" + integrity sha512-8ScCzdpPwR2wQh8IT82CA2VgDwjHyqMovPBZSNH54+tm4Jk2pCuv90gmAdH6J84OCRWi0b4gMe6O6XPXuJnjgQ== + dependencies: + caniuse-lite "^1.0.30001280" + electron-to-chromium "^1.3.896" + escalade "^3.1.1" + node-releases "^2.0.1" + picocolors "^1.0.0" + +caniuse-lite@^1.0.30001280: + version "1.0.30001282" + resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001282.tgz#38c781ee0a90ccfe1fe7fefd00e43f5ffdcb96fd" + integrity sha512-YhF/hG6nqBEllymSIjLtR2iWDDnChvhnVJqp+vloyt2tEHFG1yBR+ac2B/rOw0qOK0m0lEXU2dv4E/sMk5P9Kg== + +chalk@^2.0.0: + version "2.4.2" + resolved "https://registry.yarnpkg.com/chalk/-/chalk-2.4.2.tgz#cd42541677a54333cf541a49108c1432b44c9424" + integrity sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ== + dependencies: + ansi-styles "^3.2.1" + escape-string-regexp "^1.0.5" + supports-color "^5.3.0" + +color-convert@^1.9.0: + version "1.9.3" + resolved "https://registry.yarnpkg.com/color-convert/-/color-convert-1.9.3.tgz#bb71850690e1f136567de629d2d5471deda4c1e8" + integrity sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg== + dependencies: + color-name "1.1.3" + +color-name@1.1.3: + version "1.1.3" + resolved "https://registry.yarnpkg.com/color-name/-/color-name-1.1.3.tgz#a7d0558bd89c42f795dd42328f740831ca53bc25" + integrity sha1-p9BVi9icQveV3UIyj3QIMcpTvCU= + +convert-source-map@^1.7.0: + version "1.8.0" + resolved "https://registry.yarnpkg.com/convert-source-map/-/convert-source-map-1.8.0.tgz#f3373c32d21b4d780dd8004514684fb791ca4369" + integrity sha512-+OQdjP49zViI/6i7nIJpA8rAl4sV/JdPfU9nZs3VqOwGIgizICvuN2ru6fMd+4llL0tar18UYJXfZ/TWtmhUjA== + dependencies: + safe-buffer "~5.1.1" + +copy-anything@^2.0.1: + version "2.0.3" + resolved "https://registry.yarnpkg.com/copy-anything/-/copy-anything-2.0.3.tgz#842407ba02466b0df844819bbe3baebbe5d45d87" + integrity sha512-GK6QUtisv4fNS+XcI7shX0Gx9ORg7QqIznyfho79JTnX1XhLiyZHfftvGiziqzRiEi/Bjhgpi+D2o7HxJFPnDQ== + dependencies: + is-what "^3.12.0" + +csstype@^3.0.2: + version "3.0.10" + resolved "https://registry.yarnpkg.com/csstype/-/csstype-3.0.10.tgz#2ad3a7bed70f35b965707c092e5f30b327c290e5" + integrity sha512-2u44ZG2OcNUO9HDp/Jl8C07x6pU/eTR3ncV91SiK3dhG9TWvRVsCoJw14Ckx5DgWkzGA3waZWO3d7pgqpUI/XA== + +debug@^3.2.6: + version "3.2.7" + resolved "https://registry.yarnpkg.com/debug/-/debug-3.2.7.tgz#72580b7e9145fb39b6676f9c5e5fb100b934179a" + integrity sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ== + dependencies: + ms "^2.1.1" + +debug@^4.1.0: + version "4.3.2" + resolved "https://registry.yarnpkg.com/debug/-/debug-4.3.2.tgz#f0a49c18ac8779e31d4a0c6029dfb76873c7428b" + integrity sha512-mOp8wKcvj7XxC78zLgw/ZA+6TSgkoE2C/ienthhRD298T7UNwAg9diBpLRxC0mOezLl4B0xV7M0cCO6P/O0Xhw== + dependencies: + ms "2.1.2" + +electron-to-chromium@^1.3.896: + version "1.3.904" + resolved "https://registry.yarnpkg.com/electron-to-chromium/-/electron-to-chromium-1.3.904.tgz#52a353994faeb0f2a9fab3606b4e0614d1af7b58" + integrity sha512-x5uZWXcVNYkTh4JubD7KSC1VMKz0vZwJUqVwY3ihsW0bst1BXDe494Uqbg3Y0fDGVjJqA8vEeGuvO5foyH2+qw== + +errno@^0.1.1: + version "0.1.8" + resolved "https://registry.yarnpkg.com/errno/-/errno-0.1.8.tgz#8bb3e9c7d463be4976ff888f76b4809ebc2e811f" + integrity sha512-dJ6oBr5SQ1VSd9qkk7ByRgb/1SH4JZjCHSW/mr63/QcXO9zLVxvJ6Oy13nio03rxpSnVDDjFor75SjVeZWPW/A== + dependencies: + prr "~1.0.1" + +esbuild-android-arm64@0.13.15: + version "0.13.15" + resolved "https://registry.yarnpkg.com/esbuild-android-arm64/-/esbuild-android-arm64-0.13.15.tgz#3fc3ff0bab76fe35dd237476b5d2b32bb20a3d44" + integrity sha512-m602nft/XXeO8YQPUDVoHfjyRVPdPgjyyXOxZ44MK/agewFFkPa8tUo6lAzSWh5Ui5PB4KR9UIFTSBKh/RrCmg== + +esbuild-darwin-64@0.13.15: + version "0.13.15" + resolved "https://registry.yarnpkg.com/esbuild-darwin-64/-/esbuild-darwin-64-0.13.15.tgz#8e9169c16baf444eacec60d09b24d11b255a8e72" + integrity sha512-ihOQRGs2yyp7t5bArCwnvn2Atr6X4axqPpEdCFPVp7iUj4cVSdisgvEKdNR7yH3JDjW6aQDw40iQFoTqejqxvQ== + +esbuild-darwin-arm64@0.13.15: + version "0.13.15" + resolved "https://registry.yarnpkg.com/esbuild-darwin-arm64/-/esbuild-darwin-arm64-0.13.15.tgz#1b07f893b632114f805e188ddfca41b2b778229a" + integrity sha512-i1FZssTVxUqNlJ6cBTj5YQj4imWy3m49RZRnHhLpefFIh0To05ow9DTrXROTE1urGTQCloFUXTX8QfGJy1P8dQ== + +esbuild-freebsd-64@0.13.15: + version "0.13.15" + resolved "https://registry.yarnpkg.com/esbuild-freebsd-64/-/esbuild-freebsd-64-0.13.15.tgz#0b8b7eca1690c8ec94c75680c38c07269c1f4a85" + integrity sha512-G3dLBXUI6lC6Z09/x+WtXBXbOYQZ0E8TDBqvn7aMaOCzryJs8LyVXKY4CPnHFXZAbSwkCbqiPuSQ1+HhrNk7EA== + +esbuild-freebsd-arm64@0.13.15: + version "0.13.15" + resolved "https://registry.yarnpkg.com/esbuild-freebsd-arm64/-/esbuild-freebsd-arm64-0.13.15.tgz#2e1a6c696bfdcd20a99578b76350b41db1934e52" + integrity sha512-KJx0fzEDf1uhNOZQStV4ujg30WlnwqUASaGSFPhznLM/bbheu9HhqZ6mJJZM32lkyfGJikw0jg7v3S0oAvtvQQ== + +esbuild-linux-32@0.13.15: + version "0.13.15" + resolved "https://registry.yarnpkg.com/esbuild-linux-32/-/esbuild-linux-32-0.13.15.tgz#6fd39f36fc66dd45b6b5f515728c7bbebc342a69" + integrity sha512-ZvTBPk0YWCLMCXiFmD5EUtB30zIPvC5Itxz0mdTu/xZBbbHJftQgLWY49wEPSn2T/TxahYCRDWun5smRa0Tu+g== + +esbuild-linux-64@0.13.15: + version "0.13.15" + resolved "https://registry.yarnpkg.com/esbuild-linux-64/-/esbuild-linux-64-0.13.15.tgz#9cb8e4bcd7574e67946e4ee5f1f1e12386bb6dd3" + integrity sha512-eCKzkNSLywNeQTRBxJRQ0jxRCl2YWdMB3+PkWFo2BBQYC5mISLIVIjThNtn6HUNqua1pnvgP5xX0nHbZbPj5oA== + +esbuild-linux-arm64@0.13.15: + version "0.13.15" + resolved "https://registry.yarnpkg.com/esbuild-linux-arm64/-/esbuild-linux-arm64-0.13.15.tgz#3891aa3704ec579a1b92d2a586122e5b6a2bfba1" + integrity sha512-bYpuUlN6qYU9slzr/ltyLTR9YTBS7qUDymO8SV7kjeNext61OdmqFAzuVZom+OLW1HPHseBfJ/JfdSlx8oTUoA== + +esbuild-linux-arm@0.13.15: + version "0.13.15" + resolved "https://registry.yarnpkg.com/esbuild-linux-arm/-/esbuild-linux-arm-0.13.15.tgz#8a00e99e6a0c6c9a6b7f334841364d8a2b4aecfe" + integrity sha512-wUHttDi/ol0tD8ZgUMDH8Ef7IbDX+/UsWJOXaAyTdkT7Yy9ZBqPg8bgB/Dn3CZ9SBpNieozrPRHm0BGww7W/jA== + +esbuild-linux-mips64le@0.13.15: + version "0.13.15" + resolved "https://registry.yarnpkg.com/esbuild-linux-mips64le/-/esbuild-linux-mips64le-0.13.15.tgz#36b07cc47c3d21e48db3bb1f4d9ef8f46aead4f7" + integrity sha512-KlVjIG828uFPyJkO/8gKwy9RbXhCEUeFsCGOJBepUlpa7G8/SeZgncUEz/tOOUJTcWMTmFMtdd3GElGyAtbSWg== + +esbuild-linux-ppc64le@0.13.15: + version "0.13.15" + resolved "https://registry.yarnpkg.com/esbuild-linux-ppc64le/-/esbuild-linux-ppc64le-0.13.15.tgz#f7e6bba40b9a11eb9dcae5b01550ea04670edad2" + integrity sha512-h6gYF+OsaqEuBjeesTBtUPw0bmiDu7eAeuc2OEH9S6mV9/jPhPdhOWzdeshb0BskRZxPhxPOjqZ+/OqLcxQwEQ== + +esbuild-netbsd-64@0.13.15: + version "0.13.15" + resolved "https://registry.yarnpkg.com/esbuild-netbsd-64/-/esbuild-netbsd-64-0.13.15.tgz#a2fedc549c2b629d580a732d840712b08d440038" + integrity sha512-3+yE9emwoevLMyvu+iR3rsa+Xwhie7ZEHMGDQ6dkqP/ndFzRHkobHUKTe+NCApSqG5ce2z4rFu+NX/UHnxlh3w== + +esbuild-openbsd-64@0.13.15: + version "0.13.15" + resolved "https://registry.yarnpkg.com/esbuild-openbsd-64/-/esbuild-openbsd-64-0.13.15.tgz#b22c0e5806d3a1fbf0325872037f885306b05cd7" + integrity sha512-wTfvtwYJYAFL1fSs8yHIdf5GEE4NkbtbXtjLWjM3Cw8mmQKqsg8kTiqJ9NJQe5NX/5Qlo7Xd9r1yKMMkHllp5g== + +esbuild-sunos-64@0.13.15: + version "0.13.15" + resolved "https://registry.yarnpkg.com/esbuild-sunos-64/-/esbuild-sunos-64-0.13.15.tgz#d0b6454a88375ee8d3964daeff55c85c91c7cef4" + integrity sha512-lbivT9Bx3t1iWWrSnGyBP9ODriEvWDRiweAs69vI+miJoeKwHWOComSRukttbuzjZ8r1q0mQJ8Z7yUsDJ3hKdw== + +esbuild-windows-32@0.13.15: + version "0.13.15" + resolved "https://registry.yarnpkg.com/esbuild-windows-32/-/esbuild-windows-32-0.13.15.tgz#c96d0b9bbb52f3303322582ef8e4847c5ad375a7" + integrity sha512-fDMEf2g3SsJ599MBr50cY5ve5lP1wyVwTe6aLJsM01KtxyKkB4UT+fc5MXQFn3RLrAIAZOG+tHC+yXObpSn7Nw== + +esbuild-windows-64@0.13.15: + version "0.13.15" + resolved "https://registry.yarnpkg.com/esbuild-windows-64/-/esbuild-windows-64-0.13.15.tgz#1f79cb9b1e1bb02fb25cd414cb90d4ea2892c294" + integrity sha512-9aMsPRGDWCd3bGjUIKG/ZOJPKsiztlxl/Q3C1XDswO6eNX/Jtwu4M+jb6YDH9hRSUflQWX0XKAfWzgy5Wk54JQ== + +esbuild-windows-arm64@0.13.15: + version "0.13.15" + resolved "https://registry.yarnpkg.com/esbuild-windows-arm64/-/esbuild-windows-arm64-0.13.15.tgz#482173070810df22a752c686509c370c3be3b3c3" + integrity sha512-zzvyCVVpbwQQATaf3IG8mu1IwGEiDxKkYUdA4FpoCHi1KtPa13jeScYDjlW0Qh+ebWzpKfR2ZwvqAQkSWNcKjA== + +esbuild@^0.13.2: + version "0.13.15" + resolved "https://registry.yarnpkg.com/esbuild/-/esbuild-0.13.15.tgz#db56a88166ee373f87dbb2d8798ff449e0450cdf" + integrity sha512-raCxt02HBKv8RJxE8vkTSCXGIyKHdEdGfUmiYb8wnabnaEmHzyW7DCHb5tEN0xU8ryqg5xw54mcwnYkC4x3AIw== + optionalDependencies: + esbuild-android-arm64 "0.13.15" + esbuild-darwin-64 "0.13.15" + esbuild-darwin-arm64 "0.13.15" + esbuild-freebsd-64 "0.13.15" + esbuild-freebsd-arm64 "0.13.15" + esbuild-linux-32 "0.13.15" + esbuild-linux-64 "0.13.15" + esbuild-linux-arm "0.13.15" + esbuild-linux-arm64 "0.13.15" + esbuild-linux-mips64le "0.13.15" + esbuild-linux-ppc64le "0.13.15" + esbuild-netbsd-64 "0.13.15" + esbuild-openbsd-64 "0.13.15" + esbuild-sunos-64 "0.13.15" + esbuild-windows-32 "0.13.15" + esbuild-windows-64 "0.13.15" + esbuild-windows-arm64 "0.13.15" + +escalade@^3.1.1: + version "3.1.1" + resolved "https://registry.yarnpkg.com/escalade/-/escalade-3.1.1.tgz#d8cfdc7000965c5a0174b4a82eaa5c0552742e40" + integrity sha512-k0er2gUkLf8O0zKJiAhmkTnJlTvINGv7ygDNPbeIsX/TJjGJZHuh9B2UxbsaEkmlEo9MfhrSzmhIlhRlI2GXnw== + +escape-string-regexp@^1.0.5: + version "1.0.5" + resolved "https://registry.yarnpkg.com/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz#1b61c0562190a8dff6ae3bb2cf0200ca130b86d4" + integrity sha1-G2HAViGQqN/2rjuyzwIAyhMLhtQ= + +estree-walker@^2.0.1: + version "2.0.2" + resolved "https://registry.yarnpkg.com/estree-walker/-/estree-walker-2.0.2.tgz#52f010178c2a4c117a7757cfe942adb7d2da4cac" + integrity sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w== + +fsevents@~2.3.2: + version "2.3.2" + resolved "https://registry.yarnpkg.com/fsevents/-/fsevents-2.3.2.tgz#8a526f78b8fdf4623b709e0b975c52c24c02fd1a" + integrity sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA== + +function-bind@^1.1.1: + version "1.1.1" + resolved "https://registry.yarnpkg.com/function-bind/-/function-bind-1.1.1.tgz#a56899d3ea3c9bab874bb9773b7c5ede92f4895d" + integrity sha512-yIovAzMX49sF8Yl58fSCWJ5svSLuaibPxXQJFLmBObTuCr0Mf1KiPopGM9NiFjiYBCbfaa2Fh6breQ6ANVTI0A== + +gensync@^1.0.0-beta.2: + version "1.0.0-beta.2" + resolved "https://registry.yarnpkg.com/gensync/-/gensync-1.0.0-beta.2.tgz#32a6ee76c3d7f52d46b2b1ae5d93fea8580a25e0" + integrity sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg== + +globals@^11.1.0: + version "11.12.0" + resolved "https://registry.yarnpkg.com/globals/-/globals-11.12.0.tgz#ab8795338868a0babd8525758018c2a7eb95c42e" + integrity sha512-WOBp/EEGUiIsJSp7wcv/y6MO+lV9UoncWqxuFfm8eBwzWNgyfBd6Gz+IeKQ9jCmyhoH99g15M3T+QaVHFjizVA== + +graceful-fs@^4.1.2: + version "4.2.8" + resolved "https://registry.yarnpkg.com/graceful-fs/-/graceful-fs-4.2.8.tgz#e412b8d33f5e006593cbd3cee6df9f2cebbe802a" + integrity sha512-qkIilPUYcNhJpd33n0GBXTB1MMPp14TxEsEs0pTrsSVucApsYzW5V+Q8Qxhik6KU3evy+qkAAowTByymK0avdg== + +has-flag@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/has-flag/-/has-flag-3.0.0.tgz#b5d454dc2199ae225699f3467e5a07f3b955bafd" + integrity sha1-tdRU3CGZriJWmfNGfloH87lVuv0= + +has@^1.0.3: + version "1.0.3" + resolved "https://registry.yarnpkg.com/has/-/has-1.0.3.tgz#722d7cbfc1f6aa8241f16dd814e011e1f41e8796" + integrity sha512-f2dvO0VU6Oej7RkWJGrehjbzMAjFp5/VKPp5tTpWIV4JHHZK1/BxbFRtf/siA2SWTe09caDmVtYYzWEIbBS4zw== + dependencies: + function-bind "^1.1.1" + +iconv-lite@^0.4.4: + version "0.4.24" + resolved "https://registry.yarnpkg.com/iconv-lite/-/iconv-lite-0.4.24.tgz#2022b4b25fbddc21d2f524974a474aafe733908b" + integrity sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA== + dependencies: + safer-buffer ">= 2.1.2 < 3" + +image-size@~0.5.0: + version "0.5.5" + resolved "https://registry.yarnpkg.com/image-size/-/image-size-0.5.5.tgz#09dfd4ab9d20e29eb1c3e80b8990378df9e3cb9c" + integrity sha1-Cd/Uq50g4p6xw+gLiZA3jfnjy5w= + +is-core-module@^2.2.0: + version "2.8.0" + resolved "https://registry.yarnpkg.com/is-core-module/-/is-core-module-2.8.0.tgz#0321336c3d0925e497fd97f5d95cb114a5ccd548" + integrity sha512-vd15qHsaqrRL7dtH6QNuy0ndJmRDrS9HAM1CAiSifNUFv4x1a0CCVsj18hJ1mShxIG6T2i1sO78MkP56r0nYRw== + dependencies: + has "^1.0.3" + +is-what@^3.12.0: + version "3.14.1" + resolved "https://registry.yarnpkg.com/is-what/-/is-what-3.14.1.tgz#e1222f46ddda85dead0fd1c9df131760e77755c1" + integrity sha512-sNxgpk9793nzSs7bA6JQJGeIuRBQhAaNGG77kzYQgMkrID+lS6SlK07K5LaptscDlSaIgH+GPFzf+d75FVxozA== + +"js-tokens@^3.0.0 || ^4.0.0", js-tokens@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/js-tokens/-/js-tokens-4.0.0.tgz#19203fb59991df98e3a287050d4647cdeaf32499" + integrity sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ== + +jsesc@^2.5.1: + version "2.5.2" + resolved "https://registry.yarnpkg.com/jsesc/-/jsesc-2.5.2.tgz#80564d2e483dacf6e8ef209650a67df3f0c283a4" + integrity sha512-OYu7XEzjkCQ3C5Ps3QIZsQfNpqoJyZZA99wd9aWd05NCtC5pWOkShK2mkL6HXQR6/Cy2lbNdPlZBpuQHXE63gA== + +json5@^2.1.2: + version "2.2.0" + resolved "https://registry.yarnpkg.com/json5/-/json5-2.2.0.tgz#2dfefe720c6ba525d9ebd909950f0515316c89a3" + integrity sha512-f+8cldu7X/y7RAJurMEJmdoKXGB/X550w2Nr3tTbezL6RwEE/iMcm+tZnXeoZtKuOq6ft8+CqzEkrIgx1fPoQA== + dependencies: + minimist "^1.2.5" + +less@^4.1.1: + version "4.1.2" + resolved "https://registry.yarnpkg.com/less/-/less-4.1.2.tgz#6099ee584999750c2624b65f80145f8674e4b4b0" + integrity sha512-EoQp/Et7OSOVu0aJknJOtlXZsnr8XE8KwuzTHOLeVSEx8pVWUICc8Q0VYRHgzyjX78nMEyC/oztWFbgyhtNfDA== + dependencies: + copy-anything "^2.0.1" + parse-node-version "^1.0.1" + tslib "^2.3.0" + optionalDependencies: + errno "^0.1.1" + graceful-fs "^4.1.2" + image-size "~0.5.0" + make-dir "^2.1.0" + mime "^1.4.1" + needle "^2.5.2" + source-map "~0.6.0" + +loose-envify@^1.1.0: + version "1.4.0" + resolved "https://registry.yarnpkg.com/loose-envify/-/loose-envify-1.4.0.tgz#71ee51fa7be4caec1a63839f7e682d8132d30caf" + integrity sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q== + dependencies: + js-tokens "^3.0.0 || ^4.0.0" + +make-dir@^2.1.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/make-dir/-/make-dir-2.1.0.tgz#5f0310e18b8be898cc07009295a30ae41e91e6f5" + integrity sha512-LS9X+dc8KLxXCb8dni79fLIIUA5VyZoyjSMCwTluaXA0o27cCK0bhXkpgw+sTXVpPy/lSO57ilRixqk0vDmtRA== + dependencies: + pify "^4.0.1" + semver "^5.6.0" + +mime@^1.4.1: + version "1.6.0" + resolved "https://registry.yarnpkg.com/mime/-/mime-1.6.0.tgz#32cd9e5c64553bd58d19a568af452acff04981b1" + integrity sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg== + +minimist@^1.2.5: + version "1.2.5" + resolved "https://registry.yarnpkg.com/minimist/-/minimist-1.2.5.tgz#67d66014b66a6a8aaa0c083c5fd58df4e4e97602" + integrity sha512-FM9nNUYrRBAELZQT3xeZQ7fmMOBg6nWNmJKTcgsJeaLstP/UODVpGsr5OhXhhXg6f+qtJ8uiZ+PUxkDWcgIXLw== + +ms@2.1.2: + version "2.1.2" + resolved "https://registry.yarnpkg.com/ms/-/ms-2.1.2.tgz#d09d1f357b443f493382a8eb3ccd183872ae6009" + integrity sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w== + +ms@^2.1.1: + version "2.1.3" + resolved "https://registry.yarnpkg.com/ms/-/ms-2.1.3.tgz#574c8138ce1d2b5861f0b44579dbadd60c6615b2" + integrity sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA== + +nanoid@^3.1.30: + version "3.1.30" + resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-3.1.30.tgz#63f93cc548d2a113dc5dfbc63bfa09e2b9b64362" + integrity sha512-zJpuPDwOv8D2zq2WRoMe1HsfZthVewpel9CAvTfc/2mBD1uUT/agc5f7GHGWXlYkFvi1mVxe4IjvP2HNrop7nQ== + +needle@^2.5.2: + version "2.9.1" + resolved "https://registry.yarnpkg.com/needle/-/needle-2.9.1.tgz#22d1dffbe3490c2b83e301f7709b6736cd8f2684" + integrity sha512-6R9fqJ5Zcmf+uYaFgdIHmLwNldn5HbK8L5ybn7Uz+ylX/rnOsSp1AHcvQSrCaFN+qNM1wpymHqD7mVasEOlHGQ== + dependencies: + debug "^3.2.6" + iconv-lite "^0.4.4" + sax "^1.2.4" + +node-releases@^2.0.1: + version "2.0.1" + resolved "https://registry.yarnpkg.com/node-releases/-/node-releases-2.0.1.tgz#3d1d395f204f1f2f29a54358b9fb678765ad2fc5" + integrity sha512-CqyzN6z7Q6aMeF/ktcMVTzhAHCEpf8SOarwpzpf8pNBY2k5/oM34UHldUwp8VKI7uxct2HxSRdJjBaZeESzcxA== + +object-assign@^4.1.1: + version "4.1.1" + resolved "https://registry.yarnpkg.com/object-assign/-/object-assign-4.1.1.tgz#2109adc7965887cfc05cbbd442cac8bfbb360863" + integrity sha1-IQmtx5ZYh8/AXLvUQsrIv7s2CGM= + +parse-node-version@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/parse-node-version/-/parse-node-version-1.0.1.tgz#e2b5dbede00e7fa9bc363607f53327e8b073189b" + integrity sha512-3YHlOa/JgH6Mnpr05jP9eDG254US9ek25LyIxZlDItp2iJtwyaXQb57lBYLdT3MowkUFYEV2XXNAYIPlESvJlA== + +path-parse@^1.0.6: + version "1.0.7" + resolved "https://registry.yarnpkg.com/path-parse/-/path-parse-1.0.7.tgz#fbc114b60ca42b30d9daf5858e4bd68bbedb6735" + integrity sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw== + +picocolors@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/picocolors/-/picocolors-1.0.0.tgz#cb5bdc74ff3f51892236eaf79d68bc44564ab81c" + integrity sha512-1fygroTLlHu66zi26VoTDv8yRgm0Fccecssto+MhsZ0D/DGW2sm8E8AjW7NU5VVTRt5GxbeZ5qBuJr+HyLYkjQ== + +picomatch@^2.2.2: + version "2.3.0" + resolved "https://registry.yarnpkg.com/picomatch/-/picomatch-2.3.0.tgz#f1f061de8f6a4bf022892e2d128234fb98302972" + integrity sha512-lY1Q/PiJGC2zOv/z391WOTD+Z02bCgsFfvxoXXf6h7kv9o+WmsmzYqrAwY63sNgOxE4xEdq0WyUnXfKeBrSvYw== + +pify@^4.0.1: + version "4.0.1" + resolved "https://registry.yarnpkg.com/pify/-/pify-4.0.1.tgz#4b2cd25c50d598735c50292224fd8c6df41e3231" + integrity sha512-uB80kBFb/tfd68bVleG9T5GGsGPjJrLAUpR5PZIrhBnIaRTQRjqdJSsIKkOP6OAIFbj7GOrcudc5pNjZ+geV2g== + +postcss@^8.3.8: + version "8.3.11" + resolved "https://registry.yarnpkg.com/postcss/-/postcss-8.3.11.tgz#c3beca7ea811cd5e1c4a3ec6d2e7599ef1f8f858" + integrity sha512-hCmlUAIlUiav8Xdqw3Io4LcpA1DOt7h3LSTAC4G6JGHFFaWzI6qvFt9oilvl8BmkbBRX1IhM90ZAmpk68zccQA== + dependencies: + nanoid "^3.1.30" + picocolors "^1.0.0" + source-map-js "^0.6.2" + +prismjs@^1.25.0: + version "1.25.0" + resolved "http://npm.corp.ebay.com/prismjs/-/prismjs-1.25.0.tgz#6f822df1bdad965734b310b315a23315cf999756" + integrity sha1-b4It8b2tllc0sxCzFaIzFc+Zl1Y= + +prr@~1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/prr/-/prr-1.0.1.tgz#d3fc114ba06995a45ec6893f484ceb1d78f5f476" + integrity sha1-0/wRS6BplaRexok/SEzrHXj19HY= + +react-dom@^17.0.2: + version "17.0.2" + resolved "https://registry.yarnpkg.com/react-dom/-/react-dom-17.0.2.tgz#ecffb6845e3ad8dbfcdc498f0d0a939736502c23" + integrity sha512-s4h96KtLDUQlsENhMn1ar8t2bEa+q/YAtj8pPPdIjPDGBDIVNsrD9aXNWqspUe6AzKCIG0C1HZZLqLV7qpOBGA== + dependencies: + loose-envify "^1.1.0" + object-assign "^4.1.1" + scheduler "^0.20.2" + +react-refresh@^0.10.0: + version "0.10.0" + resolved "https://registry.yarnpkg.com/react-refresh/-/react-refresh-0.10.0.tgz#2f536c9660c0b9b1d500684d9e52a65e7404f7e3" + integrity sha512-PgidR3wST3dDYKr6b4pJoqQFpPGNKDSCDx4cZoshjXipw3LzO7mG1My2pwEzz2JVkF+inx3xRpDeQLFQGH/hsQ== + +react@^17.0.2: + version "17.0.2" + resolved "https://registry.yarnpkg.com/react/-/react-17.0.2.tgz#d0b5cc516d29eb3eee383f75b62864cfb6800037" + integrity sha512-gnhPt75i/dq/z3/6q/0asP78D0u592D5L1pd7M8P+dck6Fu/jJeL6iVVK23fptSUZj8Vjf++7wXA8UNclGQcbA== + dependencies: + loose-envify "^1.1.0" + object-assign "^4.1.1" + +resolve@^1.20.0: + version "1.20.0" + resolved "https://registry.yarnpkg.com/resolve/-/resolve-1.20.0.tgz#629a013fb3f70755d6f0b7935cc1c2c5378b1975" + integrity sha512-wENBPt4ySzg4ybFQW2TT1zMQucPK95HSh/nq2CFTZVOGut2+pQvSsgtda4d26YrYcr067wjbmzOG8byDPBX63A== + dependencies: + is-core-module "^2.2.0" + path-parse "^1.0.6" + +rollup@^2.57.0: + version "2.60.0" + resolved "https://registry.yarnpkg.com/rollup/-/rollup-2.60.0.tgz#4ee60ab7bdd0356763f87d7099f413e5460fc193" + integrity sha512-cHdv9GWd58v58rdseC8e8XIaPUo8a9cgZpnCMMDGZFDZKEODOiPPEQFXLriWr/TjXzhPPmG5bkAztPsOARIcGQ== + optionalDependencies: + fsevents "~2.3.2" + +safe-buffer@~5.1.1: + version "5.1.2" + resolved "https://registry.yarnpkg.com/safe-buffer/-/safe-buffer-5.1.2.tgz#991ec69d296e0313747d59bdfd2b745c35f8828d" + integrity sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g== + +"safer-buffer@>= 2.1.2 < 3": + version "2.1.2" + resolved "https://registry.yarnpkg.com/safer-buffer/-/safer-buffer-2.1.2.tgz#44fa161b0187b9549dd84bb91802f9bd8385cd6a" + integrity sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg== + +sax@^1.2.4: + version "1.2.4" + resolved "https://registry.yarnpkg.com/sax/-/sax-1.2.4.tgz#2816234e2378bddc4e5354fab5caa895df7100d9" + integrity sha512-NqVDv9TpANUjFm0N8uM5GxL36UgKi9/atZw+x7YFnQ8ckwFGKrl4xX4yWtrey3UJm5nP1kUbnYgLopqWNSRhWw== + +scheduler@^0.20.2: + version "0.20.2" + resolved "https://registry.yarnpkg.com/scheduler/-/scheduler-0.20.2.tgz#4baee39436e34aa93b4874bddcbf0fe8b8b50e91" + integrity sha512-2eWfGgAqqWFGqtdMmcL5zCMK1U8KlXv8SQFGglL3CEtd0aDVDWgeF/YoCmvln55m5zSk3J/20hTaSBeSObsQDQ== + dependencies: + loose-envify "^1.1.0" + object-assign "^4.1.1" + +semver@^5.6.0: + version "5.7.1" + resolved "https://registry.yarnpkg.com/semver/-/semver-5.7.1.tgz#a954f931aeba508d307bbf069eff0c01c96116f7" + integrity sha512-sauaDf/PZdVgrLTNYHRtpXa1iRiKcaebiKQ1BJdpQlWH2lCvexQdX55snPFyK7QzpudqbCI0qXFfOasHdyNDGQ== + +semver@^6.3.0: + version "6.3.0" + resolved "https://registry.yarnpkg.com/semver/-/semver-6.3.0.tgz#ee0a64c8af5e8ceea67687b133761e1becbd1d3d" + integrity sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw== + +source-map-js@^0.6.2: + version "0.6.2" + resolved "https://registry.yarnpkg.com/source-map-js/-/source-map-js-0.6.2.tgz#0bb5de631b41cfbda6cfba8bd05a80efdfd2385e" + integrity sha512-/3GptzWzu0+0MBQFrDKzw/DvvMTUORvgY6k6jd/VS6iCR4RDTKWH6v6WPwQoUO8667uQEf9Oe38DxAYWY5F/Ug== + +source-map@^0.5.0: + version "0.5.7" + resolved "https://registry.yarnpkg.com/source-map/-/source-map-0.5.7.tgz#8a039d2d1021d22d1ea14c80d8ea468ba2ef3fcc" + integrity sha1-igOdLRAh0i0eoUyA2OpGi6LvP8w= + +source-map@~0.6.0: + version "0.6.1" + resolved "https://registry.yarnpkg.com/source-map/-/source-map-0.6.1.tgz#74722af32e9614e9c287a8d0bbde48b5e2f1a263" + integrity sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g== + +supports-color@^5.3.0: + version "5.5.0" + resolved "https://registry.yarnpkg.com/supports-color/-/supports-color-5.5.0.tgz#e2e69a44ac8772f78a1ec0b35b689df6530efc8f" + integrity sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow== + dependencies: + has-flag "^3.0.0" + +tiny-undo@^0.0.8: + version "0.0.8" + resolved "https://registry.yarnpkg.com/tiny-undo/-/tiny-undo-0.0.8.tgz#6017fd1054055bcc40fd431d9e5cb6c046076aab" + integrity sha512-x/zUWVVoehq0TTLTLngUCvg41bssl5OYloNOE6sRz/2fsEetB87zpj4kEZyrmvzI3Rsq+t9olujqXprlu+lbvw== + +to-fast-properties@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/to-fast-properties/-/to-fast-properties-2.0.0.tgz#dc5e698cbd079265bc73e0377681a4e4e83f616e" + integrity sha1-3F5pjL0HkmW8c+A3doGk5Og/YW4= + +tslib@^2.3.0: + version "2.3.1" + resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.3.1.tgz#e8a335add5ceae51aa261d32a490158ef042ef01" + integrity sha512-77EbyPPpMz+FRFRuAFlWMtmgUWGe9UOG2Z25NqCwiIjRhOf5iKGuzSe5P2w1laq+FkRy4p+PCuVkJSGkzTEKVw== + +typescript@^4.3.2: + version "4.5.2" + resolved "https://registry.yarnpkg.com/typescript/-/typescript-4.5.2.tgz#8ac1fba9f52256fdb06fb89e4122fa6a346c2998" + integrity sha512-5BlMof9H1yGt0P8/WF+wPNw6GfctgGjXp5hkblpyT+8rkASSmkUKMXrxR0Xg8ThVCi/JnHQiKXeBaEwCeQwMFw== + +vite@^2.6.14: + version "2.6.14" + resolved "https://registry.yarnpkg.com/vite/-/vite-2.6.14.tgz#35c09a15e4df823410819a2a239ab11efb186271" + integrity sha512-2HA9xGyi+EhY2MXo0+A2dRsqsAG3eFNEVIo12olkWhOmc8LfiM+eMdrXf+Ruje9gdXgvSqjLI9freec1RUM5EA== + dependencies: + esbuild "^0.13.2" + postcss "^8.3.8" + resolve "^1.20.0" + rollup "^2.57.0" + optionalDependencies: + fsevents "~2.3.2"