diff --git a/LICENSE b/LICENSE index 0ad25db..e31a404 100644 --- a/LICENSE +++ b/LICENSE @@ -1,661 +1,21 @@ - GNU AFFERO GENERAL PUBLIC LICENSE - Version 3, 19 November 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 Affero General Public License is a free, copyleft license for -software and other kinds of works, specifically designed to ensure -cooperation with the community in the case of network server software. - - The licenses for most software and other practical works are designed -to take away your freedom to share and change the works. By contrast, -our General Public Licenses are 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. - - 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. - - Developers that use our General Public Licenses protect your rights -with two steps: (1) assert copyright on the software, and (2) offer -you this License which gives you legal permission to copy, distribute -and/or modify the software. - - A secondary benefit of defending all users' freedom is that -improvements made in alternate versions of the program, if they -receive widespread use, become available for other developers to -incorporate. Many developers of free software are heartened and -encouraged by the resulting cooperation. However, in the case of -software used on network servers, this result may fail to come about. -The GNU General Public License permits making a modified version and -letting the public access it on a server without ever releasing its -source code to the public. - - The GNU Affero General Public License is designed specifically to -ensure that, in such cases, the modified source code becomes available -to the community. It requires the operator of a network server to -provide the source code of the modified version running there to the -users of that server. Therefore, public use of a modified version, on -a publicly accessible server, gives the public access to the source -code of the modified version. - - An older license, called the Affero General Public License and -published by Affero, was designed to accomplish similar goals. This is -a different license, not a version of the Affero GPL, but Affero has -released a new version of the Affero GPL which permits relicensing under -this license. - - 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 Affero 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. Remote Network Interaction; Use with the GNU General Public License. - - Notwithstanding any other provision of this License, if you modify the -Program, your modified version must prominently offer all users -interacting with it remotely through a computer network (if your version -supports such interaction) an opportunity to receive the Corresponding -Source of your version by providing access to the Corresponding Source -from a network server at no charge, through some standard or customary -means of facilitating copying of software. This Corresponding Source -shall include the Corresponding Source for any work covered by version 3 -of the GNU General Public License that is incorporated pursuant to the -following paragraph. - - 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 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 work with which it is combined will remain governed by version -3 of the GNU General Public License. - - 14. Revised Versions of this License. - - The Free Software Foundation may publish revised and/or new versions of -the GNU Affero 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 Affero 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 Affero 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 Affero 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 Affero 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 Affero General Public License for more details. - - You should have received a copy of the GNU Affero General Public License - along with this program. If not, see . - -Also add information on how to contact you by electronic and paper mail. - - If your software can interact with users remotely through a computer -network, you should also make sure that it provides a way for users to -get its source. For example, if your program is a web application, its -interface could display a "Source" link that leads users to an archive -of the code. There are many ways you could offer source, and different -solutions will be better for different programs; see section 13 for the -specific requirements. - - 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 AGPL, see -. +MIT License + +Copyright (c) 2026 CyaniAgent + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md index 4819840..5957d0e 100644 --- a/README.md +++ b/README.md @@ -84,7 +84,7 @@ cd web pnpm install pnpm dev ``` -Visit `http://localhost:3000` to enter the Cyan Universe! +Visit `http://localhost:2000` to enter the Cyan Universe! ## Project Structure @@ -119,7 +119,7 @@ Asagity/ Asagity is currently in its early development phase. PRs, issues, and feature requests are highly welcome! ## License -This project is licensed under the [AGPL-3.0 License](LICENSE). +This project is licensed under the [MIT License](LICENSE). ---
diff --git a/README_CN.md b/README_CN.md index 2491785..e094297 100644 --- a/README_CN.md +++ b/README_CN.md @@ -118,7 +118,7 @@ Asagity/ Asagity 目前正处于火热的早期开发阶段。无论你是擅长 Go 的硬核极客,还是精通 React 的 UI 魔法师,我们都极其欢迎你的 PR 和 Issue! ## 📜 开源协议 -本项目采用 [AGPL-3.0 协议](LICENSE) 开源。 +本项目采用 [MIT 协议](LICENSE) 开源。 ---
diff --git a/docs/roadmap/ReduceMemUse.md b/docs/roadmap/ReduceMemUse.md new file mode 100644 index 0000000..9b70049 --- /dev/null +++ b/docs/roadmap/ReduceMemUse.md @@ -0,0 +1,48 @@ +# 前端内存优化计划 + +## 一、构建期内存优化 (Build-time) + +| # | 措施 | 说明 | 风险 | +|---|------|------|------| +| 1 | `experimental.webpackMemoryOptimizations: true` | Next.js 15+ 内置,降低构建峰值内存,代价是编译时间略增 | 低 | +| 2 | `experimental.preloadEntriesOnStart: false` | 禁用启动时预加载所有页面 JS 模块,改为按需加载 | 低 | +| 3 | 关闭生产 Source Maps | `productionBrowserSourceMaps: false` + `enablePrerenderSourceMaps: false`,减少构建期内存分配 | 低 | +| 4 | `pnpm analyze` 审计依赖 | 用 Bundle Analyzer 找出未使用/过重的包(如 `echarts` 全量引入、`@dnd-kit` 未使用部分) | 无 | + +## 二、运行期内存优化 (Runtime) + +| # | 措施 | 说明 | 风险 | +|---|------|------|------| +| 5 | 路由级代码分割 | 当前 `page.tsx` 已用 `next/dynamic`,但 `MainLayout` 内的子组件(`ContextMenu`、`NetworkStatus`、`SplashScreen`)未懒加载 — 改为 `dynamic` 导入 | 低 | +| 6 | Zustand store 瘦身 | `useMusicStore` 持久化整个 playlist(含 ArrayBuffer 音频数据),应 `partialize` 排除大数据字段;`useFreeWindowStore` 的 `currentPost`/`currentUser` 在关闭窗口后不清空 — 添加 `close()` 时重置 | 中 | +| 7 | React.memo 防级联渲染 | `PostItem`、`Icon`、`Sidebar` 等高频渲染组件用 `React.memo` 包裹,避免父组件 state 变化导致整棵树重绘 | 低 | +| 8 | 事件监听器清理 | `Welcome.tsx` 的 `mousemove`、`MainLayout.tsx` 的 `mousedown` 已有 cleanup,但需检查 `system.ts` 的 `setInterval` 心跳在组件卸载后是否仍在运行 — 应在 `MainLayout` 的 `useEffect` return 中调用 `stopHeartbeat` | 中 | +| 9 | AbortController 取消请求 | `fetchHostInfoWithTimeout` 已有 AbortController,但 `fetchMe`、`refreshAccessToken` 等未使用 — 统一添加信号取消 | 中 | + +## 三、Bundle 体积瘦身 + +| # | 措施 | 说明 | 风险 | +|---|------|------|------| +| 10 | ECharts 按需引入 | 当前 `import * as echarts from "echarts"` 全量引入约 800KB,改为 `echarts/core` + 按需注册组件 | 中 | +| 11 | `@fluentui/react-icons` tree-shaking | 当前 100+ 图标全量注册到 `iconMap`,每个图标独立导入会增加 module 数量 — 评估是否改用 `@iconify/react` 按需加载 | 中 | +| 12 | 移除未使用依赖 | `package.json` 中 `@dnd-kit/core`、`@dnd-kit/sortable`、`@dnd-kit/utilities`、`lucide-react`、`@iconify/react` 可能未实际使用 — `pnpm analyze` 后清理 | 低 | + +## 四、监控与持续优化 + +| # | 措施 | 说明 | +|---|------|------| +| 13 | 添加 `bundlesize` 配置 | 在 `package.json` 设置 JS/CSS 大小预算(如 JS < 300KB, CSS < 50KB) | +| 14 | 生产构建内存监控 | `next build --experimental-debug-memory-usage` 记录构建期 heap 使用 | +| 15 | Chrome DevTools heap snapshot | 定期在生产环境用 `--inspect` 抓取 heap profile,定位内存泄漏 | + +## 五、执行优先级 + +``` +Phase 1 (低风险立即做): #1, #2, #3, #5, #12 +Phase 2 (需测试验证): #6, #7, #8, #9 +Phase 3 (需重构): #4, #10, #11 +Phase 4 (持续): #13, #14, #15 +``` + +--- +*Created by CyaniAgent - 2026-07-06* diff --git a/web/README.md b/web/README.md index 85281c0..dac73ab 100644 --- a/web/README.md +++ b/web/README.md @@ -6,7 +6,7 @@ ```bash pnpm install -pnpm dev # http://localhost:3000 +pnpm dev # http://localhost:2000 ``` ## Commands diff --git a/web/next.config.ts b/web/next.config.ts index fb7ddbb..614c912 100644 --- a/web/next.config.ts +++ b/web/next.config.ts @@ -5,12 +5,14 @@ const withBundleAnalyzer = bundleAnalyzer({ enabled: process.env.ANALYZE === "true", }); +const isDev = process.env.NODE_ENV !== "production"; + const securityHeaders = [ { key: "Content-Security-Policy", value: [ "default-src 'self'", - "script-src 'self' 'unsafe-inline'", + `script-src 'self' 'unsafe-inline'${isDev ? " 'unsafe-eval'" : ""}`, "style-src 'self' 'unsafe-inline'", "font-src 'self' data:", "img-src 'self' data: blob: https:", @@ -47,6 +49,13 @@ const securityHeaders = [ ]; const nextConfig: NextConfig = { + experimental: { + webpackMemoryOptimizations: true, + preloadEntriesOnStart: false, + }, + + productionBrowserSourceMaps: false, + async rewrites() { return [ { diff --git a/web/package.json b/web/package.json index af47d8b..5075115 100644 --- a/web/package.json +++ b/web/package.json @@ -3,9 +3,9 @@ "version": "0.1.0", "private": true, "scripts": { - "dev": "next dev --turbopack", + "dev": "next dev --turbopack --port 2000 --hostname ::", "build": "next build", - "start": "next start", + "start": "next start --port 2000 --hostname ::", "lint": "next lint --fix", "lint:check": "next lint", "typecheck": "tsc --noEmit", @@ -16,11 +16,7 @@ "test:e2e:ui": "playwright test --ui" }, "dependencies": { - "@dnd-kit/core": "^6.3.1", - "@dnd-kit/sortable": "^10.0.0", - "@dnd-kit/utilities": "^3.2.2", "@fluentui/react-icons": "^2.0.316", - "@iconify/react": "^6.0.2", "@tanstack/react-query": "^5.101.2", "clsx": "^2.1.1", "date-fns": "^4.4.0", @@ -28,7 +24,6 @@ "echarts-for-react": "^3.0.6", "framer-motion": "^12.42.2", "lrc-kit": "^1.2.1", - "lucide-react": "^1.22.0", "mfm-js": "^0.26.0", "music-metadata": "^11.13.0", "next": "16.2.9", @@ -37,6 +32,7 @@ "react-dom": "19.2.4", "react-resizable-panels": "^4.12.0", "react-rnd": "^10.5.3", + "react-window": "^2.2.7", "recharts": "^3.9.1", "tailwind-merge": "^3.6.0", "ua-parser-js": "^2.0.10", diff --git a/web/pnpm-lock.yaml b/web/pnpm-lock.yaml index 7c87833..a971838 100644 --- a/web/pnpm-lock.yaml +++ b/web/pnpm-lock.yaml @@ -8,21 +8,9 @@ importers: .: dependencies: - '@dnd-kit/core': - specifier: ^6.3.1 - version: 6.3.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@dnd-kit/sortable': - specifier: ^10.0.0 - version: 10.0.0(@dnd-kit/core@6.3.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react@19.2.4) - '@dnd-kit/utilities': - specifier: ^3.2.2 - version: 3.2.2(react@19.2.4) '@fluentui/react-icons': specifier: ^2.0.316 version: 2.0.316(react@19.2.4) - '@iconify/react': - specifier: ^6.0.2 - version: 6.0.2(react@19.2.4) '@tanstack/react-query': specifier: ^5.101.2 version: 5.101.2(react@19.2.4) @@ -44,9 +32,6 @@ importers: lrc-kit: specifier: ^1.2.1 version: 1.2.1 - lucide-react: - specifier: ^1.22.0 - version: 1.22.0(react@19.2.4) mfm-js: specifier: ^0.26.0 version: 0.26.0 @@ -71,6 +56,9 @@ importers: react-rnd: specifier: ^10.5.3 version: 10.5.3(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + react-window: + specifier: ^2.2.7 + version: 2.2.7(react-dom@19.2.4(react@19.2.4))(react@19.2.4) recharts: specifier: ^3.9.1 version: 3.9.1(@types/react@19.2.17)(react-dom@19.2.4(react@19.2.4))(react-is@17.0.2)(react@19.2.4)(redux@5.0.1) @@ -210,28 +198,6 @@ packages: resolution: {integrity: sha512-dBVuXR082gk3jsFp7Rd/JI4kytwGHecnCoTtXFb7DB6CNHp4rg5k1bhg0nWdLGLnOV71lmDzGQaLMy8iPLY0pw==} engines: {node: '>=10.0.0'} - '@dnd-kit/accessibility@3.1.1': - resolution: {integrity: sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==} - peerDependencies: - react: '>=16.8.0' - - '@dnd-kit/core@6.3.1': - resolution: {integrity: sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==} - peerDependencies: - react: '>=16.8.0' - react-dom: '>=16.8.0' - - '@dnd-kit/sortable@10.0.0': - resolution: {integrity: sha512-+xqhmIIzvAYMGfBYYnbKuNicfSsk4RksY2XdmJhT+HAC01nix6fHCztU68jooFiMUB01Ky3F0FyOvhG/BZrWkg==} - peerDependencies: - '@dnd-kit/core': ^6.3.0 - react: '>=16.8.0' - - '@dnd-kit/utilities@3.2.2': - resolution: {integrity: sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg==} - peerDependencies: - react: '>=16.8.0' - '@emnapi/core@1.11.1': resolution: {integrity: sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==} @@ -281,14 +247,6 @@ packages: '@griffel/style-types@1.4.2': resolution: {integrity: sha512-MsSghfpyxR2MpTrYdcCozISsSLkmFjNw94wNPi4bDBRLW8W43718W/ZjmUdVkoM0KXMtJPYuEkx8Mzibqb03qA==} - '@iconify/react@6.0.2': - resolution: {integrity: sha512-SMmC2sactfpJD427WJEDN6PMyznTFMhByK9yLW0gOTtnjzzbsi/Ke/XqsumsavFPwNiXs8jSiYeZTmLCLwO+Fg==} - peerDependencies: - react: '>=16' - - '@iconify/types@2.0.0': - resolution: {integrity: sha512-+wluvCrRhXrhyOmRDJ3q8mux9JkKy5SJ/v8ol2tu4FVjyYvtEzkc/3pK15ET6RKg4b4w4BmTk1+gsCUhf21Ykg==} - '@img/colour@1.1.0': resolution: {integrity: sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==} engines: {node: '>=18'} @@ -1459,11 +1417,6 @@ packages: resolution: {integrity: sha512-RPimw/7aMdv2oqRrxKwvZXcPfwBrn/JZ2xYcY9Hus/6LaS3VOAKVWKWgNLCFSiOm1ESXinjsDlidVU7JlnCN2A==} engines: {node: 20 || >=22} - lucide-react@1.22.0: - resolution: {integrity: sha512-c9o3l0PiNcgOQDW4F31BEYHudE7kgxVt3o30qMl36ZPwTxXlGB4QnLilhERvVM4uh/pl5MDyY1/gzZSYcHDtBg==} - peerDependencies: - react: ^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0 - lz-string@1.5.0: resolution: {integrity: sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==} hasBin: true @@ -1652,6 +1605,12 @@ packages: react: '>=16.3.0' react-dom: '>=16.3.0' + react-window@2.2.7: + resolution: {integrity: sha512-SH5nvfUQwGHYyriDUAOt7wfPsfG9Qxd6OdzQxl5oQ4dsSsUicqQvjV7dR+NqZ4coY0fUn3w1jnC5PwzIUWEg5w==} + peerDependencies: + react: ^18.0.0 || ^19.0.0 + react-dom: ^18.0.0 || ^19.0.0 + react@19.2.4: resolution: {integrity: sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==} engines: {node: '>=0.10.0'} @@ -2071,31 +2030,6 @@ snapshots: '@discoveryjs/json-ext@0.5.7': {} - '@dnd-kit/accessibility@3.1.1(react@19.2.4)': - dependencies: - react: 19.2.4 - tslib: 2.8.1 - - '@dnd-kit/core@6.3.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': - dependencies: - '@dnd-kit/accessibility': 3.1.1(react@19.2.4) - '@dnd-kit/utilities': 3.2.2(react@19.2.4) - react: 19.2.4 - react-dom: 19.2.4(react@19.2.4) - tslib: 2.8.1 - - '@dnd-kit/sortable@10.0.0(@dnd-kit/core@6.3.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react@19.2.4)': - dependencies: - '@dnd-kit/core': 6.3.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@dnd-kit/utilities': 3.2.2(react@19.2.4) - react: 19.2.4 - tslib: 2.8.1 - - '@dnd-kit/utilities@3.2.2(react@19.2.4)': - dependencies: - react: 19.2.4 - tslib: 2.8.1 - '@emnapi/core@1.11.1': dependencies: '@emnapi/wasi-threads': 1.2.2 @@ -2153,13 +2087,6 @@ snapshots: dependencies: csstype: 3.2.3 - '@iconify/react@6.0.2(react@19.2.4)': - dependencies: - '@iconify/types': 2.0.0 - react: 19.2.4 - - '@iconify/types@2.0.0': {} - '@img/colour@1.1.0': optional: true @@ -3045,10 +2972,6 @@ snapshots: lru-cache@11.5.1: {} - lucide-react@1.22.0(react@19.2.4): - dependencies: - react: 19.2.4 - lz-string@1.5.0: {} magic-string@0.30.21: @@ -3235,6 +3158,11 @@ snapshots: react-draggable: 4.7.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4) tslib: 2.6.2 + react-window@2.2.7(react-dom@19.2.4(react@19.2.4))(react@19.2.4): + dependencies: + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + react@19.2.4: {} recharts@3.9.1(@types/react@19.2.17)(react-dom@19.2.4(react@19.2.4))(react-is@17.0.2)(react@19.2.4)(redux@5.0.1): diff --git a/web/pnpm-workspace.yaml b/web/pnpm-workspace.yaml index 581a9d5..15f9cfd 100644 --- a/web/pnpm-workspace.yaml +++ b/web/pnpm-workspace.yaml @@ -1,3 +1,7 @@ +allowBuilds: + '@parcel/watcher': true + '@swc/core': true + sharp: true ignoredBuiltDependencies: - sharp - unrs-resolver diff --git a/web/src/app/not-found.tsx b/web/src/app/not-found.tsx new file mode 100644 index 0000000..3bd98b7 --- /dev/null +++ b/web/src/app/not-found.tsx @@ -0,0 +1,56 @@ +"use client"; + +import dynamic from "next/dynamic"; +import Image from "next/image"; +import Link from "next/link"; +import { useRouter } from "next/navigation"; +import { useI18n } from "@/components/providers/I18nProvider"; + +const MainLayout = dynamic( + () => import("@/components/layout/MainLayout").then((m) => m.MainLayout), + { ssr: false } +); + +export default function NotFound() { + const { t } = useI18n(); + const router = useRouter(); + + return ( + +
+
+ 404 +
+

+ 404 +

+

+ {t("notFound.message")} +

+
+ + + {t("notFound.goHome")} + +
+
+
+
+
+ ); +} diff --git a/web/src/components/layout/MainLayout.tsx b/web/src/components/layout/MainLayout.tsx index 1d72239..23ea2b9 100644 --- a/web/src/components/layout/MainLayout.tsx +++ b/web/src/components/layout/MainLayout.tsx @@ -4,6 +4,7 @@ import { useState, useEffect, useRef, useCallback } from "react"; import Link from "next/link"; import Image from "next/image"; import { usePathname, useRouter } from "next/navigation"; +import dynamic from "next/dynamic"; import { useSystemStore } from "@/stores/system"; import { useUserStore } from "@/stores/user"; import { useSplitViewStore } from "@/stores/splitView"; @@ -12,14 +13,26 @@ import { useMusicStore } from "@/stores/music"; import { Sidebar } from "./Sidebar"; import { MoreMenuPopover } from "./MoreMenuPopover"; import { SplitView } from "./SplitView"; -import { SplashScreen } from "@/components/ui/SplashScreen"; -import { ContextMenu } from "@/components/ui/ContextMenu"; -import { NetworkStatus } from "@/components/shared/NetworkStatus"; import { MobileNav } from "./MobileNav"; import { Icon } from "@/components/ui/Icon"; import { useI18n } from "@/components/providers/I18nProvider"; -export function MainLayout({ children }: { children: React.ReactNode }) { +const SplashScreen = dynamic( + () => import("@/components/ui/SplashScreen").then((m) => m.SplashScreen), + { ssr: false } +); + +const ContextMenu = dynamic( + () => import("@/components/ui/ContextMenu").then((m) => m.ContextMenu), + { ssr: false } +); + +const NetworkStatus = dynamic( + () => import("@/components/shared/NetworkStatus").then((m) => m.NetworkStatus), + { ssr: false } +); + +export function MainLayout({ children, notFound }: { children: React.ReactNode; notFound?: boolean }) { const { t } = useI18n(); const pathname = usePathname(); const router = useRouter(); @@ -98,6 +111,11 @@ export function MainLayout({ children }: { children: React.ReactNode }) { const aboutTabs: { label: string; icon: string; to: string }[] = []; + const notFoundTabs = [ + { label: t("notFound.back"), icon: "arrow_back", to: "__back__" }, + { label: t("tabs.timeline"), icon: "public", to: "/" }, + ]; + useEffect(() => { systemStore.initSequence(); }, []); @@ -114,6 +132,7 @@ export function MainLayout({ children }: { children: React.ReactNode }) { }, [moreMenuOpen]); const currentTabs = (() => { + if (notFound) return notFoundTabs; if (pathname.startsWith("/chat")) return chatTabs; if (pathname.startsWith("/settings")) return settingsTabs; if (pathname.startsWith("/drive")) return driveTabs; @@ -170,15 +189,31 @@ export function MainLayout({ children }: { children: React.ReactNode }) {
); -}); +})); diff --git a/web/src/components/layout/WindowHeader.tsx b/web/src/components/layout/WindowHeader.tsx index 1cd149e..ef76fe5 100644 --- a/web/src/components/layout/WindowHeader.tsx +++ b/web/src/components/layout/WindowHeader.tsx @@ -35,6 +35,10 @@ function getIcon(type: ViewType | null | undefined, customIcon?: string) { case "error": return "error"; case "lyrics_window": return "lyrics"; case "playlist_window": return "queue_music"; + case "auth": return "lock"; + case "welcome_timeline": return "public"; + case "welcome_federation": return "globe"; + case "welcome_dashboard": return "dashboard"; default: return "tab_move"; } } @@ -54,6 +58,10 @@ function getTitle(type: ViewType | null | undefined, mode: string, customTitle?: case "error": return t_("window.systemError"); case "lyrics_window": return t_("window.lyricsWindow"); case "playlist_window": return t_("window.playlist"); + case "auth": return t_("window.authWindow"); + case "welcome_timeline": return t_("window.timelineWindow"); + case "welcome_federation": return t_("window.federationWindow"); + case "welcome_dashboard": return t_("window.dashboardWindow"); default: return mode === "split" ? t_("window.splitView") : t_("window.freeWindow"); } } diff --git a/web/src/components/post/PostItem.tsx b/web/src/components/post/PostItem.tsx index 1cd2c9b..c642da6 100644 --- a/web/src/components/post/PostItem.tsx +++ b/web/src/components/post/PostItem.tsx @@ -1,6 +1,6 @@ "use client"; -import { useMemo } from "react"; +import { useMemo, memo } from "react"; import Link from "next/link"; import Image from "next/image"; import { useRouter } from "next/navigation"; @@ -39,7 +39,7 @@ interface PostItemProps { isDetailView?: boolean; } -export function PostItem({ post, isDetailView }: PostItemProps) { +export const PostItem = memo(function PostItem({ post, isDetailView }: PostItemProps) { const router = useRouter(); const splitViewStore = useSplitViewStore(); const contextMenuStore = useContextMenuStore(); @@ -177,4 +177,4 @@ export function PostItem({ post, isDetailView }: PostItemProps) {
); -} +}); diff --git a/web/src/components/post/TimelineFeed.tsx b/web/src/components/post/TimelineFeed.tsx index faefdea..9ccd98a 100644 --- a/web/src/components/post/TimelineFeed.tsx +++ b/web/src/components/post/TimelineFeed.tsx @@ -1,5 +1,6 @@ "use client"; +import { List } from "react-window"; import { PostItem } from "./PostItem"; import type { TimelinePost } from "@/types/models"; @@ -72,15 +73,30 @@ const mockPosts: TimelinePost[] = [ }, ]; +const ITEM_HEIGHT = 200; + +function PostRow({ index, style }: { index: number; style: React.CSSProperties }) { + const post = mockPosts[index]; + if (!post) return null; + return ( +
+ +
+ ); +} + export function TimelineFeed() { return (
-
- {mockPosts.map((post) => ( - - ))} -
+ + rowComponent={PostRow} + rowCount={mockPosts.length} + rowHeight={ITEM_HEIGHT} + rowProps={{}} + overscanCount={5} + style={{ width: "100%", height: "100%" }} + />
); diff --git a/web/src/components/termity/Termity.tsx b/web/src/components/termity/Termity.tsx index 01cc9ae..b797522 100644 --- a/web/src/components/termity/Termity.tsx +++ b/web/src/components/termity/Termity.tsx @@ -3,6 +3,7 @@ import { useState, useRef, useEffect, useCallback } from "react"; import { useSystemStore } from "@/stores/system"; import { useInstanceStore } from "@/stores/instance"; +import { useUserStore } from "@/stores/user"; import { useFreeWindowStore } from "@/stores/freeWindow"; interface TerminalLine { @@ -10,6 +11,9 @@ interface TerminalLine { text: string; } +const GITHUB_REPO = "CyaniAgent/Asagity"; +const GITHUB_API = `https://api.github.com/repos/${GITHUB_REPO}`; + const COMMANDS: Record }> = { help: { name: "Help", @@ -28,9 +32,9 @@ const COMMANDS: Record { + const res = await fetch(`${GITHUB_API}${path}`, { + headers: { Accept: "application/vnd.github.v3+json" }, + signal: AbortSignal.timeout(8000), + }); + const data = await res.json(); + return { ok: res.status, data }; +} + +function parseArgs(fullArgs: string): Record { + const result: Record = {}; + const tokens = fullArgs.split(/\s+/); + for (const token of tokens) { + const eq = token.indexOf("="); + if (eq > 0) { + result[token.slice(0, eq)] = token.slice(eq + 1); + } else { + result[token] = ""; + } + } + return result; +} + +const TERMITY_PASSWORD = "TermitybyAsagity2026"; + export function Termity() { const systemStore = useSystemStore(); const instanceStore = useInstanceStore(); + const userStore = useUserStore(); + const [isAuthenticated, setIsAuthenticated] = useState(false); + const [passwordInput, setPasswordInput] = useState(""); + const [passwordError, setPasswordError] = useState(false); const [history, setHistory] = useState([ { type: "system", text: "Asagity Recovery Terminal [Termity v2.0.0]" }, { type: "system", text: 'Type "help" to see available commands.' }, @@ -64,6 +97,7 @@ export function Termity() { const [historyIndex, setHistoryIndex] = useState(-1); const containerRef = useRef(null); const inputRef = useRef(null); + const timersRef = useRef>>(new Set()); const scrollToBottom = useCallback(() => { if (containerRef.current) { @@ -75,6 +109,22 @@ export function Termity() { scrollToBottom(); }, [history, scrollToBottom]); + useEffect(() => { + return () => { + timersRef.current.forEach(clearTimeout); + timersRef.current.clear(); + }; + }, []); + + const addTimer = useCallback((fn: () => void, ms: number) => { + const id = setTimeout(() => { + timersRef.current.delete(id); + fn(); + }, ms); + timersRef.current.add(id); + return id; + }, []); + useEffect(() => { inputRef.current?.focus(); }, []); @@ -83,6 +133,16 @@ export function Termity() { setHistory((prev) => [...prev, line]); }, []); + const handlePasswordSubmit = useCallback(() => { + if (passwordInput === TERMITY_PASSWORD) { + setIsAuthenticated(true); + setPasswordError(false); + } else { + setPasswordError(true); + setPasswordInput(""); + } + }, [passwordInput]); + const executeCommand = useCallback( (cmd: string) => { const trimmed = cmd.trim(); @@ -128,7 +188,7 @@ export function Termity() { }); } else if (args[0] === "ping") { addLine({ type: "system", text: "正在测试网络延迟..." }); - setTimeout(() => { + addTimer(() => { addLine({ type: "output", text: "Pong! 延迟: 42ms" }); }, 500); } else if (args[0] === "config") { @@ -145,17 +205,133 @@ export function Termity() { if (args[0] === "--help") { addLine({ type: "output", text: "Function Switch - 子命令列表:" }); Object.entries(COMMANDS.func.subcommands!).forEach(([cmd, desc]) => { - addLine({ type: "output", text: ` ${cmd.padEnd(25)} │ ${desc}` }); + addLine({ type: "output", text: ` ${cmd.padEnd(35)} │ ${desc}` }); }); - } else if (args.join(" ") === "enable DevMode") { - systemStore.enableDevMode(false); - addLine({ type: "output", text: "> Developer Mode ENABLED (Session)" }); - } else if (args.join(" ") === "enable DevMode --forever") { - systemStore.enableDevMode(true); - addLine({ type: "output", text: "> Developer Mode ENABLED (Persistent)" }); - } else if (args.join(" ") === "disable DevMode") { - systemStore.disableDevMode(); - addLine({ type: "output", text: "> Developer Mode DISABLED" }); + addLine({ type: "output", text: "" }); + addLine({ type: "output", text: "Develop 高级命令:" }); + addLine({ type: "output", text: ` ${"latest commit".padEnd(35)} │ 获取 main 分支最近 commit` }); + addLine({ type: "output", text: ` ${"latest release".padEnd(35)} │ 获取 main 分支最新 release` }); + addLine({ type: "output", text: ` ${"{ver} commit branch={br}".padEnd(35)} │ 获取指定分支指定版本 commit` }); + addLine({ type: "output", text: ` ${"{ver} release branch={br}".padEnd(35)} │ 获取指定分支指定版本 release` }); + } else if (args[0] === "enable" && args[1] === "Develop") { + const subArgs = parseArgs(args.slice(2).join(" ")); + if (subArgs["time"] === "meta") { + userStore.developerEnter(); + localStorage.setItem("asagity_dev_persistent", "true"); + addLine({ type: "output", text: "> Developer Entry ENABLED (Persistent)" }); + addLine({ type: "output", text: " 开发者账户已通过持久化存储登录。刷新页面后仍保持登录。" }); + } else { + userStore.developerEnter(); + addLine({ type: "output", text: "> Developer Entry ENABLED (Session)" }); + addLine({ type: "output", text: " 开发者账户已登录。刷新页面后将退出。" }); + } + } else if (args[0] === "disable" && args[1] === "Develop") { + userStore.logout(); + localStorage.removeItem("asagity_dev_persistent"); + addLine({ type: "output", text: "> Developer Account LOGGED OUT" }); + addLine({ type: "output", text: " 已退出开发者账户并解除持久化登录。" }); + } else if (args[0] === "Develop" || args[0] === "develop") { + const developCmd = args.slice(1); + const subArgs = parseArgs(developCmd.join(" ")); + + if (developCmd.length === 0) { + addLine({ type: "output", text: '使用 "func --help" 查看 Develop 命令列表。' }); + break; + } + + const developAction = developCmd[0].toLowerCase(); + + if (developAction === "latest") { + const type = developCmd[1]?.toLowerCase(); + if (type === "commit") { + addLine({ type: "system", text: "正在获取 main 分支最新 commit..." }); + githubFetch("/commits/main").then(({ ok, data }) => { + if (ok !== 200) { + addLine({ type: "error", text: `GitHub API 错误: ${ok}` }); + return; + } + const d = data as { sha: string; commit: { message: string; author: { name: string }; committer: { date: string } } }; + addLine({ type: "output", text: ` SHA: ${d.sha.slice(0, 7)}` }); + addLine({ type: "output", text: ` Message: ${d.commit.message.split("\n")[0]}` }); + addLine({ type: "output", text: ` Author: ${d.commit.author.name}` }); + addLine({ type: "output", text: ` Date: ${d.commit.committer.date}` }); + }); + } else if (type === "release") { + addLine({ type: "system", text: "正在获取 main 分支最新 release..." }); + Promise.all([ + githubFetch("/releases/latest"), + githubFetch("/releases"), + ]).then(([latestRes, releasesRes]) => { + if (latestRes.ok !== 200) { + addLine({ type: "error", text: `GitHub API 错误: ${latestRes.ok}` }); + return; + } + const latest = latestRes.data as { tag_name: string; name: string; body: string; published_at: string }; + addLine({ type: "output", text: ` Version: ${latest.tag_name}` }); + addLine({ type: "output", text: ` Name: ${latest.name}` }); + addLine({ type: "output", text: ` Date: ${latest.published_at}` }); + + if (releasesRes.ok === 200) { + const releases = releasesRes.data as { tag_name: string }[]; + if (releases.length > 1) { + const prev = releases[1]; + if (prev.tag_name !== latest.tag_name) { + addLine({ type: "warning", text: ` ${prev.tag_name} -> ${latest.tag_name} (新版本可用)` }); + } + } + } + + if (latest.body) { + addLine({ type: "output", text: " Changelog:" }); + latest.body.split("\n").slice(0, 8).forEach((line: string) => { + addLine({ type: "output", text: ` ${line}` }); + }); + } + }); + } else { + addLine({ type: "output", text: '用法: func Develop latest commit | latest release' }); + } + } else { + const version = developAction; + const type = developCmd[1]?.toLowerCase(); + const branch = subArgs["branch"] || "main"; + + if (type === "commit") { + addLine({ type: "system", text: `正在获取 ${branch} 分支 ${version} commit...` }); + githubFetch(`/commits/${version}`).then(({ ok, data }) => { + if (ok !== 200) { + addLine({ type: "error", text: `GitHub API 错误: ${ok}` }); + return; + } + const d = data as { sha: string; commit: { message: string; author: { name: string }; committer: { date: string } } }; + addLine({ type: "output", text: ` SHA: ${d.sha.slice(0, 7)}` }); + addLine({ type: "output", text: ` Message: ${d.commit.message.split("\n")[0]}` }); + addLine({ type: "output", text: ` Author: ${d.commit.author.name}` }); + addLine({ type: "output", text: ` Date: ${d.commit.committer.date}` }); + }); + } else if (type === "release") { + addLine({ type: "system", text: `正在获取 ${branch} 分支 ${version} release...` }); + githubFetch(`/releases/tags/${version}`).then(({ ok, data }) => { + if (ok !== 200) { + addLine({ type: "error", text: `GitHub API 错误: ${ok}` }); + return; + } + const d = data as { tag_name: string; name: string; body: string; published_at: string }; + addLine({ type: "output", text: ` Version: ${d.tag_name}` }); + addLine({ type: "output", text: ` Name: ${d.name}` }); + addLine({ type: "output", text: ` Date: ${d.published_at}` }); + if (d.body) { + addLine({ type: "output", text: " Changelog:" }); + d.body.split("\n").slice(0, 8).forEach((line: string) => { + addLine({ type: "output", text: ` ${line}` }); + }); + } + }); + } else { + addLine({ type: "output", text: '用法: func Develop {version} commit branch={branch}' }); + addLine({ type: "output", text: ' func Develop {version} release branch={branch}' }); + } + } } else { addLine({ type: "output", text: '使用 "func --help" 查看可用子命令。' }); } @@ -168,13 +344,13 @@ export function Termity() { addLine({ type: "output", text: ` 别名: ${instanceStore.alias || "asagity.io"}` }); addLine({ type: "output", text: ` 版本: ${instanceStore.version || "2.0.0"}` }); addLine({ type: "output", text: ` 描述: ${instanceStore.description || "Asagity NET"}` }); - addLine({ type: "output", text: ` 开发者模式: ${systemStore.isDevMode ? "已启用" : "未启用"}` }); + addLine({ type: "output", text: ` 开发者: ${userStore.isLoggedIn ? userStore.username || "已登录" : "未登录"}` }); break; } case "exit": { addLine({ type: "system", text: "正在关闭 Termity 会话..." }); - setTimeout(() => { + addTimer(() => { useFreeWindowStore.getState().close(); }, 300); break; @@ -199,7 +375,7 @@ export function Termity() { addLine({ type: "system", text: "" }); }, - [addLine, systemStore, instanceStore] + [addLine, systemStore, instanceStore, userStore] ); const handleKeyDown = useCallback( @@ -234,6 +410,45 @@ export function Termity() { [input, executeCommand, commandHistory, historyIndex] ); + if (!isAuthenticated) { + return ( +
+
+
+
Access required! You need to enter Termity password.
+
{ e.preventDefault(); handlePasswordSubmit(); }} className="flex items-center"> + Password: + { setPasswordInput(e.target.value); setPasswordError(false); }} + className="flex-1 bg-transparent border-none outline-none text-white ml-1 caret-green-500" + spellCheck={false} + autoComplete="off" + autoFocus + /> +
+ {passwordError && ( +
Permission denied. Please try again.
+ )} +
+ +
+ ); + } + return (
= { upload: ArrowUpload24Regular, edit: Edit24Regular, view: ViewDesktop24Regular, + arrow_right_16: ArrowRight16Regular, + arrow_enter_16: ArrowEnter16Regular, + earth_16_filled: Earth16Filled, + timeline_20_filled: Timeline20Filled, + server_link_20_filled: ServerLink20Filled, + chart_multiple_16_filled: ChartMultiple16Filled, }; interface IconProps { @@ -182,7 +195,7 @@ interface IconProps { fontSize?: number | string; } -export function Icon({ name, className = "", filled = false, fontSize }: IconProps) { +export const Icon = memo(function Icon({ name, className = "", filled = false, fontSize }: IconProps) { const baseName = name.replace("_filled", ""); const key = filled ? `${baseName}_filled` : name; const IconComponent = iconMap[key] || iconMap[baseName] || iconMap["tag"]; @@ -193,4 +206,4 @@ export function Icon({ name, className = "", filled = false, fontSize }: IconPro style={fontSize ? { fontSize, width: "1em", height: "1em" } : undefined} /> ); -} +}); diff --git a/web/src/components/ui/SplashScreen.tsx b/web/src/components/ui/SplashScreen.tsx index 00b00c3..e1ee7db 100644 --- a/web/src/components/ui/SplashScreen.tsx +++ b/web/src/components/ui/SplashScreen.tsx @@ -19,7 +19,7 @@ export function SplashScreen() {
- Logo + Logo
diff --git a/web/src/components/welcome/Welcome.tsx b/web/src/components/welcome/Welcome.tsx index 15035e4..ca9e5d3 100644 --- a/web/src/components/welcome/Welcome.tsx +++ b/web/src/components/welcome/Welcome.tsx @@ -2,17 +2,15 @@ import { useState, useEffect, useCallback } from "react"; import Image from "next/image"; -import { useUserStore } from "@/stores/user"; import { useInstanceStore } from "@/stores/instance"; -import { AuthForm } from "@/components/auth/AuthForm"; +import { useFreeWindowStore } from "@/stores/freeWindow"; import { Icon } from "@/components/ui/Icon"; import { useI18n } from "@/components/providers/I18nProvider"; export function Welcome() { - const userStore = useUserStore(); const instanceStore = useInstanceStore(); + const { openFromContext } = useFreeWindowStore(); const [mousePos, setMousePos] = useState({ x: 0, y: 0 }); - const [showAuth, setShowAuth] = useState(false); const { t } = useI18n(); const handleMouseMove = useCallback((e: MouseEvent) => { @@ -26,17 +24,34 @@ export function Welcome() { return () => window.removeEventListener("mousemove", handleMouseMove); }, [handleMouseMove]); + const handleOpenAuth = () => { + openFromContext("auth", { post: null, user: null, chat: null }); + }; + + const handleOpenTimeline = () => { + openFromContext("welcome_timeline", { post: null, user: null, chat: null }); + }; + + const handleOpenFederation = () => { + openFromContext("welcome_federation", { post: null, user: null, chat: null }); + }; + + const handleOpenDashboard = () => { + openFromContext("welcome_dashboard", { post: null, user: null, chat: null }); + }; + + const handleOpenTerminal = () => { + useFreeWindowStore.getState().openTermity(); + }; + return (
{/* Animated Background */}
- {/* Base gradient */}
- - {/* Glow orbs */}
- - {/* Grid floor */}
- - {/* Skyline buildings */}
- {/* Building 1 */}
- {/* Building 2 */}
- {/* Building 3 - Tower */}
- {/* Building 4 */}
- {/* Building 5 */}
- - {/* Floating label */}
- {/* Main Content */} + {/* Right Side Card Stack */}
- {/* Instance Stats */} -
-
- {t("welcome.online")} - 1 -
-
-
- {t("welcome.users")} - 1 -
-
-
- {t("welcome.posts")} - 0 + {/* Panel 1 - Stats Dashboard (light) */} +
+
+
+ {t("welcome.online")} + 1 +
+
+
+ {t("welcome.users")} + 1 +
+
+
+ {t("welcome.posts")} + 0 +
- {/* Main Card */} -
- {/* Accent line */} + {/* Panel 2 - Instance Info (light) */} +
- {!showAuth ? ( - /* Landing Content */ -
- {/* Logo */} -
- Logo -
- -

+
+
+ Logo +
+
+

{instanceStore.name}

- + {instanceStore.alias} - -

- {instanceStore.description} -

- -
- - - -
- ) : ( - /* Auth Form */ -
-
-
- Logo -
-

- {t("welcome.authLayer")} -

- - {t("welcome.authenticateMatrix")} - -
+
- +

+ {instanceStore.description} +

+ +
+ + +
+
-
- + {/* Panel 3 - Browse This Instance (light) */} +
+
+
+
+
+ {t("welcome.browseThisInstance")}
- )} +
+
+
+
+
+
+ +
+ + +
+ + +
+
diff --git a/web/src/components/windows/FreeWindow.tsx b/web/src/components/windows/FreeWindow.tsx index 5071cf5..32d04de 100644 --- a/web/src/components/windows/FreeWindow.tsx +++ b/web/src/components/windows/FreeWindow.tsx @@ -1,6 +1,6 @@ "use client"; -import { useState, useEffect } from "react"; +import { useState, useEffect, useRef, useCallback } from "react"; import { Rnd } from "react-rnd"; import { createPortal } from "react-dom"; import { WindowHeader } from "@/components/layout/WindowHeader"; @@ -43,7 +43,20 @@ export function FreeWindow({ const [position, setPosition] = useState({ x: 0, y: 0 }); const [size, setSize] = useState({ width: initialWidth, height: initialHeight }); const [mounted, setMounted] = useState(false); + const [visible, setVisible] = useState(false); + const [isClosing, setIsClosing] = useState(false); + const [restoreAnim, setRestoreAnim] = useState(false); + const timersRef = useRef>>(new Set()); + + const addTimer = useCallback((fn: () => void, ms: number) => { + const id = setTimeout(() => { + timersRef.current.delete(id); + fn(); + }, ms); + timersRef.current.add(id); + return id; + }, []); useEffect(() => { setMounted(true); @@ -53,24 +66,101 @@ export function FreeWindow({ }); }, [initialWidth, initialHeight]); + useEffect(() => { + return () => { + timersRef.current.forEach(clearTimeout); + timersRef.current.clear(); + }; + }, []); + useEffect(() => { if (isOpen) { requestAnimationFrame(() => setVisible(true)); } else { setVisible(false); + setIsClosing(false); + setIsMinimized(false); + setIsMaximized(false); + setRestoreAnim(false); } }, [isOpen]); if (!mounted) return null; const handleToggleMaximize = () => { - setIsMaximized(!isMaximized); - if (!isMaximized) setIsMinimized(false); + setIsMaximized((prev) => { + if (!prev) setIsMinimized(false); + return !prev; + }); + setIsClosing(false); }; const handleToggleMinimize = () => { - setIsMinimized(!isMinimized); - if (!isMinimized) setIsMaximized(false); + if (isMinimized) { + setIsMinimized(false); + setRestoreAnim(false); + requestAnimationFrame(() => { + requestAnimationFrame(() => { + setRestoreAnim(true); + addTimer(() => setRestoreAnim(false), 300); + }); + }); + } else { + setIsClosing(true); + addTimer(() => { + setIsMinimized(true); + setIsClosing(false); + }, 350); + } + setIsMaximized(false); + }; + + const handleClose = () => { + setIsClosing(true); + addTimer(() => { + setIsClosing(false); + onClose?.(); + }, 200); + }; + + const handleTransitionEnd = () => { + if (restoreAnim) { + setRestoreAnim(false); + } + }; + + const getTransition = () => { + if (isClosing) { + if (isMinimized) return "opacity 200ms ease"; + return "opacity 350ms ease, transform 350ms ease"; + } + if (restoreAnim) { + return "opacity 300ms ease, transform 300ms ease"; + } + if (isMaximized) { + return "all 200ms ease"; + } + return "all 200ms ease"; + }; + + const getTransformStyle = () => { + if (isClosing && !isMinimized) { + return "translateY(100vh) scale(0.8)"; + } + if (restoreAnim) { + return "translateY(0) scale(1)"; + } + if (!visible && !isClosing) { + return "scale(0.95) translateY(10px)"; + } + return "scale(1) translateY(0)"; + }; + + const getOpacity = () => { + if (isClosing && !isMinimized) return 0; + if (restoreAnim) return 1; + if (!visible && !isClosing) return 0; + return 1; }; return createPortal( @@ -103,18 +193,16 @@ export function FreeWindow({ bounds="window" style={{ display: isMinimized ? "none" : undefined, - opacity: visible && !isMinimized ? 1 : 0, - transform: visible && !isMinimized ? "scale(1) translateY(0)" : "scale(0.9) translateY(20px)", - transition: isMaximized - ? "all 0.5s cubic-bezier(0.4,0,0.2,1)" - : "opacity 0.4s cubic-bezier(0.34,1.56,0.64,1), transform 0.4s cubic-bezier(0.34,1.56,0.64,1)", + opacity: getOpacity(), + transform: getTransformStyle(), + transition: getTransition(), + zIndex: 9990, }} className="z-[9990]" + onTransitionEnd={handleTransitionEnd} >
{/* Drag Handle / Header */}
@@ -129,7 +217,7 @@ export function FreeWindow({ disableTransfer={disableTransfer} disableMaximize={disableMaximize} disableMinimize={disableMinimize} - onClose={onClose} + onClose={handleClose} onToggleMaximize={handleToggleMaximize} onToggleMinimize={handleToggleMinimize} onRefresh={onRefresh} diff --git a/web/src/components/windows/WindowManager.tsx b/web/src/components/windows/WindowManager.tsx index b2881d9..767d820 100644 --- a/web/src/components/windows/WindowManager.tsx +++ b/web/src/components/windows/WindowManager.tsx @@ -13,6 +13,12 @@ const LyricsWindow = lazy(() => const PlaylistWindow = lazy(() => import("@/components/music/PlaylistWindow").then((m) => ({ default: m.PlaylistWindow })) ); +const AuthForm = lazy(() => + import("@/components/auth/AuthForm").then((m) => ({ default: m.AuthForm })) +); +const TimelineFeed = lazy(() => + import("@/components/post/TimelineFeed").then((m) => ({ default: m.TimelineFeed })) +); function WindowManagerFallback() { return ( @@ -46,6 +52,14 @@ function getViewConfig(viewType: string | null) { return { title: "Lyrics Window", icon: "lyrics", width: 400, height: 600, disableTransfer: true }; case "playlist_window": return { title: "Playlist", icon: "queue_music", width: 350, height: 500, disableTransfer: true }; + case "auth": + return { title: "身份认证", icon: "lock", width: 420, height: 560, disableMinimize: true }; + case "welcome_timeline": + return { title: "时间线", icon: "public", width: 500, height: 600, disableMinimize: true }; + case "welcome_federation": + return { title: "联邦实例", icon: "globe", width: 500, height: 600, disableMinimize: true }; + case "welcome_dashboard": + return { title: "数据面板", icon: "dashboard", width: 500, height: 600, disableMinimize: true }; default: return { title: "Free Window", icon: "tab_move", width: 450, height: 600 }; } @@ -75,6 +89,14 @@ function renderContent(viewType: string | null, refreshKey: number, browserUrl: return }>; case "playlist_window": return }>; + case "auth": + return }>; + case "welcome_timeline": + return }>; + case "welcome_federation": + return
联邦实例列表开发中
; + case "welcome_dashboard": + return
数据面板开发中
; default: return
未知视图类型
; } diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts index a93d5b2..a191ecf 100644 --- a/web/src/lib/api.ts +++ b/web/src/lib/api.ts @@ -5,6 +5,7 @@ interface ApiOptions { query?: Record; body?: Record | BodyInit | null; method?: "GET" | "POST" | "PUT" | "DELETE" | "PATCH" | "HEAD"; + signal?: AbortSignal; } interface ApiResponse { @@ -37,6 +38,7 @@ async function request( options.body && typeof options.body === "object" && !(options.body instanceof FormData) ? JSON.stringify(options.body) : options.body ?? undefined, + signal: options.signal, ...(options.query ? { url: `${url}?${new URLSearchParams(options.query as Record).toString()}` } : {}), diff --git a/web/src/messages/en-US.json b/web/src/messages/en-US.json index 0118bac..7991afb 100644 --- a/web/src/messages/en-US.json +++ b/web/src/messages/en-US.json @@ -192,6 +192,11 @@ "stop": "Stop", "unknownArtist": "Unknown Artist" }, + "notFound": { + "back": "Go Back", + "goHome": "Back to Timeline", + "message": "Oops, this page could not be found!\nTry refreshing the page, or contact the administrator." + }, "network": { "connectionFailed": "Connection Failed", "connectionFailedWithLogin": "Connection Failed, Please Login Again", @@ -554,13 +559,25 @@ "authenticateMatrix": "Authentication Matrix", "authLayer": "Authentication Layer", "back": "Back", + "browseThisInstance": "Browse This Instance", + "browseTimeline": "Timeline", + "dataDashboard": "Data Dashboard", "directEnter": "Direct Enter", + "federatedInstances": "Federated Instances", + "joinInstance": "Join Instance", + "more": "More...", "online": "Online", "posts": "Posts", + "terminal": "Enter Terminal", + "terminalPassword": "Enter terminal password", "users": "Users" }, "window": { + "authWindow": "Authentication", + "dashboardWindow": "Data Dashboard", + "federationWindow": "Federated Instances", "openInFreeWindow": "Open in Free Window", - "openInSplitView": "Open in Split View" + "openInSplitView": "Open in Split View", + "timelineWindow": "Timeline" } } diff --git a/web/src/messages/ja-JP.json b/web/src/messages/ja-JP.json index f81ed22..3892dc1 100644 --- a/web/src/messages/ja-JP.json +++ b/web/src/messages/ja-JP.json @@ -192,6 +192,11 @@ "stop": "停止", "unknownArtist": "不明なアーティスト" }, + "notFound": { + "back": "前のページに戻る", + "goHome": "タイムラインに戻る", + "message": "おっと、このページは見つかりませんでした!\nページを再読み込みするか、管理者に連絡してください。" + }, "network": { "connectionFailed": "接続失敗", "connectionFailedWithLogin": "接続失敗、再ログインしてください", @@ -554,13 +559,25 @@ "authenticateMatrix": "認証マトリックス", "authLayer": "認証レイヤー", "back": "戻る", + "browseThisInstance": "このインスタンスを閲覧", + "browseTimeline": "タイムライン", + "dataDashboard": "データダッシュボード", "directEnter": "直接入場", + "federatedInstances": "連合インスタンス", + "joinInstance": "インスタンスに参加", + "more": "もっと...", "online": "オンライン", "posts": "投稿", + "terminal": "ターミナルに入る", + "terminalPassword": "ターミナルパスワードを入力してください", "users": "ユーザー" }, "window": { + "authWindow": "認証", + "dashboardWindow": "データダッシュボード", + "federationWindow": "連合インスタンス", "openInFreeWindow": "フリーウィンドウで開く", - "openInSplitView": "分割表示で開く" + "openInSplitView": "分割表示で開く", + "timelineWindow": "タイムライン" } } diff --git a/web/src/messages/zh-CN.json b/web/src/messages/zh-CN.json index eecea99..0979298 100644 --- a/web/src/messages/zh-CN.json +++ b/web/src/messages/zh-CN.json @@ -192,6 +192,11 @@ "stop": "停止", "unknownArtist": "未知艺术家" }, + "notFound": { + "back": "返回上一个页面", + "goHome": "回到时间线", + "message": "啊哦,没有找到这个页面!\n稍后可以刷新页面,或者联系管理员。" + }, "network": { "connectionFailed": "连接失败", "connectionFailedWithLogin": "连接失败,请重新登录", @@ -554,13 +559,25 @@ "authenticateMatrix": "身份验证矩阵", "authLayer": "认证层", "back": "返回", + "browseThisInstance": "浏览这个实例", + "browseTimeline": "时间线", + "dataDashboard": "数据看板", "directEnter": "直接进入", + "federatedInstances": "联合的实例", + "joinInstance": "加入实例", + "more": "更多...", "online": "在线", "posts": "帖子", + "terminal": "进入终端", + "terminalPassword": "请输入终端密码", "users": "用户" }, "window": { + "authWindow": "身份认证", + "dashboardWindow": "数据面板", + "federationWindow": "联邦实例", "openInFreeWindow": "在自由窗口中打开", - "openInSplitView": "在分屏视图中打开" + "openInSplitView": "在分屏视图中打开", + "timelineWindow": "时间线" } } diff --git a/web/src/messages/zh-TW.json b/web/src/messages/zh-TW.json index 929eadd..980493f 100644 --- a/web/src/messages/zh-TW.json +++ b/web/src/messages/zh-TW.json @@ -192,6 +192,11 @@ "stop": "停止", "unknownArtist": "未知藝術家" }, + "notFound": { + "back": "返回上一頁", + "goHome": "回到時間線", + "message": "啊哦,沒有找到這個頁面!\n稍後可以刷新頁面,或者聯繫管理員。" + }, "network": { "connectionFailed": "連線失敗", "connectionFailedWithLogin": "連線失敗,請重新登入", @@ -554,13 +559,25 @@ "authenticateMatrix": "身份驗證矩陣", "authLayer": "認證層", "back": "返回", + "browseThisInstance": "瀏覽本實例", + "browseTimeline": "時間線", + "dataDashboard": "資料看板", "directEnter": "直接進入", + "federatedInstances": "聯合的實例", + "joinInstance": "加入實例", + "more": "更多...", "online": "線上", "posts": "貼文", + "terminal": "進入終端", + "terminalPassword": "請輸入終端密碼", "users": "使用者" }, "window": { + "authWindow": "身份認證", + "dashboardWindow": "資料面板", + "federationWindow": "聯邦實例", "openInFreeWindow": "在自由視窗中開啟", - "openInSplitView": "在分割檢視中開啟" + "openInSplitView": "在分割檢視中開啟", + "timelineWindow": "時間線" } } diff --git a/web/src/stores/freeWindow.ts b/web/src/stores/freeWindow.ts index 4e7e4b2..7b5c729 100644 --- a/web/src/stores/freeWindow.ts +++ b/web/src/stores/freeWindow.ts @@ -117,6 +117,10 @@ export const useFreeWindowStore = create()((set) => ({ isOpen: false, isMaximized: false, isMinimized: false, + currentPost: null, + currentUser: null, + currentChat: null, + currentBrowserUrl: "", }), toggleMaximize: () => diff --git a/web/src/stores/music.ts b/web/src/stores/music.ts index 81d76af..ccf22ac 100644 --- a/web/src/stores/music.ts +++ b/web/src/stores/music.ts @@ -42,6 +42,7 @@ interface MusicState { progressPercentage: number; audioQuality: string; initAudio: () => void; + destroyAudio: () => void; seek: (time: number) => void; togglePlay: () => void; setProgress: (value: number) => void; @@ -71,6 +72,7 @@ const DEFAULT_TRACK: Track = { }; let audioElement: HTMLAudioElement | null = null; +let currentBlobUrl: string | null = null; function parseLrc(lrcContent: string): LyricLine[] { try { @@ -173,6 +175,18 @@ export const useMusicStore = create()( get().fetchMetadata(get().currentTrack.url); }, + destroyAudio: () => { + if (!audioElement) return; + audioElement.pause(); + audioElement.removeAttribute("src"); + audioElement.load(); + audioElement = null; + if (currentBlobUrl) { + URL.revokeObjectURL(currentBlobUrl); + currentBlobUrl = null; + } + }, + seek: (time) => { if (!audioElement) return; audioElement.currentTime = time; @@ -216,7 +230,9 @@ export const useMusicStore = create()( if (pic) { const uint8Array = new Uint8Array(pic.data); const imgBlob = new Blob([uint8Array], { type: pic.format }); + if (currentBlobUrl) URL.revokeObjectURL(currentBlobUrl); albumArt = URL.createObjectURL(imgBlob); + currentBlobUrl = albumArt; } } diff --git a/web/src/stores/soundManager.ts b/web/src/stores/soundManager.ts index 5747742..5109ca1 100644 --- a/web/src/stores/soundManager.ts +++ b/web/src/stores/soundManager.ts @@ -1,12 +1,10 @@ import { create } from "zustand"; -interface SoundCache { - [key: string]: AudioBuffer | null; -} +const MAX_CACHE_SIZE = 10; interface SoundManagerState { audioContext: AudioContext | null; - soundCache: SoundCache; + soundCache: Map; isPreloaded: boolean; isPreloading: boolean; soundRegistry: Record; @@ -19,7 +17,7 @@ interface SoundManagerState { export const useSoundManagerStore = create()((set, get) => ({ audioContext: null, - soundCache: {}, + soundCache: new Map(), isPreloaded: false, isPreloading: false, @@ -44,8 +42,8 @@ export const useSoundManagerStore = create()((set, get) => ({ }, loadSound: async (name, url) => { - if (get().soundCache[name]) { - return get().soundCache[name]; + if (get().soundCache.has(name)) { + return get().soundCache.get(name) ?? null; } try { @@ -57,9 +55,17 @@ export const useSoundManagerStore = create()((set, get) => ({ } const arrayBuffer = await response.arrayBuffer(); const audioBuffer = await ctx.decodeAudioData(arrayBuffer); - set((state) => ({ - soundCache: { ...state.soundCache, [name]: audioBuffer }, - })); + + set((state) => { + const newCache = new Map(state.soundCache); + if (newCache.size >= MAX_CACHE_SIZE) { + const firstKey = newCache.keys().next().value; + if (firstKey) newCache.delete(firstKey); + } + newCache.set(name, audioBuffer); + return { soundCache: newCache }; + }); + return audioBuffer; } catch (err) { console.warn(`SoundManager: Failed to load sound ${name}:`, err); @@ -85,7 +91,7 @@ export const useSoundManagerStore = create()((set, get) => ({ play: async (name) => { const registry = get().soundRegistry; - const buffer = get().soundCache[name] || (await get().loadSound(name, registry[name])); + const buffer = get().soundCache.get(name) || (await get().loadSound(name, registry[name])); if (!buffer) { console.warn(`SoundManager: Cannot play ${name} - not loaded`); return; diff --git a/web/src/stores/theme.ts b/web/src/stores/theme.ts index 528bb88..cc6b281 100644 --- a/web/src/stores/theme.ts +++ b/web/src/stores/theme.ts @@ -9,7 +9,9 @@ interface ThemeState { isDark: boolean; currentMode: "light" | "dark"; modeLabel: string; + _mediaQueryHandler: ((e: MediaQueryListEvent) => void) | null; init: () => void; + destroy: () => void; setPreference: (mode: ColorMode) => void; toggle: () => void; applyColorMode: () => void; @@ -20,6 +22,7 @@ export const useThemeStore = create()( (set, get) => ({ preference: "system", systemPreference: "dark", + _mediaQueryHandler: null, get isDark() { const state = get(); @@ -58,14 +61,26 @@ export const useThemeStore = create()( const systemPref = mediaQuery.matches ? "dark" : "light"; set({ systemPreference: systemPref }); - mediaQuery.addEventListener("change", (e) => { + const handler = (e: MediaQueryListEvent) => { set({ systemPreference: e.matches ? "dark" : "light" }); get().applyColorMode(); - }); + }; + mediaQuery.addEventListener("change", handler); + set({ _mediaQueryHandler: handler }); get().applyColorMode(); }, + destroy: () => { + if (typeof window === "undefined") return; + const handler = get()._mediaQueryHandler; + if (handler) { + const mediaQuery = window.matchMedia("(prefers-color-scheme: dark)"); + mediaQuery.removeEventListener("change", handler); + set({ _mediaQueryHandler: null }); + } + }, + setPreference: (mode) => { set({ preference: mode }); if (typeof window !== "undefined") { diff --git a/web/src/stores/user.ts b/web/src/stores/user.ts index dd1b2c9..e74a6c8 100644 --- a/web/src/stores/user.ts +++ b/web/src/stores/user.ts @@ -95,7 +95,10 @@ export const useUserStore = create()( refreshAccessToken: async () => { try { - const res = await fetch("/api/auth/refresh", { method: "POST" }); + const res = await fetch("/api/auth/refresh", { + method: "POST", + signal: AbortSignal.timeout(10000), + }); if (!res.ok) return false; const data = await res.json(); if (data.ok && data.data) { @@ -125,6 +128,7 @@ export const useUserStore = create()( headers: { Authorization: `Bearer ${state.accessToken}`, }, + signal: AbortSignal.timeout(10000), }); if (!res.ok) throw new Error("Failed to fetch user"); diff --git a/web/src/types/windows.ts b/web/src/types/windows.ts index 4e95a33..5d10b07 100644 --- a/web/src/types/windows.ts +++ b/web/src/types/windows.ts @@ -9,7 +9,11 @@ export type ViewType = | "error" | "termity" | "lyrics_window" - | "playlist_window"; + | "playlist_window" + | "auth" + | "welcome_timeline" + | "welcome_federation" + | "welcome_dashboard"; export type RightViewType = | "post"