diff --git a/.gitignore b/.gitignore index a5d453a..83c9bb8 100644 --- a/.gitignore +++ b/.gitignore @@ -19,6 +19,8 @@ Pipfile Pipfile.lock # Tests files test.* +# Autosaves +*.dia~ ## Deployment files diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..f288702 --- /dev/null +++ b/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/Makefile b/Makefile new file mode 100644 index 0000000..5ede6f3 --- /dev/null +++ b/Makefile @@ -0,0 +1,13 @@ +CSSC := lesscpy +src := $(wildcard app/static/less/*.less) +obj := $(src:app/static/less/%.less=app/static/css/%.css) + +run: css + @flask run + +css: $(obj) + +app/static/css/%.css: app/static/less/%.less + $(CSSC) $< $@ + +.PHONY: css run diff --git a/README.md b/README.md index 1b8781d..7f25d06 100644 --- a/README.md +++ b/README.md @@ -1,11 +1,66 @@ # Planète Casio v5 +## Présentation + +La v4 se fait vieille, écrite en PHP 5 a l'origine elle a pu être mise a jour +vers PHP 7. +Mais le site n'est plus a jour, ne répond plus aux attentes de la communauté +et on ne peut pas le modifier sans un gros travail. + +Pour répondre a tout ces problèmes nous avons décidé de faire une nouvelle +version du site, la v5. +Écrite en Python avec Flask elle doit répondre aux nouvelles attentes de la +communauté. +La v5 est donc un logiciel libre, vous pouvez tous participer a sa création. +Vous pouvez dès maintenant tester la version de pré-production du site +[ici](https://v5.planet-casio.com). + +## Des images + +La page d’accueil, un peu vide pour le moment. +![La page d’accueil, un peu vide pour le moment](demo/index.png) +L'index des forums. +![L'index des forums](demo/forum.png) +L'index des topics de discussion, aussi connu sous l'abus des essais de DS. +![L'index des topics de discussion](demo/index_discussions.png) +Un topic au hasard, et voila le thème sombre. +![Un topic, et le thème sombre sombre](demo/topic_dark.png) +La barre de menu. +![La barre de menu](demo/barre_laterale.png) +Un profil. +![Le profil d'Eragon](demo/profil.png) +Les paramètres utilisateurs. +![Et les paramètres utilisateurs](demo/parametres.png) +El la version mobile de la v5. +![La v5 est même pensé pour les téléphones](demo/mobile.png) + +## Contribuer + +Tu veux aider ? +Tu peut nous aider en allant sur [la démo](https://v5.planet-casio.com/) et +en cherchant des problèmes. +Tu peut aussi venir apporter ton avis dans les réflexions pour l'avancé du site. +Et si tu sait coder en python, nous serons heureux de t'accueillir parmi les +développeurs de la v5. + +## Quelques liens utiles + +[Le wiki du développement](https://gitea.planet-casio.com/devs/PCv5/wiki/00-Home) +[Le topic sur la v4](https://www.planet-casio.com/Fr/forums/topic13736-1-planete-casio-v5.html) +[La RFC des notifications](https://www.planet-casio.com/Fr/forums/topic15828-1-rfc-v5-systeme-de-notifications.html) + ## Code de conduite -Don't be an asshole. +Respectez les règles de Planète Casio. +(cf [La charte d'utilisation du forum](https://www.planet-casio.com/Fr/forums/topic12618-1-charte-dutilisation-du-forum-cuf.html)) ## Style de code * On respecte la PEP8. Je sais c'est relou d'indenter avec des espaces, mais au moins le reste est consistant. * La seule exception concerne la longueur des lignes. Merci d'essayer de respecter les 79 colonnes, mais dans certains cas c'est plus crade de revenir à la ligne, donc blc. * Je conseille d'utiliser Flake8 qui permet de vérifier les erreurs de syntaxe, de style, etc. en live. +* On essaye d'écrire des commits en anglais + +### Licence + +Le code de Planète Casio v5 est sous licence GPLv3+. Voyez [`LICENSE`](LICENSE). diff --git a/REQUIREMENTS.md b/REQUIREMENTS.md index c65f0fc..90660dc 100644 --- a/REQUIREMENTS.md +++ b/REQUIREMENTS.md @@ -6,6 +6,8 @@ Attention, l'environnement est sous `python3`. Vérifiez que ce soit bien le cas La liste de paquets fourni est pour Archlinux, les paquets peuvent avoir des noms légèrement différents dans votre distribution. ``` python3 +python-bleach +python-email-validator python-flask python-flask-login python-flask-mail @@ -15,6 +17,7 @@ python-flask-sqlalchemy python-flask-wtf python-itsdangerous python-ldap +python-markdown python-uwsgi python-psycopg2 python-pillow diff --git a/app/data/forums.yaml b/app/data/forums.yaml index 9f6c931..2485a30 100644 --- a/app/data/forums.yaml +++ b/app/data/forums.yaml @@ -1,10 +1,8 @@ # This file is a list of forums to create when setting up Planète Casio. # # * Keys are used as URLs paths and for unique identification. -# * Prefixes represent the privilege category for a forum. Owning privileges -# with this prefix allows the user to post in this forum and all its -# sub-forum regardless of their settings ("forum-root-*" are hyper powerful). -# * For open forums, use the prefix "open". +# * Prefixes are used to identify privileges for each forum, see groups.yaml +# for details. /: name: Forum de Planète Casio @@ -104,8 +102,8 @@ prefix: discussion descr: Sujets hors-sujet et discussion libre. -# Limited-access board -# Prefixes "admin" and "assoc" are reserved for this and require special +# Limited-access boards +# Prefixes "admin" and "creativecalc" are reserved for this and require special # privileges to list, read and edit topics and messages. /admin: @@ -116,5 +114,5 @@ /creativecalc: name: CreativeCalc - prefix: assoc + prefix: creativecalc descr: Forum privé de l'association CreativeCalc, réservé aux membres. diff --git a/app/data/groups.yaml b/app/data/groups.yaml index 4e34b0c..f81d603 100644 --- a/app/data/groups.yaml +++ b/app/data/groups.yaml @@ -1,63 +1,109 @@ +# LIST OF PRIVILEGES: +# +# Access to specific forums (see forums.yaml for prefix values): +# forum.access. +# forum.post. +# forum.post-news +# forum.post-anywhere +# -> All forums are readable by default except and +# -> All forums are writable by default except , , +# children of , and forums with children ("categories") +# -> Use member.can_access_forum(forum) and member.can_post_in_forum(forum) +# +# Access to extended publication methods: +# publish.schedule-posts +# publish.pin-posts +# publish.shared-files +# +# Moderation: +# edit.posts (includes top comment selection) +# edit.tests +# edit.accounts +# edit.trophies +# delete.posts (includes triple XP removal) +# delete.tests +# delete.accounts +# delete.shared-files +# move.posts +# +# Shoutbox: +# shoutbox.kick +# shoutbox.ban +# +# Miscellaneous: +# misc.unlimited-pms +# misc.dev-infos +# misc.arbitrary-login +# misc.community-login +# misc.admin-panel +# misc.no-upload-limits +# +# TODO: PRIVILEGES NOT YET IMPLEMENTED: +# The features that these privileges control are not implemented yet, or the +# privilege checks are missing. +# +# publish.* +# edit.tests +# delete.tests delete.shared-files +# move.posts +# shoutbox.* +# misc.unlimited-pms + - name: Administrateur css: "color: #ee0000;" descr: "Vous voyez Chuck Norris ? Pareil." - privs: access-admin-board access-assoc-board write-news write-anywhere - upload-shared-files delete-shared-files - edit-posts delete-posts scheduled-posting - delete-content move-public-content move-private-content showcase-content - edit-static-content extract-posts - delete-notes delete-tests - shoutbox-kick shoutbox-ban - unlimited-pms footer-statistics community-login - access-admin-panel edit-account delete-account edit-trophies - delete_notification no-upload-limits + privs: forum.access.admin forum.access.creativecalc forum.post-news + forum.post-anywhere + publish.schedule-posts publish.pin-posts publish.shared-files + edit.posts edit.tests edit.accounts edit.trophies + delete.posts delete.tests delete.accounts delete.shared-files + move.posts + shoutbox.kick shoutbox.ban + misc.unlimited-pms misc.dev-infos misc.admin-panel + misc.no-upload-limits misc.arbitrary-login - name: Modérateur css: "color: green;" descr: "Maîtres du kick, ils sont là pour faire respecter un semblant d'ordre." - privs: access-admin-board - edit-posts delete-posts - move-public-content extract-posts - delete-notes delete-tests - shoutbox-kick shoutbox-ban - unlimited-pms no-upload-limits + privs: forum.access.admin + edit.posts edit.tests + delete.posts delete.tests + move.posts + shoutbox.kick shoutbox.ban + misc.unlimited-pms misc.no-upload-limits - name: Développeur css: "color: #4169e1;" descr: "Les développeurs maintiennent et améliorent le code du site." - privs: access-admin-board - upload-shared-files delete-shared-files - scheduled-posting - edit-static-content - unlimited-pms footer-statistics community-login - access-admin-panel no-upload-limits + privs: forum.access.admin forum.post-anywhere + publish.schedule-posts publish.shared-files + delete.shared-files + misc.unlimited-pms misc.dev-infos misc.community-login misc.admin-panel - name: Rédacteur css: "color: blue;" descr: "Rédigent les meilleurs articles de la page d'accueil, rien que pour vous <3" - privs: access-admin-board write-news - upload-shared-files delete-shared-files - scheduled-posting - showcase-content edit-static-content - no-upload-limits + privs: forum.access.admin forum.post-news + publish.schedule-posts publish.pin-posts publish.shared-files + delete.shared-files + misc.no-upload-limits misc.community-login - name: Responsable communauté css: "color: DarkOrange;" descr: "Anime les pages Twitter et Facebook de Planète Casio et surveille l'évolution du monde autour de nous !" - privs: access-admin-board write-news - upload-shared-files delete-shared-files - scheduled-posting - showcase-content + privs: forum.access.admin forum.post-news + publish.schedule-posts publish.pin-posts publish.shared-files + delete.shared-files misc.community-login - name: Partenaire css: "color: purple;" descr: "Membres de l'équipe d'administration des sites partenaires." - privs: write-news - upload-shared-files delete-shared-files - scheduled-posting + privs: forum.post-news + publish.schedule-posts publish.shared-files + delete.shared-files - name: Compte communautaire css: "background:#d8d8d8; border-radius:4px; color:#303030; padding:1px 2px;" @@ -66,13 +112,13 @@ name: Robot css: "color: #cf25d0;" descr: "♫ Je suis Nono, le petit robot, l'ami d'Ulysse ♫" - privs: shoutbox-post shoutbox-kick shoutbox-ban + privs: shoutbox.kick shoutbox.ban - name: Membre de CreativeCalc css: "color: #222222;" descr: "CreativeCalc est l'association qui gère Planète Casio." - privs: access-assoc-board + privs: forum.access.creativecalc - - name: No login - css: "color: #888888;" - descr: "Compte dont l'accès au site est désactivé." + name: No login + css: "color: #888888;" + descr: "Compte dont l'accès au site est désactivé." diff --git a/app/data/trophies.yaml b/app/data/trophies.yaml index 54e5dca..a4c50a6 100644 --- a/app/data/trophies.yaml +++ b/app/data/trophies.yaml @@ -101,7 +101,7 @@ - name: Programmeur du dimanche is_title: False - description: Publier 5 prorammes. + description: Publier 5 programmes. hidden: False - name: Codeur invétéré diff --git a/app/forms/account.py b/app/forms/account.py index e9bee41..227ac72 100644 --- a/app/forms/account.py +++ b/app/forms/account.py @@ -1,6 +1,7 @@ from flask_wtf import FlaskForm -from wtforms import StringField, PasswordField, BooleanField, TextAreaField, SubmitField, DecimalField, SelectField -from wtforms.fields.html5 import DateField, EmailField +from wtforms import StringField, PasswordField, BooleanField, TextAreaField, SubmitField, DecimalField, SelectField, RadioField +from wtforms.fields.datetime import DateField +from wtforms.fields.simple import EmailField from wtforms.validators import InputRequired, Optional, Email, EqualTo from flask_wtf.file import FileField # Cuz' wtforms' FileField is shitty import app.utils.validators as vd @@ -10,271 +11,177 @@ class RegistrationForm(FlaskForm): username = StringField( 'Pseudonyme', description='Ce nom est définitif !', - validators=[ - InputRequired(), - vd.name.valid, - vd.name.available, - ], - ) + validators=[InputRequired(), vd.name.valid, vd.name.available]) + email = EmailField( 'Adresse Email', validators=[ InputRequired(), Email(message="Adresse email invalide."), - vd.email, - ], - ) + vd.email + ]) + password = PasswordField( 'Mot de passe', - validators=[ - InputRequired(), - vd.password.is_strong, - ], - ) + validators=[InputRequired(), vd.password.is_strong]) + password2 = PasswordField( 'Répéter le mot de passe', validators=[ InputRequired(), - EqualTo('password', message="Les mots de passe doivent être identiques."), - ], - ) + EqualTo('password', message="Les mots de passe doivent être identiques.") + ]) + guidelines = BooleanField( """J'accepte les CGU""", - validators=[ - InputRequired(), - ], - ) + validators=[InputRequired()]) + newsletter = BooleanField( 'Inscription à la newsletter', - description='Un mail par trimestre environ, pour être prévenu des concours, évènements et nouveautés.', - ) - submit = SubmitField( - "S'inscrire", - ) + description='Un mail par trimestre environ, pour être prévenu des concours, évènements et nouveautés.') + + submit = SubmitField("S'inscrire") -class UpdateAccountForm(FlaskForm): +class UpdateAccountBaseForm(FlaskForm): avatar = FileField( 'Avatar', - validators=[ - Optional(), - vd.file.is_image, - vd.file.avatar_size, - ], - ) + validators=[Optional(), vd.file.is_image, vd.file.avatar_size]) + email = EmailField( 'Adresse email', validators=[ Optional(), Email(message="Addresse email invalide."), vd.email, - vd.password.old_password, - ], - ) + # For users: vd.password.old_password is added dynamically + ]) + password = PasswordField( 'Nouveau mot de passe', + description="L'ancien mot de passe ne pourra pas être récupéré !", validators=[ Optional(), vd.password.is_strong, - vd.password.old_password, - ], - ) - password2 = PasswordField( - 'Répéter le mot de passe', - validators=[ - Optional(), - EqualTo('password', message="Les mots de passe doivent être identiques."), - ], - ) - old_password = PasswordField( - 'Mot de passe actuel', - validators=[ - Optional(), - ], - ) + # For users: vd.password.old_password is added dynamically + ]) + birthday = DateField( 'Anniversaire', - validators=[ - Optional(), - ], - ) + validators=[Optional()]) + signature = TextAreaField( 'Signature', - validators=[ - Optional(), - ] - ) + validators=[Optional()]) + biography = TextAreaField( 'Présentation', - validators=[ - Optional(), - ] - ) + validators=[Optional()]) + title = SelectField( 'Titre', coerce=int, validators=[ Optional(), - vd.own_title, - ] - ) + # For users: vd.own_title (admins can assign any title!) + ]) + newsletter = BooleanField( 'Inscription à la newsletter', - description='Un mail par trimestre environ, pour être prévenu des concours, évènements et nouveautés.', - ) + description='Un mail par trimestre environ, pour être prévenu des concours, évènements et nouveautés.') + submit = SubmitField('Mettre à jour') -class DeleteAccountForm(FlaskForm): +class UpdateAccountForm(UpdateAccountBaseForm): + password2 = PasswordField( + 'Répéter le mot de passe', + validators=[ + Optional(), + EqualTo('password', message="Les mots de passe doivent être identiques.") + ]) + + old_password = PasswordField( + 'Mot de passe actuel', + validators=[Optional()]) + + theme = RadioField( + 'Thème du site', + choices=[ + ('default_theme', 'Planète Casio v5'), + ('FK_dark_theme', 'Thème sombre (FlamingKite)'), + ('Tituya_v43_theme', 'Thème Planète Casio v4 (Tituya)'), + ]) + + +class AdminUpdateAccountForm(UpdateAccountBaseForm): + username = StringField( + 'Pseudonyme', + validators=[Optional(), vd.name.valid, vd.name.available]) + + email_confirmed = BooleanField( + "Confirmer l'email", + description="Si décoché, l'utilisateur devra demander explicitement un" + " email de validation, ou faire valider son adresse email par un" + " administrateur.") + + xp = DecimalField( + 'XP', + validators=[Optional()]) + + +class DeleteAccountBaseForm(FlaskForm): + transfer = BooleanField( + 'Conserver les posts sous forme anonyme', + description="Aucune information personnelle n'est conservée ; seul le texte de posts reste. Cela permet de garder un historique fidèle des échanges.", + default=True) + delete = BooleanField( 'Confirmer la suppression', - validators=[ - InputRequired(), - ], - description='Attention, cette opération est irréversible !' - ) + validators=[InputRequired()], + description='Attention, cette opération est irréversible !') + + submit = SubmitField('Supprimer le compte') + + +class DeleteAccountForm(DeleteAccountBaseForm): old_password = PasswordField( 'Mot de passe', - validators=[ - InputRequired(), - vd.password.old_password, - ], - ) - submit = SubmitField( - 'Supprimer le compte', - ) + validators=[InputRequired(), vd.password.old_password]) + + +class AdminDeleteAccountForm(DeleteAccountBaseForm): + pass class AskResetPasswordForm(FlaskForm): email = EmailField( 'Adresse email', - validators=[ - Optional(), - Email(message="Addresse email invalide."), - ], - ) + validators=[Optional(), Email(message="Addresse email invalide.")]) + submit = SubmitField('Valider') class ResetPasswordForm(FlaskForm): password = PasswordField( 'Mot de passe', - validators=[ - Optional(), - vd.password.is_strong, - ], - ) + validators=[Optional(), vd.password.is_strong]) + password2 = PasswordField( 'Répéter le mot de passe', validators=[ Optional(), - EqualTo('password', message="Les mots de passe doivent être identiques."), - ], - ) + EqualTo('password', message="Les mots de passe doivent être identiques.") + ]) + submit = SubmitField('Valider') -class AdminUpdateAccountForm(FlaskForm): - username = StringField( - 'Pseudonyme', - validators=[ - Optional(), - vd.name.valid, - vd.name.available, - ], - ) - avatar = FileField( - 'Avatar', - validators=[ - Optional(), - vd.file.is_image, - vd.file.avatar_size, - ], - ) - email = EmailField( - 'Adresse email', - validators=[ - Optional(), - Email(message="Addresse email invalide."), - vd.email, - ], - ) - email_confirmed = BooleanField( - "Confirmer l'email", - description="Si décoché, l'utilisateur devra demander explicitement un email " - "de validation, ou faire valider son adresse email par un administrateur.", - ) - password = PasswordField( - 'Mot de passe', - description="L'ancien mot de passe ne pourra pas être récupéré !", - validators=[ - Optional(), - vd.password.is_strong, - ], - ) - xp = DecimalField( - 'XP', - validators=[ - Optional(), - ] - ) - birthday = DateField( - 'Anniversaire', - validators=[ - Optional(), - ], - ) - signature = TextAreaField( - 'Signature', - validators=[ - Optional(), - ], - ) - biography = TextAreaField( - 'Présentation', - validators=[ - Optional(), - ], - ) - title = SelectField( - 'Titre', - coerce=int, - validators=[ - Optional(), - # Admin can set any title to any member! - ] - ) - newsletter = BooleanField( - 'Inscription à la newsletter', - description='Un mail par trimestre environ, pour être prévenu des concours, évènements et nouveautés.', - ) - submit = SubmitField( - 'Mettre à jour', - ) - - class AdminAccountEditTrophyForm(FlaskForm): # Boolean inputs are generated on-the-fly from trophy list - submit = SubmitField( - 'Modifier', - ) + submit = SubmitField('Modifier') class AdminAccountEditGroupForm(FlaskForm): # Boolean inputs are generated on-the-fly from group list - submit = SubmitField( - 'Modifier', - ) - - -class AdminDeleteAccountForm(FlaskForm): - delete = BooleanField( - 'Confirmer la suppression', - validators=[ - InputRequired(), - ], - description='Attention, cette opération est irréversible !', - ) - submit = SubmitField( - 'Supprimer le compte', - ) + submit = SubmitField('Modifier') diff --git a/app/forms/forum.py b/app/forms/forum.py index a6aac90..d28d873 100644 --- a/app/forms/forum.py +++ b/app/forms/forum.py @@ -1,22 +1,47 @@ from flask_wtf import FlaskForm -from wtforms import StringField, SubmitField, TextAreaField, MultipleFileField +from wtforms import StringField, SubmitField, TextAreaField, MultipleFileField, SelectField from wtforms.validators import InputRequired, Length import app.utils.validators as vd +from app.utils.antibot_field import AntibotField class CommentForm(FlaskForm): - message = TextAreaField('Message', validators=[InputRequired()]) - attachments = MultipleFileField('Pièces-jointes', - validators=[vd.file.optional, vd.file.count, vd.file.extension, - vd.file.size, vd.file.namelength]) + message = TextAreaField( + 'Message', + validators=[InputRequired()]) + + attachments = MultipleFileField( + 'Pièces-jointes', + validators=[ + vd.file.optional, + vd.file.count, + vd.file.extension, + vd.file.size, + vd.file.namelength + ]) + submit = SubmitField('Commenter') class AnonymousCommentForm(CommentForm): - pseudo = StringField('Pseudo', + pseudo = StringField( + 'Pseudo', validators=[InputRequired(), vd.name.valid, vd.name.available]) + ab = AntibotField() + class CommentEditForm(CommentForm): + # Boolean fields to remove files are added dynamically + attachments = MultipleFileField( + 'Ajouter des pièces jointes', + validators=[ + vd.file.optional, + vd.file.count, + vd.file.extension, + vd.file.size, + vd.file.namelength + ]) + submit = SubmitField('Modifier') @@ -25,10 +50,25 @@ class AnonymousCommentEditForm(CommentEditForm, AnonymousCommentForm): class TopicCreationForm(CommentForm): - title = StringField('Nom du sujet', + title = StringField( + 'Nom du sujet', validators=[InputRequired(), Length(min=3, max=128)]) + submit = SubmitField('Créer le sujet') class AnonymousTopicCreationForm(TopicCreationForm, AnonymousCommentForm): - pass + ab = AntibotField() + + +class TopicEditForm(CommentEditForm): + title = StringField( + 'Nom du sujet', + validators=[InputRequired(), Length(min=3, max=128)]) + + # List of forums is generated at runtime + forum = SelectField( + 'Forum', + validators=[InputRequired()]) + + submit = SubmitField('Modifier le sujet') diff --git a/app/forms/login.py b/app/forms/login.py index 498a319..c43fd56 100644 --- a/app/forms/login.py +++ b/app/forms/login.py @@ -5,13 +5,13 @@ from wtforms.validators import InputRequired class LoginForm(FlaskForm): username = StringField( - 'Identifiant', + 'Identifiant', validators=[ InputRequired(), ], ) password = PasswordField( - 'Mot de passe', + 'Mot de passe', validators=[ InputRequired(), ], diff --git a/app/forms/login_as.py b/app/forms/login_as.py new file mode 100644 index 0000000..d3235f0 --- /dev/null +++ b/app/forms/login_as.py @@ -0,0 +1,15 @@ +from flask_wtf import FlaskForm +from wtforms import StringField, SubmitField +from wtforms.validators import InputRequired + + +class LoginAsForm(FlaskForm): + username = StringField( + 'Identifiant', + validators=[ + InputRequired(), + ], + ) + submit = SubmitField( + 'Vandaliser', + ) diff --git a/app/forms/poll.py b/app/forms/poll.py index 845d7f9..28da0ec 100644 --- a/app/forms/poll.py +++ b/app/forms/poll.py @@ -1,7 +1,7 @@ from flask_wtf import FlaskForm from wtforms import StringField, SubmitField, TextAreaField, SelectField, \ BooleanField -from wtforms.fields.html5 import DateTimeField +from wtforms.fields.datetime import DateTimeField from wtforms.validators import InputRequired, Optional from datetime import datetime, timedelta diff --git a/app/forms/post.py b/app/forms/post.py new file mode 100644 index 0000000..2cd810e --- /dev/null +++ b/app/forms/post.py @@ -0,0 +1,12 @@ +from flask_wtf import FlaskForm +from wtforms import StringField, SelectField, SubmitField + + +class MovePost(FlaskForm): + # List of threads is generated at runtime + thread = SelectField('Fil de discussion', coerce=int, validators=[]) + submit = SubmitField('Déplacer') + +class SearchThread(FlaskForm): + name = StringField("Nom d'un topic, programme, …") + search = SubmitField('Rechercher') diff --git a/app/forms/search.py b/app/forms/search.py index 17718f3..d33f333 100644 --- a/app/forms/search.py +++ b/app/forms/search.py @@ -1,6 +1,6 @@ from flask_wtf import FlaskForm from wtforms import StringField, SubmitField -from wtforms.fields.html5 import DateField +from wtforms.fields.datetime import DateField from wtforms.validators import InputRequired, Optional diff --git a/app/forms/trophy.py b/app/forms/trophy.py index 4a77b02..2558bd2 100644 --- a/app/forms/trophy.py +++ b/app/forms/trophy.py @@ -2,6 +2,7 @@ from flask_wtf import FlaskForm from wtforms import StringField, SubmitField, BooleanField from wtforms.validators import InputRequired, Optional from flask_wtf.file import FileField # Cuz' wtforms' FileField is shitty +import app.utils.validators class TrophyForm(FlaskForm): @@ -34,6 +35,9 @@ class TrophyForm(FlaskForm): css = StringField( 'CSS', description='CSS appliqué au titre, le cas échéant.', + validators=[ + app.utils.validators.css, + ], ) submit = SubmitField( 'Envoyer', diff --git a/app/models/attachment.py b/app/models/attachment.py index c237fab..0bc5856 100644 --- a/app/models/attachment.py +++ b/app/models/attachment.py @@ -13,7 +13,8 @@ class Attachment(db.Model): name = db.Column(db.Unicode(64)) # The comment linked with - comment_id = db.Column(db.Integer, db.ForeignKey('comment.id'), nullable=False) + comment_id = db.Column(db.Integer, db.ForeignKey('comment.id'), + nullable=False, index=True) comment = db.relationship('Comment', backref=backref('attachments')) # The size of the file @@ -45,6 +46,10 @@ class Attachment(db.Model): def delete_file(self): try: - os.delete(self.path) + os.remove(self.path) except FileNotFoundError: pass + + def delete(self): + self.delete_file() + db.session.delete(self) diff --git a/app/models/comment.py b/app/models/comment.py index e9834d1..f5cb42a 100644 --- a/app/models/comment.py +++ b/app/models/comment.py @@ -15,11 +15,16 @@ class Comment(Post): # Parent thread thread_id = db.Column(db.Integer, db.ForeignKey('thread.id'), - nullable=False) + nullable=False, index=True) thread = db.relationship('Thread', backref=backref('comments', lazy='dynamic'), foreign_keys=thread_id) + # attachments (relation from Attachment) + + @property + def is_top_comment(self): + return self.id == self.thread.top_comment_id def __init__(self, author, text, thread): """ @@ -43,7 +48,9 @@ class Comment(Post): def delete(self): """Recursively delete post and all associated contents.""" - # FIXME: Attached files? + for a in self.attachments: + a.delete() + db.session.commit() db.session.delete(self) def __repr__(self): diff --git a/app/models/forum.py b/app/models/forum.py index f1cfe60..231b539 100644 --- a/app/models/forum.py +++ b/app/models/forum.py @@ -20,7 +20,8 @@ class Forum(db.Model): lazy=True, foreign_keys=parent_id) # Other fields populated automatically through relations: - # List of topics in this exact forum (of type Topic) + # Children forums + # List of topics in this exact forum (of type Topic) # Some configuration TOPICS_PER_PAGE = 30 @@ -36,6 +37,19 @@ class Forum(db.Model): else: self.parent = parent + def is_news(self): + """Whether this forum is a news board.""" + return (self.parent is not None) and (self.parent.prefix == "news") + + def is_default_accessible(self): + """Whether this forum can be read without privileges.""" + return (self.prefix != "admin") and (self.prefix != "creativecalc") + + def is_default_postable(self): + """Whether this forum can be posted to without privileges.""" + return self.is_default_accessible() and (not self.is_news()) and \ + (self.sub_forums == []) + def post_count(self): """Number of posts in every topic of the forum, without subforums.""" # TODO: optimize this with real ORM diff --git a/app/models/notification.py b/app/models/notification.py index 7ace4fa..315e30f 100644 --- a/app/models/notification.py +++ b/app/models/notification.py @@ -11,7 +11,8 @@ class Notification(db.Model): href = db.Column(db.UnicodeText) date = db.Column(db.DateTime, default=datetime.now()) - owner_id = db.Column(db.Integer, db.ForeignKey('member.id'),nullable=False) + owner_id = db.Column(db.Integer, db.ForeignKey('member.id'), + nullable=False, index=True) owner = db.relationship('Member', backref='notifications', foreign_keys=owner_id) @@ -22,3 +23,6 @@ class Notification(db.Model): def __repr__(self): return f'' + + def delete(self): + db.session.delete(self) diff --git a/app/models/poll.py b/app/models/poll.py index 9a6d0b2..547b91b 100644 --- a/app/models/poll.py +++ b/app/models/poll.py @@ -57,13 +57,10 @@ class Poll(db.Model): def delete(self): """Deletes a poll and its answers""" - # TODO: move this out of class definition? - for answer in SpecialPrivilege.query.filter_by(poll_id=self.id).all(): - db.session.delete(answer) + for a in self.answers: + db.session.delete(a) db.session.commit() - db.session.delete(self) - db.session.commit() # Common properties and methods @property @@ -106,7 +103,7 @@ class PollAnswer(db.Model): id = db.Column(db.Integer, primary_key=True) # Poll - poll_id = db.Column(db.Integer, db.ForeignKey('poll.id')) + poll_id = db.Column(db.Integer, db.ForeignKey('poll.id'), index=True) poll = db.relationship('Poll', backref=backref('answers'), foreign_keys=poll_id) diff --git a/app/models/post.py b/app/models/post.py index 0f6dc2c..85d2dea 100644 --- a/app/models/post.py +++ b/app/models/post.py @@ -10,18 +10,17 @@ class Post(db.Model): # Unique Post ID for the whole site id = db.Column(db.Integer, primary_key=True) # Post type (polymorphic discriminator) - type = db.Column(db.String(20)) + type = db.Column(db.String(20), index=True) # Creation and edition date date_created = db.Column(db.DateTime) - date_modified = db.Column(db.DateTime) + date_modified = db.Column(db.DateTime, index=True) # Post author - author_id = db.Column(db.Integer, db.ForeignKey('user.id'), nullable=False) + author_id = db.Column(db.Integer, db.ForeignKey('user.id'), nullable=False, + index=True) author = db.relationship('User', backref="posts", foreign_keys=author_id) - # TODO: Post attachments? - __mapper_args__ = { 'polymorphic_identity': __tablename__, 'polymorphic_on': type diff --git a/app/models/program.py b/app/models/program.py index 8ce6095..592e2e1 100644 --- a/app/models/program.py +++ b/app/models/program.py @@ -40,5 +40,8 @@ class Program(Post): p = Program(topic.author, topic.title, topic.thread) topic.promotion = p + def delete(self): + db.session.delete(self) + def __repr__(self): return f'' diff --git a/app/models/thread.py b/app/models/thread.py index 418c6bc..8dfaa52 100644 --- a/app/models/thread.py +++ b/app/models/thread.py @@ -9,7 +9,8 @@ class Thread(db.Model): id = db.Column(db.Integer, primary_key=True) # Top comment - top_comment_id = db.Column(db.Integer, db.ForeignKey('comment.id')) + top_comment_id = db.Column(db.Integer, + db.ForeignKey('comment.id', use_alter=True)) top_comment = db.relationship('Comment', foreign_keys=top_comment_id) # Post owning the thread, set only by Topic, Program, etc. In general, you diff --git a/app/models/topic.py b/app/models/topic.py index abc00bf..89383d5 100644 --- a/app/models/topic.py +++ b/app/models/topic.py @@ -9,6 +9,8 @@ class Topic(Post): __mapper_args__ = { 'polymorphic_identity': __tablename__, + # Because there is an extra relation to Post (promotion), SQLAlchemy + # cannot guess which Post we inherit from; specify here. 'inherit_condition': id == Post.id } @@ -21,7 +23,8 @@ class Topic(Post): title = db.Column(db.Unicode(128)) # Parent forum - forum_id = db.Column(db.Integer, db.ForeignKey('forum.id'), nullable=False) + forum_id = db.Column(db.Integer, db.ForeignKey('forum.id'), nullable=False, + index=True) forum = db.relationship('Forum', backref=backref('topics', lazy='dynamic'), foreign_keys=forum_id) @@ -52,7 +55,8 @@ class Topic(Post): def delete(self): """Recursively delete topic and all associated contents.""" - self.thread.delete() + if self.promotion is None: + self.thread.delete() db.session.delete(self) def __repr__(self): diff --git a/app/models/trophy.py b/app/models/trophy.py index 74936b0..c0336ce 100644 --- a/app/models/trophy.py +++ b/app/models/trophy.py @@ -28,6 +28,13 @@ class Trophy(db.Model): self.description = description self.hidden = hidden + def delete(self): + for owner in self.owners: + owner.del_trophy(self) + db.session.add(owner) + db.session.commit() + db.session.delete(self) + def __repr__(self): return f'' @@ -52,4 +59,4 @@ class Title(Trophy): # Many-to-many relation for users earning trophies TrophyMember = db.Table('trophy_member', db.Model.metadata, db.Column('tid', db.Integer, db.ForeignKey('trophy.id')), - db.Column('uid', db.Integer, db.ForeignKey('member.id'))) + db.Column('uid', db.Integer, db.ForeignKey('member.id'), index=True)) diff --git a/app/models/user.py b/app/models/user.py index e3b7c30..325040d 100644 --- a/app/models/user.py +++ b/app/models/user.py @@ -3,11 +3,17 @@ from flask_login import UserMixin from sqlalchemy import func as SQLfunc from os.path import isfile from PIL import Image + from app import app, db from app.models.priv import SpecialPrivilege, Group, GroupMember, \ GroupPrivilege from app.models.trophy import Trophy, TrophyMember, Title from app.models.notification import Notification +from app.models.post import Post +from app.models.comment import Comment +from app.models.topic import Topic +from app.models.program import Program + import app.utils.unicode_names as unicode_names import app.utils.ldap as ldap from app.utils.unicode_names import normalize @@ -20,11 +26,11 @@ import os class User(UserMixin, db.Model): - """ Website user that performs actions on the post """ + """ Any website user, logged in (Member) or not (Guest) """ __tablename__ = 'user' - # User ID, should be used to refer to any user. Thea actual user can either + # User ID, should be used to refer to any user. The actual user can either # be a guest (with IP as key) or a member (with this ID as key). id = db.Column(db.Integer, primary_key=True) # User type (polymorphic discriminator) @@ -55,18 +61,18 @@ class Guest(User): # ID of the [User] entry id = db.Column(db.Integer, db.ForeignKey('user.id'), primary_key=True) # Reusable username, cannot be chosen as the name of a member - # but will be distinguished at rendering time if a member take it later + # but will be distinguished at rendering time if a member takes it later name = db.Column(db.Unicode(User.NAME_MAXLEN)) def __init__(self, name): self.name = name def __repr__(self): - return f'' + return f'' class Member(User): - """ Registered user with full access to the website's services """ + """ Registered user with full access to the site's features """ __tablename__ = 'member' __mapper_args__ = {'polymorphic_identity': __tablename__} @@ -108,17 +114,20 @@ class Member(User): # Settings newsletter = db.Column(db.Boolean, default=False) + theme = db.Column(db.Unicode(32)) # Relations trophies = db.relationship('Trophy', secondary=TrophyMember, back_populates='owners') - topics = db.relationship('Topic') - programs = db.relationship('Program') - comments = db.relationship('Comment') - # Displayed title - # title_id = db.Column(db.Integer, db.ForeignKey('title.id')) - # title = db.relationship('Title', foreign_keys=title_id) + # Access to polymorphic posts + # TODO: Check that the query uses the double index on Post.{author_id,type} + def comments(self): + return db.session.query(Comment).filter(Post.author_id==self.id).all() + def topics(self): + return db.session.query(Topic).filter(Post.author_id==self.id).all() + def programs(self): + return db.session.query(Program).filter(Post.author_id==self.id).all() # Other fields populated automatically through relations: # List of unseen notifications (of type Notification) @@ -132,25 +141,60 @@ class Member(User): self.email_confirmed = not V5Config.ENABLE_EMAIL_CONFIRMATION if not V5Config.USE_LDAP: self.set_password(password) - # Workflow with LDAP enabled is User → Postgresql → LDAP → set password + # Workflow with LDAP enabled is User → PostgreSQL → LDAP → set password self.xp = 0 + self.theme = 'default_theme' self.bio = "" self.signature = "" self.birthday = None + def generate_guest_name(self): + """Generates a unique guest name to transfer contents to.""" + count = 0 + while Guest.query.filter_by(name=f"{self.name}_{count}").first(): + count += 1 + return f"{self.name}_{count}" + + def transfer_posts(self, other): + """ + Transfers all the posts to another user. This is generally used to + transfer ownership to a newly-created Guest before deleting an account. + """ + for t in self.topics(): + t.author = other + db.session.add(t) + for p in self.programs(): + p.author = other + db.session.add(p) + for c in self.comments(): + c.author = other + db.session.add(c) + + def delete_posts(self): + """Deletes the user's posts.""" + for t in self.topics(): + t.delete() + for p in self.programs(): + p.delete() + for c in self.comments(): + c.delete() + def delete(self): """ - Deletes the user and the associated information: - * Special privileges + Deletes the user, but not the posts; use either transfer_posts() or + delete_posts() before calling this. """ - for sp in SpecialPrivilege.query.filter_by(mid=self.id).all(): db.session.delete(sp) + + self.trophies = [] + db.session.add(self) db.session.commit() db.session.delete(self) - db.session.commit() + + # Privilege checks def priv(self, priv): """Check whether the member has the specified privilege.""" @@ -166,6 +210,46 @@ class Member(User): sp = SpecialPrivilege.query.filter_by(mid=self.id).all() return sorted(row.priv for row in sp) + def can_access_forum(self, forum): + """Whether this member can read the forum's contents.""" + return forum.is_default_accessible() or \ + self.priv(f"forum.access.{forum.prefix}") + + def can_post_in_forum(self, forum): + """Whether this member can post in the forum.""" + return forum.is_default_postable() or \ + (forum.is_news() and self.priv("forum.post-news")) or \ + self.priv("forum.post.{forum.prefix}") or \ + self.priv("forum.post-anywhere") + + def can_access_post(self, post): + """Whether this member can access the post's forum (if any).""" + if post.type == "comment" and post.thread.owner_topic: + return self.can_access_forum(post.thread.owner_post.forum) + # Posts from other types of content are all public + return True + + def can_edit_post(self, post): + """Whether this member can edit the post.""" + return self.can_access_post(post) and \ + ((post.author == self) or self.priv("edit.posts")) + + def can_delete_post(self, post): + """Whether this member can delete the post.""" + return self.can_access_post(post) and \ + ((post.author == self) or self.priv("delete.posts")) + + def can_punish_post(self, post): + """Whether this member can delete the post with penalty.""" + return self.can_access_post(post) and self.priv("delete.posts") + + def can_set_topcomment(self, comment): + """Whether this member can designate the comment as top comment.""" + if comment.type != "comment": + return False + post = comment.thread.owner_post + return self.can_edit_post(post) and (comment.author == post.author) + def update(self, **data): """ Update all or part of the user's metadata. The [data] dictionary @@ -179,6 +263,7 @@ class Member(User): "newsletter" bool Newsletter setting "xp" int Experience points "avatar" File Avatar image + "theme" str Name of theme file For future compatibility, other attributes are silently ignored. None values can be specified and are ignored. @@ -212,6 +297,8 @@ class Member(User): self.set_avatar(data["avatar"]) if "title" in data: self.title = Title.query.get(data["title"]) + if "theme" in data: + self.theme = data["theme"] # For admins only if "email_confirmed" in data: @@ -363,12 +450,8 @@ class Member(User): else: self.del_trophy(trophies[level]) - if context in ["new-post", "new-program", "new-tutorial", "new-test", - None]: - # FIXME: Use ORM tools with careful, non-circular imports - post_count = db.session.execute(f"""SELECT COUNT(*) FROM post - INNER JOIN member ON member.id = post.author_id - WHERE member.id = {self.id}""").first()[0] + if context in ["new-post","new-program","new-tutorial","new-test",None]: + post_count = len(self.posts) levels = { 20: "Premiers mots", @@ -379,7 +462,7 @@ class Member(User): progress(levels, post_count) if context in ["new-program", None]: - program_count = self.programs.count() + program_count = len(self.programs()) levels = { 5: "Programmeur du dimanche", diff --git a/app/processors/menu.py b/app/processors/menu.py index d7b2007..b45bbf6 100644 --- a/app/processors/menu.py +++ b/app/processors/menu.py @@ -1,3 +1,4 @@ +from flask_login import current_user from app import app, db from app.forms.login import LoginForm from app.forms.search import SearchForm @@ -5,6 +6,7 @@ from app.models.forum import Forum from app.models.topic import Topic + @app.context_processor def menu_processor(): """ All items used to render main menu. Includes search form """ @@ -19,8 +21,16 @@ def menu_processor(): INNER JOIN post ON post.id = comment.id GROUP BY topic.id ORDER BY MAX(post.date_created) DESC - LIMIT 10;""") + LIMIT 20;""") last_active_topics = [Topic.query.get(id) for id in raw] + # Filter the topics the user can view and limit to 10 + if current_user.is_authenticated: + f = lambda t: current_user.can_access_forum(t.forum) + else: + f = lambda t: t.forum.is_default_accessible() + + last_active_topics = list(filter(f, last_active_topics))[:10] + return dict(login_form=login_form, search_form=search_form, main_forum=main_forum, last_active_topics=last_active_topics) diff --git a/app/processors/stats.py b/app/processors/stats.py index 0a2c893..cb1ed26 100644 --- a/app/processors/stats.py +++ b/app/processors/stats.py @@ -5,4 +5,4 @@ from app import app @app.before_request def request_time(): g.request_start_time = time() - g.request_time = lambda: "%.5fs" % (time() - g.request_start_time) + g.request_time = lambda: time() - g.request_start_time diff --git a/app/processors/utilities.py b/app/processors/utilities.py index 7200fb8..0b8edf2 100644 --- a/app/processors/utilities.py +++ b/app/processors/utilities.py @@ -2,6 +2,7 @@ from app import app from flask import url_for from config import V5Config from slugify import slugify +from app.utils.login_as import is_vandal @app.context_processor def utilities_processor(): @@ -12,4 +13,5 @@ def utilities_processor(): _url_for=lambda route, args, **other: url_for(route, **args, **other), V5Config=V5Config, slugify=slugify, + is_vandal=is_vandal ) diff --git a/app/routes/__init__.py b/app/routes/__init__.py index 28cdc9c..b39260d 100644 --- a/app/routes/__init__.py +++ b/app/routes/__init__.py @@ -3,7 +3,7 @@ from app.routes import index, search, users, tools, development from app.routes.account import login, account, notification, polls from app.routes.admin import index, groups, account, trophies, forums, \ - attachments, config, members, polls + attachments, config, members, polls, login_as from app.routes.forum import index, topic from app.routes.polls import vote, delete from app.routes.posts import edit diff --git a/app/routes/account/account.py b/app/routes/account/account.py index 241f91a..31720ef 100644 --- a/app/routes/account/account.py +++ b/app/routes/account/account.py @@ -3,12 +3,13 @@ from flask_login import login_required, current_user, logout_user from app import app, db from app.forms.account import UpdateAccountForm, RegistrationForm, \ DeleteAccountForm, AskResetPasswordForm, ResetPasswordForm -from app.models.user import Member +from app.models.user import Guest, Member from app.models.trophy import Title from app.utils.render import render from app.utils.send_mail import send_validation_mail, send_reset_password_mail from app.utils.priv_required import guest_only import app.utils.ldap as ldap +import app.utils.validators as vd from itsdangerous import URLSafeTimedSerializer from config import V5Config @@ -20,8 +21,15 @@ def edit_account(): titles = [(t.id, t.name) for t in current_user.trophies if isinstance(t, Title)] titles.insert(0, (-1, "Membre")) form.title.choices = titles + + extra_vd = { + "email": [vd.password.old_password], + "password": [vd.password.old_password], + "title": [vd.own_title], + } + if form.submit.data: - if form.validate_on_submit(): + if form.is_submitted() and form.validate(extra_validators=extra_vd): current_user.update( avatar=form.avatar.data or None, email=form.email.data or None, @@ -30,7 +38,8 @@ def edit_account(): signature=form.signature.data, bio=form.biography.data, title=form.title.data, - newsletter=form.newsletter.data + newsletter=form.newsletter.data, + theme=form.theme.data ) db.session.merge(current_user) db.session.commit() @@ -39,6 +48,8 @@ def edit_account(): return redirect(request.url) else: flash('Erreur lors de la modification', 'error') + else: + form.theme.data = current_user.theme or 'default_theme' return render('account/account.html', scripts=["+scripts/entropy.js"], form=form) @@ -88,9 +99,20 @@ def reset_password(token): @login_required def delete_account(): del_form = DeleteAccountForm() + if del_form.submit.data: if del_form.validate_on_submit(): - db.session.delete(current_user) + if del_form.transfer.data: + guest = Guest(current_user.generate_guest_name()) + db.session.add(guest) + db.session.commit() + current_user.transfer_posts(guest) + db.session.commit() + else: + current_user.delete_posts() + db.session.commit() + + current_user.delete() logout_user() db.session.commit() flash('Compte supprimé', 'ok') @@ -98,6 +120,7 @@ def delete_account(): else: flash('Erreur lors de la suppression du compte', 'error') del_form.delete.data = False # Force to tick to delete the account + return render('account/delete_account.html', del_form=del_form) diff --git a/app/routes/account/notification.py b/app/routes/account/notification.py index c73269e..db7f631 100644 --- a/app/routes/account/notification.py +++ b/app/routes/account/notification.py @@ -27,11 +27,11 @@ def delete_notification(id=None): if notification: # Only current user or admin can delete notifications if notification.owner_id == current_user.id: - db.session.delete(notification) + notification.delete() db.session.commit() return redirect(url_for('list_notifications')) elif 'delete_notification' in current_user.privs: - db.session.delete(notification) + notification.delete() db.session.commit() if request.referrer: return redirect(request.referrer) @@ -41,7 +41,7 @@ def delete_notification(id=None): abort(404) elif id == "all": for n in current_user.notifications: - db.session.delete(n) + n.delete() db.session.commit() return redirect(url_for('list_notifications')) # TODO: add something to allow an admin to delete all notifs for a user diff --git a/app/routes/admin/account.py b/app/routes/admin/account.py index 1d4c04c..3cf49d5 100644 --- a/app/routes/admin/account.py +++ b/app/routes/admin/account.py @@ -2,7 +2,7 @@ from flask import flash, redirect, url_for, request from flask_login import current_user from wtforms import BooleanField from app.utils.priv_required import priv_required -from app.models.user import Member +from app.models.user import Guest, Member from app.models.trophy import Trophy, Title from app.models.priv import Group from app.forms.account import AdminUpdateAccountForm, AdminDeleteAccountForm, \ @@ -14,7 +14,7 @@ from config import V5Config @app.route('/admin/compte//editer', methods=['GET', 'POST']) -@priv_required('access-admin-panel', 'edit-account') +@priv_required('misc.admin-panel', 'edit.accounts') def adm_edit_account(user_id): user = Member.query.filter_by(id=user_id).first_or_404() @@ -27,11 +27,13 @@ def adm_edit_account(user_id): for t in Trophy.query.all(): setattr(TrophyForm, f't{t.id}', BooleanField(t.name)) + setattr(TrophyForm, "trophies", {f't{t.id}': t for t in Trophy.query.all()}) setattr(TrophyForm, "user_trophies", [f't{t.id}' for t in user.trophies]) trophy_form = TrophyForm(prefix="trophies") for g in Group.query.all(): setattr(GroupForm, f'g{g.id}', BooleanField(g.name)) + setattr(GroupForm, "groups", {f'g{g.id}': g for g in Group.query.all()}) setattr(GroupForm, "user_groups", [f'g{g.id}' for g in user.groups]) group_form = GroupForm(prefix="group") @@ -119,24 +121,40 @@ def adm_edit_account(user_id): @app.route('/admin/compte//supprimer', methods=['GET', 'POST']) -@priv_required('access-admin-panel', 'delete-account') +@priv_required('misc.admin-panel', 'delete.accounts') def adm_delete_account(user_id): + # A user deleting their own account will be disconnected user = Member.query.filter_by(id=user_id).first_or_404() - # Note: A user deleting their own account will be disconnected. + # TODO: Number of comments by *other* members which will be deleted + stats = { + 'comments': len(user.comments()), + 'topics': len(user.topics()), + 'programs': len(user.programs()), + 'groups': len(user.groups), + 'privs': len(user.special_privileges()), + } - # TODO: Add an overview of what will be deleted. - # * How many posts will be turned into guest posts - # * Option: purely delete the posts in question - # * How many PMs will be deleted (can't unassign PMs) - # * etc. del_form = AdminDeleteAccountForm() if del_form.submit.data: if del_form.validate_on_submit(): + if del_form.transfer.data: + guest = Guest(user.generate_guest_name()) + db.session.add(guest) + db.session.commit() + user.transfer_posts(guest) + db.session.commit() + else: + user.delete_posts() + db.session.commit() + user.delete() + db.session.commit() flash('Compte supprimé', 'ok') return redirect(url_for('adm')) else: flash('Erreur lors de la suppression du compte', 'error') del_form.delete.data = False # Force to tick to delete the account - return render('admin/delete_account.html', user=user, del_form=del_form) + + return render('admin/delete_account.html', user=user, stats=stats, + del_form=del_form) diff --git a/app/routes/admin/attachments.py b/app/routes/admin/attachments.py index b8b3bdf..2439509 100644 --- a/app/routes/admin/attachments.py +++ b/app/routes/admin/attachments.py @@ -6,7 +6,7 @@ from app.utils.render import render # TODO: add pagination & moderation tools (deletion) @app.route('/admin/fichiers', methods=['GET']) -@priv_required('access-admin-panel') +@priv_required('misc.admin-panel') def adm_attachments(): attachments = Attachment.query.all() diff --git a/app/routes/admin/config.py b/app/routes/admin/config.py index 102287d..b950fbd 100644 --- a/app/routes/admin/config.py +++ b/app/routes/admin/config.py @@ -4,7 +4,7 @@ from app import app from config import V5Config @app.route('/admin/config', methods=['GET']) -@priv_required('access-admin-panel') +@priv_required('misc.admin-panel') def adm_config(): config = {k: getattr(V5Config, k) for k in [ "DOMAIN", "DB_NAME", "USE_LDAP", "LDAP_ROOT", "LDAP_ENV", diff --git a/app/routes/admin/forums.py b/app/routes/admin/forums.py index 000d872..c3bfb52 100644 --- a/app/routes/admin/forums.py +++ b/app/routes/admin/forums.py @@ -4,7 +4,7 @@ from app.models.forum import Forum from app import app, db @app.route('/admin/forums', methods=['GET']) -@priv_required('access-admin-panel') +@priv_required('misc.admin-panel') def adm_forums(): main_forum = Forum.query.filter_by(parent=None).first() diff --git a/app/routes/admin/groups.py b/app/routes/admin/groups.py index 6c81bb1..3c2ff3d 100644 --- a/app/routes/admin/groups.py +++ b/app/routes/admin/groups.py @@ -10,7 +10,7 @@ import os @app.route('/admin/groupes', methods=['GET', 'POST']) -@priv_required('access-admin-panel') +@priv_required('misc.admin-panel') def adm_groups(): # Users with either groups or special privileges users_groups = Member.query.join(GroupMember) diff --git a/app/routes/admin/index.py b/app/routes/admin/index.py index bd0c7cc..e5fbe70 100644 --- a/app/routes/admin/index.py +++ b/app/routes/admin/index.py @@ -4,6 +4,6 @@ from app import app @app.route('/admin', methods=['GET']) -@priv_required('access-admin-panel') +@priv_required('misc.admin-panel') def adm(): return render('admin/index.html') diff --git a/app/routes/admin/login_as.py b/app/routes/admin/login_as.py new file mode 100644 index 0000000..208775a --- /dev/null +++ b/app/routes/admin/login_as.py @@ -0,0 +1,88 @@ +from flask import request, flash, make_response, redirect, url_for, abort +from flask_login import current_user, login_user, logout_user, login_required +from itsdangerous import Serializer +from itsdangerous.exc import BadSignature +from app import app +from app.utils.render import render +from app.utils.login_as import is_vandal +from app.utils.unicode_names import normalize +from app.models.user import Member +from app.models.priv import Group +from app.forms.login_as import LoginAsForm + + +@app.route("/admin/vandalisme", methods=['GET', 'POST']) +@login_required +def adm_login_as(): + """ Show a basic form and login as arbitrary user when asked """ + + # Basic permission + if (not current_user.priv("misc.arbitrary-login") and + not current_user.priv("misc.community-login")): + abort(403) + if is_vandal(): + flash("Vous êtes déjà authentifié", "error") + return redirect(url_for('index')) + + # Handle form + form = LoginAsForm() + if form.validate_on_submit(): + norm = normalize(form.username.data) + user = Member.query.filter_by(norm=norm).one() + if user is None: + flash("Utilisateur invalide", "error") + return render('admin/login_as.html', form=form) + + # Apply for community login + g = Group.query.filter_by(name="Compte communautaire").one() + is_community = g in user.groups + if not is_community and not current_user.priv("misc.arbitrary-login"): + abort(403) + + # Create a safe token to flee when needed + s = Serializer(app.config["SECRET_KEY"]) + vandal_token = s.dumps(current_user.id) + + # Login and display some messages + login_user(user) + if user.name == "GLaDOS": + flash("Vous espérez quoi exactement ? Survivre ? " + "Dans ce cas, évitez de me faire du mal.") + else: + flash(f"Connecté en tant que {user.name}") + + # Return the response + resp = make_response(redirect(url_for('index'))) + resp.set_cookie('vandale', vandal_token) + return resp + + # Else return form + return render('admin/login_as.html', form=form) + +@app.route("/admin/vandalisme/fuir") +@login_required +def adm_logout_as(): + """ Log out as a vandalized user, login back as admin """ + s = Serializer(app.config["SECRET_KEY"]) + + vandal_token = request.cookies.get('vandale') + if vandal_token is None: + abort(403) + + try: + id = s.loads(vandal_token) + except BadSignature: + flash("Vous avez vraiment agi de manière stupide.", "error") + abort(403) + + user = Member.query.get(id) + logout_user() + login_user(user) + + if request.referrer: + resp = make_response(redirect(request.referrer)) + else: + resp = make_response(redirect(url_for('index'))) + + resp.set_cookie('vandale', '', expires=0) + return resp diff --git a/app/routes/admin/members.py b/app/routes/admin/members.py index 407d661..b780a38 100644 --- a/app/routes/admin/members.py +++ b/app/routes/admin/members.py @@ -6,7 +6,7 @@ from app import app, db @app.route('/admin/membres', methods=['GET', 'POST']) -@priv_required('access-admin-panel') +@priv_required('misc.admin-panel') def adm_members(): users = Member.query.all() users = sorted(users, key = lambda x: x.name) diff --git a/app/routes/admin/trophies.py b/app/routes/admin/trophies.py index 80265bc..b0ac084 100644 --- a/app/routes/admin/trophies.py +++ b/app/routes/admin/trophies.py @@ -7,7 +7,7 @@ from app import app, db @app.route('/admin/trophees', methods=['GET', 'POST']) -@priv_required('access-admin-panel', 'edit-trophies') +@priv_required('misc.admin-panel', 'edit.trophies') def adm_trophies(): form = TrophyForm() if request.method == "POST": @@ -31,7 +31,7 @@ def adm_trophies(): @app.route('/admin/trophees//editer', methods=['GET', 'POST']) -@priv_required('access-admin-panel', 'edit-trophies') +@priv_required('misc.admin-panel', 'edit.trophies') def adm_edit_trophy(trophy_id): trophy = Trophy.query.filter_by(id=trophy_id).first_or_404() @@ -59,7 +59,7 @@ def adm_edit_trophy(trophy_id): @app.route('/admin/trophees//supprimer', methods=['GET', 'POST']) -@priv_required('access-admin-panel', 'edit-trophies') +@priv_required('misc.admin-panel', 'edit.trophies') def adm_delete_trophy(trophy_id): trophy = Trophy.query.filter_by(id=trophy_id).first_or_404() @@ -67,8 +67,7 @@ def adm_delete_trophy(trophy_id): del_form = DeleteTrophyForm() if request.method == "POST": if del_form.validate_on_submit(): - # TODO: Remove relationship with users that have the trophy - db.session.delete(trophy) + trophy.delete() db.session.commit() flash('Trophée supprimé', 'ok') return redirect(url_for('adm_trophies')) diff --git a/app/routes/forum/index.py b/app/routes/forum/index.py index aa0d209..d843872 100644 --- a/app/routes/forum/index.py +++ b/app/routes/forum/index.py @@ -20,21 +20,18 @@ def forum_index(): @app.route('/forum//', methods=['GET', 'POST']) @app.route('/forum//p/', methods=['GET', 'POST']) def forum_page(f, page=1): + if not f.is_default_accessible() and not ( + current_user.is_authenticated and current_user.can_access_forum(f)): + abort(403) + if current_user.is_authenticated: form = TopicCreationForm() else: form = AnonymousTopicCreationForm() - # TODO: do not hardcode name of news forums if form.validate_on_submit() and ( - # User can write anywhere - (current_user.is_authenticated and current_user.priv('write-anywhere')) - # Forum is news forum TODO: add good condition to check if it's news - or ("/actus" in f.url and current_user.is_authenticated - and current_user.priv('write-news')) - # Forum is not news and is a leaf: - or ("/actus" not in f.url and not f.sub_forums)) and ( - V5Config.ENABLE_GUEST_POST or current_user.is_authenticated): + (V5Config.ENABLE_GUEST_POST and f.is_default_postable()) or \ + (current_user.is_authenticated and current_user.can_post_in_forum(f))): # Manage author if current_user.is_authenticated: @@ -83,4 +80,17 @@ def forum_page(f, page=1): topics = f.topics.order_by(Topic.date_created.desc()).paginate( page, Forum.TOPICS_PER_PAGE, True) - return render('/forum/forum.html', f=f, topics=topics, form=form) + # Count comments; this direct request avoids performing one request for + # each topic.thread.comments.count() in the view, which the database + # doesn't really appreciate performance-wise. + selection = " OR ".join(f"thread_id={t.thread.id}" for t in topics.items) + selection = "WHERE " + selection if selection else "" + + comment_counts = db.session.execute(f""" + SELECT thread_id, COUNT(*) FROM comment {selection} + GROUP BY thread_id + """) + comment_counts = dict(list(comment_counts)) + + return render('/forum/forum.html', f=f, topics=topics, form=form, + comment_counts=comment_counts) diff --git a/app/routes/forum/topic.py b/app/routes/forum/topic.py index c5dab42..ebb2be7 100644 --- a/app/routes/forum/topic.py +++ b/app/routes/forum/topic.py @@ -18,6 +18,10 @@ from datetime import datetime def forum_topic(f, page): t, page = page + if not f.is_default_accessible() and not ( + current_user.is_authenticated and current_user.can_access_forum(f)): + abort(403) + # Quick n' dirty workaround to converters if f != t.forum: abort(404) @@ -27,8 +31,10 @@ def forum_topic(f, page): else: form = AnonymousCommentForm() - if form.validate_on_submit() and \ - (V5Config.ENABLE_GUEST_POST or current_user.is_authenticated): + if form.validate_on_submit() and ( + V5Config.ENABLE_GUEST_POST or \ + (current_user.is_authenticated and current_user.can_post_in_forum(f))): + # Manage author if current_user.is_authenticated: author = current_user @@ -70,8 +76,8 @@ def forum_topic(f, page): if page == -1: page = (t.thread.comments.count() - 1) // Thread.COMMENTS_PER_PAGE + 1 - comments = t.thread.comments.paginate(page, - Thread.COMMENTS_PER_PAGE, True) + comments = t.thread.comments.order_by(Comment.date_created.asc()) \ + .paginate(page, Thread.COMMENTS_PER_PAGE, True) # Anti-necropost last_com = t.thread.comments.order_by(desc(Comment.date_modified)).first() diff --git a/app/routes/polls/delete.py b/app/routes/polls/delete.py index 1d41af8..d2cf665 100644 --- a/app/routes/polls/delete.py +++ b/app/routes/polls/delete.py @@ -18,11 +18,7 @@ def poll_delete(poll_id): form = DeletePollForm() if form.validate_on_submit(): - for a in poll.answers: - db.session.delete(a) - db.session.commit() - - db.session.delete(poll) + poll.delete() db.session.commit() flash('Le sondage a été supprimé', 'info') diff --git a/app/routes/posts/edit.py b/app/routes/posts/edit.py index f8de2aa..b62b2ee 100644 --- a/app/routes/posts/edit.py +++ b/app/routes/posts/edit.py @@ -1,14 +1,23 @@ from app import app, db +from app.models.attachment import Attachment +from app.models.comment import Comment +from app.models.forum import Forum from app.models.post import Post +from app.models.program import Program +from app.models.thread import Thread +from app.models.topic import Topic +from app.models.user import Member from app.utils.render import render from app.utils.check_csrf import check_csrf -from app.forms.forum import CommentEditForm, AnonymousCommentEditForm +from app.forms.forum import CommentEditForm, AnonymousCommentEditForm, TopicEditForm +from app.forms.post import MovePost, SearchThread +from wtforms import BooleanField from urllib.parse import urlparse -from flask import redirect, url_for, abort, request +from flask import redirect, url_for, abort, request, flash from flask_login import login_required, current_user +from sqlalchemy import text -@app.route('/post/', methods=['GET','POST']) -# TODO: Allow guest edit of posts +@app.route('/post/editer/', methods=['GET','POST']) @login_required def edit_post(postid): # TODO: Maybe not safe @@ -17,42 +26,168 @@ def edit_post(postid): p = Post.query.filter_by(id=postid).first_or_404() - # TODO: Check whether privileged user has access to board - if p.author != current_user and not current_user.priv("edit-posts"): + # Check permissions. TODO: Allow guests to edit their posts? + if not current_user.can_edit_post(p): abort(403) - if p.type == "comment": - form = CommentEditForm() - - if form.validate_on_submit(): - p.text = form.message.data - - if form.submit.data: - db.session.add(p) - db.session.commit() - - return redirect(referrer) - - form.message.data = p.text - return render('forum/edit_comment.html', comment=p, form=form) + if isinstance(p, Comment): + base = CommentEditForm + comment = p + elif isinstance(p, Topic): + base = TopicEditForm + comment = p.thread.top_comment else: abort(404) + class TheForm(base): + pass + for a in comment.attachments: + setattr(TheForm, f'a{a.id}', BooleanField(f'a{a.id}')) + setattr(TheForm, 'attachment_list', + { f'a{a.id}': a for a in comment.attachments }) + form = TheForm() + + if isinstance(p, Topic): + forums = sorted(Forum.query.all(), key=lambda f: f.url) + forums = [f for f in forums if current_user.can_post_in_forum(f)] + form.forum.choices = [(f.url, f"{f.url}: {f.name}") for f in forums] + + if form.validate_on_submit(): + comment.text = form.message.data + + # Remove attachments + for id, a in form.attachment_list.items(): + if form[id].data: + a.delete() + + # Add new attachments + attachments = [] + for file in form.attachments.data: + if file.filename != "": + a = Attachment(file, comment) + attachments.append((a, file)) + db.session.add(a) + + db.session.add(comment) + + if isinstance(p, Topic): + p.title = form.title.data + f = Forum.query.filter_by(url=form.forum.data).first_or_404() + if current_user.can_post_in_forum(f): + p.forum = f + db.session.merge(p) + + db.session.commit() + + for a, file in attachments: + a.set_file(file) + + # Determine topic URL now, in case forum was changed + if isinstance(p, Topic): + return redirect(url_for('forum_topic', f=p.forum, page=(p,1))) + else: + return redirect(referrer) + + # Non-submitted form + if isinstance(p, Comment): + form.message.data = p.text + return render('forum/edit_comment.html', comment=p, form=form) + elif isinstance(p, Topic): + form.message.data = p.thread.top_comment.text + form.title.data = p.title + form.forum.data = p.forum.url + return render('forum/edit_topic.html', t=p, form=form) + @app.route('/post/supprimer/', methods=['GET','POST']) @login_required @check_csrf def delete_post(postid): + next_page = request.referrer p = Post.query.filter_by(id=postid).first_or_404() + xp = -1 - # TODO: Check whether privileged user has access to board - if p.author != current_user and not current_user.priv("delete-posts"): + if not current_user.can_delete_post(p): abort(403) - for a in p.attachments: - a.delete_file() - db.session.delete(a) + # Users who need to have their trophies updated + authors = set() + + # When deleting topics, return to forum page + if isinstance(p, Topic): + next_page = url_for('forum_page', f=p.forum) + xp = -2 + + for comment in p.thread.comments: + if isinstance(comment.author, Member): + comment.author.add_xp(-1) + db.session.merge(comment.author) + authors.add(comment.author) + + if isinstance(p.author, Member): + factor = 3 if request.args.get('penalty') == 'True' else 1 + p.author.add_xp(xp * factor) + db.session.merge(p.author) + authors.add(p.author) + + p.delete() db.session.commit() - db.session.delete(p) - db.session.commit() + for author in authors: + author.update_trophies("new-post") + + return redirect(next_page) + +@app.route('/post/entete/', methods=['GET']) +@login_required +@check_csrf +def set_post_topcomment(postid): + comment = Post.query.filter_by(id=postid).first_or_404() + + if current_user.can_set_topcomment(comment): + comment.thread.top_comment = comment + db.session.add(comment.thread) + db.session.commit() + return redirect(request.referrer) + + +@app.route('/post/deplacer/', methods=['GET', 'POST']) +@login_required +def move_post(postid): + comment = Post.query.filter_by(id=postid).first_or_404() + + if not current_user.can_edit_post(comment): + abort(403) + + if not isinstance(comment, Comment): + flash("Vous ne pouvez pas déplacer un message principal", 'error') + abort(403) + + move_form = MovePost(prefix="move_") + search_form = SearchThread(prefix="thread_") + keyword = search_form.name.data if search_form.validate_on_submit() else "" + + # Get 10 last corresponding threads + # TODO: add support for every MainPost + req = text("""SELECT thread.id, topic.title FROM thread + INNER JOIN topic ON topic.thread_id = thread.id + WHERE lower(topic.title) LIKE lower(:keyword) + ORDER BY thread.id DESC LIMIT 10""") + threads = list(db.session.execute(req, {'keyword': '%'+keyword+'%'})) + move_form.thread.choices = [(t[0], f"{t[1]}") for t in threads] + + if move_form.validate_on_submit(): + thread = Thread.query.get_or_404(move_form.thread.data) + owner_post = thread.owner_post + + if isinstance(owner_post, Topic): + t = owner_post + if not current_user.can_access_forum(t.forum): + abort(403) + comment.thread = thread + db.session.add(comment) + db.session.commit() + return redirect(url_for('forum_topic', f=t.forum, page=(t,1))) + + return render('post/move_post.html', comment=comment, + search_form=search_form, move_form=move_form) diff --git a/app/static/css/admin/form.css b/app/static/css/admin/form.css deleted file mode 100644 index f7bcb8d..0000000 --- a/app/static/css/admin/form.css +++ /dev/null @@ -1,59 +0,0 @@ -.form .avatar { - width: 128px; height: 128px; -} - -.form .avatar + input[type="file"] { - margin: 16px 0 0 0; - vertical-align: middle; -} - -.form form > div:not(:last-child) { - margin-bottom: 15px; -} - -.form form label { - display: inline-block; - margin-bottom: 5px; -} - -.form input { - cursor: pointer; /* don't know why it is not a cursor by default */ -} - -.form input[type='text'], -.form input[type='email'], -.form input[type='date'], -.form input[type='password'], -.form input[type='search'], -.form textarea { - display: block; - width: 100%; padding: 6px 8px; - border: 1px solid #c8c8c8; - - /* Transitions when resizing with the mouse produces apparent lag */ - transition: all .15s ease, width 0s, height 0s; -} -.form input[type='text']:focus, -.form input[type='email']:focus, -.form input[type='date']:focus, -.form input[type='password']:focus, -.form input[type='search']:focus, -.form textarea:focus { - border-color: #91bfef; - box-shadow: 0 0 0 3px rgba(87, 143, 228, 0.4); -} - -.form textarea { - max-width: 100%; - resize: vertical; -} - -.form input[type="submit"] { - /*width: 20%;*/ -} - -.form form .msgerror { - color: red; - font-weight: 400; - margin-top: 5px; -} diff --git a/app/static/css/container.css b/app/static/css/container.css index 1e03e6e..06fe90a 100644 --- a/app/static/css/container.css +++ b/app/static/css/container.css @@ -1,51 +1,22 @@ .container { - margin-left: 110px; + margin-left: 110px; +} +@media screen and (max-width:849px) { + .container { + margin-left: 0; + } } - section { width: 80%; - margin: 20px auto 0 auto; + margin: 20px auto 0 auto; } - -section h1 { - margin-top: 0; - border-bottom: 1px solid #d8d8d8; - font-family: Cantarell; font-weight: bold; - font-size: 26px; color: #101010; -} - -section h2 { - margin: 24px 0 16px 0; - border-bottom: 1px solid #d8d8d8; - font-family: Cantarell; font-weight: bold; - font-size: 18px; color: #101010; - padding-bottom: 2px; -} - -section .avatar { - display: block; - width: 128px; height: 128px; -} - - -/* Some grid */ -.flex-grid { - display: flex; - flex-flow: row wrap; -} -.flex-grid > * { - min-width: 250px; - flex: auto; -} -/* Two columns */ -.flex-grid.fg2 > * { - width: 50%; -} -/* Three columns */ -.flex-grid.fg3 > * { - width: 33%; -} -/* Four columns */ -.flex-grid.fg4 > * { - width: 25%; +@media screen and (max-width:1449px) { + section { + width: 90%; + } } +@media screen and (max-width:1199px) { + section { + width: 95%; + } +} \ No newline at end of file diff --git a/app/static/css/editor.css b/app/static/css/editor.css deleted file mode 100644 index 16b0ac0..0000000 --- a/app/static/css/editor.css +++ /dev/null @@ -1,22 +0,0 @@ -.editor div { - display: flex; flex-direction: row; - flex-wrap: wrap; align-items: center; - margin-bottom: 5px; -} -.editor button { - height: 25px; margin: 0 0px; padding: 0 3px; - border: var(--border); border-radius: 2px; - cursor: pointer; - background: var(--background); -} -.editor button > img { - opacity: .7; -} -.editor button:hover, -.editor button:focus { - border: var(--border-focused); -} -.editor button:hover > img, -.editor button:focus > img { - opacity: 1; -} diff --git a/app/static/css/flash.css b/app/static/css/flash.css index 7f08e3b..3067713 100644 --- a/app/static/css/flash.css +++ b/app/static/css/flash.css @@ -1,33 +1,29 @@ -/* - flash overlay -*/ - .flash { - margin: 5px auto; - display: flex; - align-items: center; - width: 80%; - font-size: 14px; - border-bottom: 5px solid var(--info); - border-radius: 1px; - box-shadow: var(--shadow); + margin: 5px auto; + display: flex; + align-items: center; + width: 80%; + font-size: 14px; + border-bottom: 5px solid var(--info); + border-radius: 1px; + box-shadow: var(--shadow); } .flash.info { - border-color: var(--info); + border-color: var(--info); } .flash.ok { - border-color: var(--ok); + border-color: var(--ok); } .flash.warning { - border-color: var(--warn); + border-color: var(--warn); } .flash.error { - border-color: var(--error); + border-color: var(--error); } .flash span { - flex-grow: 1; - margin: 15px 10px 10px 0; + flex-grow: 1; + margin: 15px 10px 10px 0; } .flash svg { - margin: 15px 20px 10px 30px; -} + margin: 15px 20px 10px 30px; +} \ No newline at end of file diff --git a/app/static/css/footer.css b/app/static/css/footer.css index 9292ac1..0266495 100644 --- a/app/static/css/footer.css +++ b/app/static/css/footer.css @@ -1,13 +1,13 @@ -/* - Footer -*/ - footer { - margin: 20px 0 0 0; padding: 10px 10%; - text-align: center; font-size: 11px; font-style: italic; - background: var(--background); color: var(--text); - border-top: var(--border); + margin: 20px 0 0 0; + padding: 10px 10%; + text-align: center; + font-size: 11px; + font-style: italic; + background: var(--background); + color: var(--text); + border-top: var(--border); } footer p { - margin: 3px 0; -} + margin: 3px 0; +} \ No newline at end of file diff --git a/app/static/css/form.css b/app/static/css/form.css index fa11e42..716a28d 100644 --- a/app/static/css/form.css +++ b/app/static/css/form.css @@ -1,142 +1,114 @@ -.form .avatar { - width: 128px; height: 128px; -} - -.form .avatar + input[type="file"] { - margin: 16px 0 0 0; - vertical-align: middle; -} - .form form > div:not(:last-child):not(.editor-toolbar) { - margin-bottom: 16px; + margin-bottom: 16px; } - -.form form label, -.trophies-panel p { - display: inline-block; - margin-bottom: 4px; +.form form label { + display: inline-block; + margin: 0 5px 4px 0; } -.form label + .desc { - margin: 0 0 4px 0; +.form form label + .desc { + margin: 0 0 4px 0; + font-size: 80%; + opacity: .75; +} +.form form .avatar { + width: 128px; + height: 128px; +} +.form form .avatar + input[type="file"] { + margin: 16px 0 0 0; + vertical-align: middle; } - .form input[type='text'], .form input[type='email'], .form input[type='date'], .form input[type='password'], .form input[type='search'], .form textarea, -.form select, -.trophies-panel > div { - display: block; - width: 100%; padding: 6px 8px; - background: var(--background); color: var(--text); - border: var(--border); - - /* Transitions when resizing with the mouse produces apparent lag */ - transition: all .15s ease, width 0s, height 0s; +.form select { + display: block; + width: 100%; + padding: 6px 8px; + background: var(--background); + color: var(--text); + border: var(--border); + transition: all .15s ease, width 0s, height 0s; } .form input[type='text']:focus, .form input[type='email']:focus, .form input[type='date']:focus, .form input[type='password']:focus, .form input[type='search']:focus, -.form textarea:focus { - border-color: var(--border-focused); - box-shadow: 0 0 0 3px var(--shadow-focused); +.form textarea:focus, +.form select:focus { + border-color: var(--border-focused); + box-shadow: 0 0 0 3px var(--shadow-focused); +} +.form input[type='text']:focus-within, +.form input[type='email']:focus-within, +.form input[type='date']:focus-within, +.form input[type='password']:focus-within, +.form input[type='search']:focus-within, +.form textarea:focus-within, +.form select:focus-within { + outline: none; +} +.form input[type='checkbox'], +.form input[type='radio'] { + display: inline; + vertical-align: middle; + margin: 0 4px 0 0; } - .form textarea { - max-width: 100%; - resize: vertical; + max-width: 100%; + resize: vertical; } .form select { - width: auto; + width: auto; } - .form progress.entropy { - display: none; /* display with Js enabled */ - width: 100%; margin-top: 5px; - background: var(--background); - border: var(--border); -} -.form progress.entropy.low::-moz-progress-bar { - background: var(--error); + display: none; + width: 100%; + margin-top: 5px; + background: var(--background); + border: var(--border); } +.form progress.entropy.low::-moz-progress-bar, .form progress.entropy.low::-webkit-progress-bar { - background: var(--error); -} -.form progress.entropy.medium::-moz-progress-bar { - background: var(--warn); + background: var(--error); } +.form progress.entropy.medium::-moz-progress-bar, .form progress.entropy.medium::-webkit-progress-bar { - background: var(--warn); -} -.form progress.entropy.high::-moz-progress-bar { - background: var(--ok); + background: var(--warn); } +.form progress.entropy.high::-moz-progress-bar, .form progress.entropy.high::-webkit-progress-bar { - background: var(--ok); + background: var(--ok); } - -.form input[type="checkbox"], -.form input[type="radio"] { - display: inline; - vertical-align: middle; -} - -.form input[type="submit"] { - /*width: 20%;*/ -} - -.form form .msgerror { - color: var(--error); - font-weight: 400; - margin-top: 5px; -} - -.form .desc { - font-size: 80%; - opacity: .75; -} - .form hr { - height: 3px; - border: var(--hr-border); - border-width: 1px 0; - margin: 24px 0; + height: 3px; + border: var(--hr-border); + border-width: 1px 0; + margin: 24px 0; } -.trophies-panel label { - margin-right: 5px; +.form .msgerror { + color: var(--error); + font-weight: 400; + margin-top: 5px; } -.trophies-panel p:first-child { - margin-top: 0; +.form input.abfield[type="email"] { + display: none; } -.trophies-panel p label { - margin: 0; +.form.filter { + margin-bottom: 16px; } - -/* Editor */ - -.editor textarea { - font-family: monospace; - height: 192px; -} - -/* Interactive filter forms */ - .form.filter > p:first-child { font-size: 80%; color: gray; margin-bottom: 2px; } -.form.filter { - margin-bottom: 16px; -} .form.filter input { font-family: monospace; } - .form.filter .syntax-explanation { font-size: 80%; color: gray; @@ -149,10 +121,8 @@ line-height: 20px; margin-top: 2px; } -.form.filter .syntax-explanation li { -} .form.filter .syntax-explanation code { background: rgba(0,0,0,.05); padding: 1px 2px; border-radius: 2px; -} +} \ No newline at end of file diff --git a/app/static/css/global.css b/app/static/css/global.css index 110da9d..e96db7f 100644 --- a/app/static/css/global.css +++ b/app/static/css/global.css @@ -1,121 +1,154 @@ -/* Fonts */ - -@font-face { font-family: NotoSans; src: url(../fonts/noto_sans.ttf); font-display: swap; } -@font-face { font-family: Twemoji; src: url(../fonts/TwitterColorEmoji.ttf); font-display: swap; } -@font-face { font-family: Cantarell; font-weight: normal; src: url(../fonts/Cantarell-Regular.otf); font-display: swap; } -@font-face { font-family: Cantarell; font-weight: bold; src: url(../fonts/Cantarell-Bold.otf); font-display: swap; } - -/* Whole page */ - +@font-face { + font-family: NotoSans; + src: url(../fonts/noto_sans.ttf); + font-display: swap; +} +@font-face { + font-family: Twemoji; + src: url(../fonts/TwitterColorEmoji.ttf); + font-display: swap; +} +@font-face { + font-family: Cantarell; + font-weight: normal; + src: url(../fonts/Cantarell-Regular.otf); + font-display: swap; +} +@font-face { + font-family: Cantarell; + font-weight: bold; + src: url(../fonts/Cantarell-Bold.otf); + font-display: swap; +} * { - box-sizing: border-box; - /* This transition value is replicated everywhere transitions are customized, - make sure to track them when editing */ - transition: .15s ease; + box-sizing: border-box; + transition: .15s ease; } - body { - margin: 0; - background: var(--background); color: var(--text); - font-family: 'DejaVu Sans', sans-serif; + margin: 0; + background: var(--background); + color: var(--text); + font-family: 'DejaVu Sans', sans-serif; + font-size: 13px; +} +@media screen and (min-width:1449px) { + body { + font-size: 14px; + } } - -/* General */ - a { - text-decoration: none; - color: var(--links); -} -a:hover { - text-decoration: underline; + text-decoration: none; + color: var(--links); } +a:hover, a:focus { - outline: none; + text-decoration: underline; + outline: none; +} +img.pixelated { + image-rendering: pixelated; } - section p { - line-height: 20px; - word-wrap: anywhere; + line-height: 20px; + word-wrap: anywhere; } - section ul { - line-height: 24px; + line-height: 24px; +} +section h1 { + margin-top: 0; + border-bottom: var(--hr-border); + font-family: Cantarell; + font-weight: bold; + font-size: 26px; + color: var(--text-light); +} +section h2 { + margin: 24px 0 16px 0; + border-bottom: var(--hr-border); + font-family: Cantarell; + font-weight: bold; + font-size: 18px; + color: var(--text-light); + padding-bottom: 2px; } - -/* Buttons */ - .button, input[type="button"], input[type="submit"] { - padding: 6px 10px; border-radius: 2px; - cursor: pointer; - font-family: 'DejaVu Sans', sans-serif; font-weight: 400; - border: 0; + padding: 6px 10px; + border-radius: 2px; + cursor: pointer; + font-family: 'DejaVu Sans', sans-serif; + font-weight: 400; + border: 0; } +.button:hover, input[type="button"]:hover, input[type="submit"]:hover, -.button:hover { - text-decoration: none; +.button:focus, +input[type="button"]:focus, +input[type="submit"]:focus { + text-decoration: none; } - - -@media screen and (max-width: 1499px) { - .profile-avatar { - width: 96px; - height: 96px; - } - .profile-xp { - height: 8px; - min-width: 64px; - } - .profile-xp div { - height: 8px; - } -} -@media screen and (max-width: 1199px) { - .profile-points { - display: none; - } - .profile-points-small { - display: unset; - } -} - -/* - Bootstrap-style rules -*/ .flex { - display: flex; + display: flex; } - -.bg-ok, .bg-ok { - background: var(--ok); - color: var(--ok-text); + background: var(--ok); + color: var(--ok-text); } .bg-ok:hover, .bg-ok:focus, .bg-ok:active { - background: var(--ok-active); + background: var(--ok-active); } - -.bg-error, .bg-error { - background: var(--error); - color: var(--error-text); + background: var(--error); + color: var(--error-text); } .bg-error:hover, .bg-error:focus, .bg-error:active { - background: var(--error-active); + background: var(--error-active); } - .bg-warn { - background: var(--warn); - color: var(--warn-text); + background: var(--warn); + color: var(--warn-text); } .bg-warn:hover, .bg-warn:focus, .bg-warn:active { - background: var(--warn-active); + background: var(--warn-active); } +.align-left { + text-align: left; +} +.align-center { + display: block; + margin-left: auto; + margin-right: auto; +} +.align-right { + display: block; + margin-left: auto; +} +.float-left { + float: left; +} +.float-right { + float: right; +} +.skip-to-content-link { + height: 30px; + left: 50%; + padding: 8px; + position: absolute; + transform: translateY(-100%); + transition: transform 0.3s; + background: var(--links); + color: var(--warn-text); + border-radius: 1px; +} +.skip-to-content-link:focus { + transform: translateY(0%); +} \ No newline at end of file diff --git a/app/static/css/header.css b/app/static/css/header.css index a75f8b4..7429d1e 100644 --- a/app/static/css/header.css +++ b/app/static/css/header.css @@ -1,87 +1,98 @@ -/* - header -*/ - header { - height: 50px; margin: 0; padding: 0 16px; - background: var(--background); border-bottom: var(--border); - - display: flex; align-items: center; justify-content: space-between; - flex-flow: row wrap; - - /* When the search field occupies the rightmost position, the calculated - position of the svg icon (on the right) might overflow from the header and - induce horizontal scrolling. */ - overflow: hidden; + margin: 0; + padding: 8px 16px; + background: var(--background); + border-bottom: var(--border); + display: flex; + align-items: center; + justify-content: space-between; + flex-flow: row wrap; + overflow: hidden; } -@media screen and (max-width: 1199px) { - #spotlight { - display: none; - } - header input[type="search"] { - width: 200px; - } +header .title { + margin: 4px 0; } -@media screen and (max-width: 849px) { - header .form { - display: none; - } -} - header .title a { - color: inherit; + color: inherit; } header .title h1 { - font-family: Cantarell; font-weight: bold; font-size: 18px; - display: inline; + font-family: Cantarell; + font-weight: bold; + font-size: 18px; + display: inline; } - header .spacer { - flex: 1 0 auto; + flex: 1 0 auto; } - header .links { - margin-left: 16px; + margin-left: 16px; } -header svg { - width: 24px; height: 24px; vertical-align: middle; - transition: .15s ease; -} -header a:hover > svg, header a:focus > svg { - fill: var(--text); -} -header a { - fill: #363636; - cursor: pointer; -} - header .form { - /* The search icon is draws inside the input field but its space is allocated - on the right. Apply a negative margin to compensate this: - -24px for the search icon - -2px for the spacing between the search icon and the field */ - margin-right: -26px; + margin-right: -26px; } header .form input[type="search"] { - display: inline-block; width: 250px; - padding: 5px 35px 5px 10px; + display: inline-block; + width: 250px; + padding: 5px 35px 5px 10px; +} +header .form input[type="search"]:focus ~ a { + opacity: 1; } header .form input[type="search"] ~ a { - position: relative; left: -33px; - opacity: .7; + position: relative; + left: -33px; + opacity: .7; } header .form input[type="search"] ~ a > svg > path { - fill: var(--text); + fill: var(--text); } -header .form input[type="search"] ~ a:hover, -header .form input[type="search"]:focus ~ a { - opacity: 1; +header .form a { + fill: #363636; + cursor: pointer; } - - -#spotlight { - margin-left: 16px; +header .form a:hover > svg, +header .form a:focus > svg { + fill: var(--text); } -#spotlight a { - display: block; +header .form svg { + width: 24px; + height: 24px; + vertical-align: middle; + transition: .15s ease; } +header #spotlight { + margin-left: 16px; +} +header #spotlight a { + display: block; +} +@media screen and (max-width:849px) { + header .form { + display: none; + } +} +@media screen and (max-width:1199px) { + header .form input[type="search"] { + width: 200px; + } +} +@media screen and (min-width:1449px) { + header .form input[type="search"] { + font-size: 14px; + } +} +@media screen and (max-width:1199px) { + header #spotlight { + display: none; + } +} +#server-speed-warning { + background: var(--warn); + color: var(--warn-text); + text-align: center; + border-radius: 2px; + padding: 4px; + margin: 0 8px; + font-weight: bold; + text-shadow: 0 1px 1px rgba(0,0,0,.5); +} \ No newline at end of file diff --git a/app/static/css/homepage.css b/app/static/css/homepage.css index fa5bfbd..c0d487e 100644 --- a/app/static/css/homepage.css +++ b/app/static/css/homepage.css @@ -1,134 +1,135 @@ -/* - home-title -*/ - .home-title { - margin: 20px 0; padding: 10px 5%; - background: #bf1c11; box-shadow: 0 2px 2px rgba(0, 0, 0, .3); - border-top: 10px solid #ab170c; + margin: 20px 0; + padding: 10px 5%; + background: #bf1c11; + box-shadow: 0 2px 2px rgba(0,0,0,.3); + border-top: 10px solid #ab170c; } - -.home-title h1 { - margin-top: 0; - color: #ffffff; border-color: #ffffff; +.home-title h1 { + margin-top: 0; + color: #ffffff; + border-color: #ffffff; } - .home-title p { - margin-bottom: 0; text-align: justify; - color: #ffffff; + margin-bottom: 0; + text-align: justify; + color: #ffffff; } - .home-title a { - color: inherit; text-decoration: underline; + color: inherit; + text-decoration: underline; } - - -/* - pinned-content -*/ - .home-pinned-content > div { - display: flex; justify-content: space-between; + display: flex; + justify-content: space-between; } - -.home-pinned-content article { - flex-grow: 1; margin: 0 1px; padding: 0; - position: relative; - max-width: 250px; overflow: hidden; -} - -.home-pinned-content a { - display: block; -} - -.home-pinned-content img { - width: 100%; filter: blur(0px); -} - -.home-pinned-content article div { - position: absolute; bottom: 0; z-index: 3; - width: 90%; margin: 0; - padding: 30px 5% 10px 5%; - color: #ffffff; text-shadow: 1px 1px 0 rgba(0,0,0,.6); - background-image: linear-gradient(180deg,transparent 0,rgba(0,0,0,.7) 40px,rgba(0,0,0,.8)); -} - .home-pinned-content h2 { - display: block; margin: 5px 0; - font-size: 18px; font-family: NotoSans; font-weight: 200; - line-height: 20px; + display: block; + margin: 5px 0; + font-size: 18px; + font-family: NotoSans; + font-weight: 200; + line-height: 20px; } - - -/* - home-articles -*/ - -.home-articles { - display: flex; justify-content: space-between; +.home-pinned-content a { + display: block; } -.home-articles > div { - flex-grow: 1; max-width: 48%; -} -.home-articles h1 { - display: flex; justify-content: space-between; align-items: center; -} -.home-articles h1 a { - padding: 0; - font-family: NotoSans; font-size: 16px; - font-weight: 400; color: /*#015078*/ /*#bf1c11*/ #234d5f; -} - -.home-articles article { - padding: 10px; margin: 10px 0; display: flex; align-items: center; - background: #ffffff; border: 1px solid rgba(0, 0, 0, .2); -} -.home-articles article > img { - float: left; margin-right: 10px; flex-shrink: 0; -} -.home-articles article > img.screeshot { - width: 128px; height: 64px; -} -.home-articles article > div { - flex-shrink: 1; -} -.home-articles article h3 { - margin: 0; - color: #424242; font-weight: normal; -} -.home-articles p { - margin: 5px 0; - text-align: justify; - color: #808080; -} -.home-articles .metadata { - margin: 0; - color: #22292c; -} -.home-articles .metadata a { - color: #22292c; font-weight: 400; font-style: italic; -} - - -/* - hover rules -*/ - .home-pinned-content a:hover img, .home-pinned-content a:focus img { - filter: blur(3px); + filter: blur(3px); } .home-pinned-content a:hover div, .home-pinned-content a:focus div { - padding: 200px 5% 10px 5%; - background-image: linear-gradient(180deg,transparent 0,rgba(0,0,0,.7) 40px,rgba(0,0,0,.8)); + padding: 200px 5% 10px 5%; + background-image: linear-gradient(180deg,transparent 0,rgba(0,0,0,.7)40px,rgba(0,0,0,.8)); +} +.home-pinned-content img { + width: 100%; + filter: blur(0px); +} +.home-pinned-content article { + flex-grow: 1; + margin: 0 1px; + padding: 0; + position: relative; + max-width: 250px; + overflow: hidden; +} +.home-pinned-content article div { + position: absolute; + bottom: 0; + z-index: 3; + width: 90%; + margin: 0; + padding: 30px 5% 10px 5%; + color: #ffffff; + text-shadow: 1px 1px 0 rgba(0,0,0,.6); + background-image: linear-gradient(180deg,transparent 0,rgba(0,0,0,.7)40px,rgba(0,0,0,.8)); +} +.home-articles { + display: flex; + justify-content: space-between; +} +.home-articles > div { + flex-grow: 1; + max-width: 48%; +} +.home-articles h1 { + display: flex; + justify-content: space-between; + align-items: center; +} +.home-articles h1 a { + padding: 0; + font-family: NotoSans; + font-size: 16px; + font-weight: 400; + color: #234d5f; } - .home-articles h1 a:hover, .home-articles h1 a:focus { - padding-right: 10px; + padding-right: 10px; +} +.home-articles p { + margin: 5px 0; + text-align: justify; + color: #808080; +} +.home-articles article { + padding: 10px; + margin: 10px 0; + display: flex; + align-items: center; + background: #ffffff; + border: 1px solid rgba(0,0,0,.2); +} +.home-articles article > img { + float: left; + margin-right: 10px; + flex-shrink: 0; +} +.home-articles article > img.screeshot { + width: 128px; + height: 64px; +} +.home-articles article > div { + flex-shrink: 1; +} +.home-articles article h3 { + margin: 0; + color: #424242; + font-weight: normal; } .home-articles article a:hover, .home-articles article a:focus { - text-decoration: underline; + text-decoration: underline; } +.home-articles .metadata { + margin: 0; + color: #22292c; +} +.home-articles .metadata a { + color: #22292c; + font-weight: 400; + font-style: italic; +} \ No newline at end of file diff --git a/app/static/css/light.css b/app/static/css/light.css deleted file mode 100644 index a3fbf59..0000000 --- a/app/static/css/light.css +++ /dev/null @@ -1,223 +0,0 @@ -/* Whole page */ - -.light-hidden { - display: none; -} - -.container { - margin-left: 0; -} - -/* Menu */ - -#light-menu { - position: unset; - display: flex; flex-direction: row; align-items: center; - width: 100%; height: 60px; - overflow-x: auto; overflow-y: hidden; -} - -#logo { - width: auto; height: 100%; margin-bottom: 0; -} -#logo img { - width: 60px; height: inherit; - margin-bottom: -4.5px; -} - -#light-menu li { - display: flex; flex-direction: column; - align-items: center; flex-grow: 1; - height: 100%; - padding: 0 2px; -} -#light-menu li > a { - cursor: pointer; margin: 0; -} -#light-menu li > a:hover { - text-decoration: none; -} -#light-menu li > a > svg { - width: 20px; -} -#light-menu li > a > div { - display: block; - font-size: 12px; -} -#light-menu li:not(.opened) > a:hover::after, -#light-menu li:not(.opened) > a:focus::after { - display: none; -} - - -#light-menu li span[notifications]:not([notifications="0"])::before { - content: attr(notifications); - display: inline-block; margin-right: 6px; - vertical-align: middle; - padding: 0 5px 0 4px; border-radius: 5px; - font-family: NotoSans; - background: #ffffff; color: #000000; -} - - -#menu { - width: 100%; height: 0; overflow-x: hidden; - font-family: NotoSans; font-size: 12px; - transition: .1s ease; - position: unset; - left: unset; -} -#menu.opened { - height: 100%; - overflow-y: auto; - left: unset; -} - -#menu > div { - width: 100%; -} -#menu h2 { - font-size: 15px; -} -#menu h2 > svg { - width: 24px; -} - -#menu span { - display: block; - color: #b8b8b8; - font-size: 10px; -} -#menu span > a { - display: inline; - margin: 0; font-style: normal; - font-size: 12px; -} -#menu ul { - list-style: none; - margin: 10px 0; padding: 0; - line-height: 20px; - color: #b8b8b8; -} -#menu li { - margin: 5px 0; -} - -@media all and (max-width: 500px) { - #light-menu, #spacer-menu { - height: 40px; - } - #logo img { - width: 40px; - } - #light-menu li > a > div { - display: none; - } -} - -#menu form input { - display: block; - margin: 5px 15px; padding: 5px 10px; - font-size: 14px; - transition: .15s ease; -} -#menu form label { - float: left; margin-right: 10px; -} -#menu form input:first-child { - margin-bottom: 0; border-bottom: none; - border-top-left-radius: 5px; - -webkit-border-top-left-radius: 5px; - -moz-border-top-left-radius: 5px; - border-top-right-radius: 5px; - -webkit-border-top-right-radius: 5px; - -moz-border-top-right-radius: 5px; -} -#menu form input:nth-child(2) { - margin-top: 0; border-top: 1px solid #dddddd; - border-bottom-left-radius: 5px; - -webkit-border-bottom-left-radius: 5px; - -moz-border-bottom-left-radius: 5px; - border-bottom-right-radius: 5px; - -webkit-border-bottom-right-radius: 5px; - -moz-border-bottom-right-radius: 5px; -} -#menu form a { - display: block; margin-left: 15px; -} - - -/* Header */ - -header { - padding: 0 8px; -} - -/* Homepage */ - -#shoutbox { - display: none; -} - -section { - width: unset; - margin: 16px; -} -.home-title { - padding: 10px; -} -.home-title p { - font-size: 14px; -} - -.home-pinned-content { - margin-top: 30px; -} -.home-pinned-content article { - margin: 5px 0; -} -.home-pinned-content article > a { - width: 100%; - display: flex; align-items: center; - text-decoration: none; -} -.home-pinned-content img { - flex-shrink: 0; - width: 100px; height: 100px; -} -.home-pinned-content article div { - flex-grow: 1; margin-left: 10px; -} -.home-pinned-content h2 { - margin: 0; color: #242424; - text-decoration: underline; -} -.home-pinned-content span { - color: #000000; font-size: 14px; -} - -.home-articles > div { - margin-top: 30px; -} -.home-articles article { - margin-bottom: 15px; -} -.home-articles article > img { - flex-shrink: 0; width: 128px; height: 64px; -} -.home-articles article > div { - margin-left: 5px; -} -.home-articles h1 > a { - font-size: 13px; color: #666666; -} -.home-articles p { - font-size: 14px; -} - - -/* Notifications */ - -.alert { - display: none; -} diff --git a/app/static/css/navbar.css b/app/static/css/navbar.css index fcdcf20..ab0d404 100644 --- a/app/static/css/navbar.css +++ b/app/static/css/navbar.css @@ -1,183 +1,310 @@ nav a { - opacity: .8; - cursor: pointer; + opacity: .8; + cursor: pointer; } nav a:hover, nav a:focus { - opacity: 1; + opacity: 1; } - - -/* Menu */ - -#light-menu { - position: fixed; z-index: 10; - list-style: none; - width: 110px; - height: 100%; overflow-y: auto; - margin: 0; padding: 0; - text-indent: 0; - background: var(--background); box-shadow: var(--shadow); -} - #logo { - position: relative; display: block; - width: 100%; - margin-bottom: 10px; - opacity: 1; - background: var(--logo-bg); - transition: .15s ease; -} -#logo img { - display: block; height: 65px; - margin: 0 auto; padding: 0; - filter: drop-shadow(0 0 2px rgba(0, 0, 0, .0)); - transition: filter .15s ease; + position: relative; + display: block; + width: 100%; + margin-bottom: 10px; + opacity: 1; + background: var(--logo-bg); + transition: .15s ease; } #logo:hover, #logo:focus { - background: var(--logo-active); + background: var(--logo-active); } #logo:hover img, #logo:focus img { - filter: drop-shadow(var(--logo-shadow)); + filter: drop-shadow(var(--logo-shadow)); +} +#logo img { + display: block; + height: 65px; + margin: 0 auto; + padding: 0; + filter: drop-shadow(0 0 2px rgba(0,0,0,.0)); + transition: filter .15s ease; +} +@media screen and (max-width:849px) { + #logo { + width: auto; + height: 100%; + margin-bottom: 0; + } +} +@media screen and (max-width:1199px) { + #logo img { + width: 60px; + height: inherit; + margin-bottom: -4.5px; + } +} +@media screen and (max-width:499px) { + #logo img { + width: 50px; + } +} +#light-menu { + position: fixed; + z-index: 10; + list-style: none; + width: 110px; + height: 100%; + overflow-y: auto; + margin: 0; + padding: 0; + text-indent: 0; + font-size: 13px; + background: var(--background); + box-shadow: var(--shadow); } - #light-menu li { - width: 100%; - color: var(--text); + width: 100%; + color: var(--text); } #light-menu li > a { - display: flex; flex-direction: column; flex-grow: 0; - align-items: center; justify-content: center; - width: 100%; height: 100%; - margin: 20px 0; - color: var(--text); - transition: opacity .15s ease; /* because Chrome sucks */ + display: flex; + flex-direction: column; + flex-grow: 0; + align-items: center; + justify-content: center; + width: 100%; + height: 100%; + margin: 20px 0; + color: var(--text); + transition: opacity .15s ease; } - #light-menu li > a > img { - display: block; width: 60px; flex-shrink: 0; flex-grow: 0; - margin: 0 7px 5px 7px; - border-radius: 10%; + display: block; + width: 60px; + flex-shrink: 0; + flex-grow: 0; + margin: 0 7px 5px 7px; + border-radius: 10%; } #light-menu li > a > svg { - display: block; width: 25px; flex-shrink: 0; flex-grow: 0; - margin: 0 7px; + display: block; + width: 25px; + flex-shrink: 0; + flex-grow: 0; + margin: 0 7px; } #light-menu li > a > svg > path { - fill: var(--icons); + fill: var(--icons); } - -#light-menu li div { - /*flex-grow: 1;*/ +@media screen and (max-width:849px) { + #light-menu { + position: unset; + display: flex; + flex-direction: row; + align-items: center; + width: 100%; + height: 60px; + overflow-x: auto; + overflow-y: hidden; + } +} +@media screen and (max-width:499px) { + #light-menu { + height: 50px; + } +} +@media screen and (max-width:849px) { + #light-menu li { + display: flex; + flex-direction: column; + align-items: center; + flex-grow: 1; + padding: 0 2px; + font-size: 12px; + } +} +@media screen and (max-width:849px) { + #light-menu li > a > img { + width: 45px; + margin: 0; + } + #light-menu li > a > img ~ div { + display: none; + } +} +@media screen and (max-width:499px) { + #light-menu li > a > img { + width: 40px; + } +} +@media screen and (max-width:499px) { + #light-menu li > a > div { + display: none; + } } - - -/* Overlay */ #menu { - position: fixed; z-index: 5; - left: -190px; width: 300px; /* default: left-to-right animation */ - height: 100%; overflow-x: hidden; overflow-y: auto; - background: var(--background); color: var(--text); - box-shadow: var(--shadow); - transition: .15s ease; + position: fixed; + z-index: 5; + left: -190px; + width: 300px; + height: 100%; + overflow-x: hidden; + overflow-y: auto; + font-size: 13px; + background: var(--background); + color: var(--text); + box-shadow: var(--shadow); + transition: .15s ease; } #menu.opened { - left: 110px; + left: 110px; } - -/* Just apply class="scroll-animation" to menu to change to scroll animation */ #menu.scroll-animation { - left: 110px; width: 0; + left: 110px; + width: 0; } #menu.scroll-animation.opened { - width: 300px; + width: 300px; } - - #menu > div { - width: 300px; - padding: 16px; - display: none; + width: 300px; + padding: 16px; + display: none; } #menu > div.opened { - display: block; + display: block; } - #menu h2 { - margin: 0 0 20px 0; - font-family: Cantarell; font-weight: bold; font-size: 18px; - color: var(--text); - display: flex; align-items: center; + margin: 0 0 20px 0; + font-family: Cantarell; + font-weight: bold; + font-size: 18px; + color: var(--text); + display: flex; + align-items: center; } #menu h2 a { - margin: 0; - font-size: inherit; opacity: inherit; -} -#menu h2 > svg { - width: 32px; vertical-align: middle; margin-right: 8px; -} -#menu h2 img { - height: 48px; vertical-align: middle; margin-right: 10px; + margin: 0; + font-size: inherit; + opacity: inherit; } #menu h2 a:hover, #menu h2 a:focus { - text-decoration: underline; + text-decoration: underline; +} +#menu h2 > svg { + width: 32px; + vertical-align: middle; + margin-right: 8px; +} +#menu h2 img { + height: 48px; + vertical-align: middle; + margin-right: 10px; } - #menu h3 { - margin: 16px 0; - font-family: Cantarell; font-weight: bold; font-size: 15px; - color: var(--text); + margin: 16px 0; + font-family: Cantarell; + font-weight: bold; + font-size: 15px; + color: var(--text); } #menu hr { - margin: 15px 0; - border: none; - border-bottom: 1px solid rgba(255, 255, 255, 0.1); + margin: 15px 0; + border: none; + border-bottom: 1px solid rgba(255,255,255,0.1); } - #menu ul { - margin: 0; padding: 0; list-style: none; + margin: 0; + padding: 0; + list-style: none; } #menu a, #menu li { - display: block; margin: 10px 0; - color: var(--text); - transition: opacity .15s ease; + display: block; + margin: 10px 0; + color: var(--text); + transition: opacity .15s ease; } #menu li > a { - display: inline; - margin: 0; font-style: normal; - font-size: 13px; + display: inline; + margin: 0; + font-style: normal; + font-size: 13px; } #menu a > img { - vertical-align: middle; - margin-right: 15px; + vertical-align: middle; + margin-right: 15px; } #menu a > svg { - width: 20px; height: 20px; vertical-align: middle; - margin-right: 10px; + width: 20px; + height: 20px; + vertical-align: middle; + margin-right: 10px; } - #menu form { - padding: 0 8%; + padding: 0 8%; } #menu form input[type="text"], #menu form input[type="password"] { - margin: 8px 0; padding: 5px 2%; - font-size: 14px; color: inherit; - border: var(--input-border); - background: var(--input-bg); color: var(--input-text); opacity: .8; + margin: 8px 0; + padding: 5px 2%; + font-size: 14px; + border: var(--input-border); + background: var(--input-bg); + color: var(--input-text); + opacity: .8; } #menu form input[type="text"]:focus, #menu form input[type="password"]:focus { - opacity: 1; + opacity: 1; } #menu form input[type="submit"] { - width: 100%; - margin: 8px 0 5px 0; + width: 100%; + margin: 8px 0 5px 0; } #menu form label { - font-size: 13px; opacity: .8; + font-size: 13px; + opacity: .8; } +@media screen and (max-width:849px) { + #menu { + width: 100%; + height: 0; + overflow-x: hidden; + font-family: NotoSans; + transition: .1s ease; + position: unset; + left: unset; + } +} +@media screen and (max-width:849px) { + #menu.opened { + height: 100%; + overflow-y: auto; + left: unset; + } +} +@media screen and (max-width:849px) { + #menu > div { + width: 100%; + padding-bottom: 2px; + } +} +@media screen and (max-width:499px) { + #menu h2 { + font-size: 15px; + } +} +@media screen and (max-width:499px) { + #menu h2 > svg { + width: 24px; + } +} +@media screen and (max-width:849px) { + #menu form { + padding: 0; + } +} \ No newline at end of file diff --git a/app/static/css/pagination.css b/app/static/css/pagination.css index 2f59ca5..834294f 100644 --- a/app/static/css/pagination.css +++ b/app/static/css/pagination.css @@ -1,4 +1,4 @@ .pagination { - text-align: center; - margin: 5px 0; -} + text-align: center; + margin: 5px 0; +} \ No newline at end of file diff --git a/app/static/css/pygments.css b/app/static/css/pygments-default.css similarity index 99% rename from app/static/css/pygments.css rename to app/static/css/pygments-default.css index 4531226..fe93c6e 100644 --- a/app/static/css/pygments.css +++ b/app/static/css/pygments-default.css @@ -1,6 +1,5 @@ pre { line-height: 125%; } td.linenos .normal { color: inherit; background-color: transparent; padding-left: 5px; padding-right: 5px; } -td.linenos { padding: 0 5px; } span.linenos { color: inherit; background-color: transparent; padding-left: 5px; padding-right: 5px; } td.linenos .special { color: #000000; background-color: #ffffc0; padding-left: 5px; padding-right: 5px; } span.linenos.special { color: #000000; background-color: #ffffc0; padding-left: 5px; padding-right: 5px; } diff --git a/app/static/css/pygments-friendly.css b/app/static/css/pygments-friendly.css new file mode 100644 index 0000000..4642fc0 --- /dev/null +++ b/app/static/css/pygments-friendly.css @@ -0,0 +1,74 @@ +pre { line-height: 125%; } +td.linenos .normal { color: #666666; background-color: transparent; padding-left: 5px; padding-right: 5px; } +span.linenos { color: #666666; background-color: transparent; padding-left: 5px; padding-right: 5px; } +td.linenos .special { color: #000000; background-color: #ffffc0; padding-left: 5px; padding-right: 5px; } +span.linenos.special { color: #000000; background-color: #ffffc0; padding-left: 5px; padding-right: 5px; } +.codehilite .hll { background-color: #ffffcc } +.codehilite { background: #f0f0f0; } +.codehilite .c { color: #60a0b0; font-style: italic } /* Comment */ +.codehilite .err { border: 1px solid #FF0000 } /* Error */ +.codehilite .k { color: #007020; font-weight: bold } /* Keyword */ +.codehilite .o { color: #666666 } /* Operator */ +.codehilite .ch { color: #60a0b0; font-style: italic } /* Comment.Hashbang */ +.codehilite .cm { color: #60a0b0; font-style: italic } /* Comment.Multiline */ +.codehilite .cp { color: #007020 } /* Comment.Preproc */ +.codehilite .cpf { color: #60a0b0; font-style: italic } /* Comment.PreprocFile */ +.codehilite .c1 { color: #60a0b0; font-style: italic } /* Comment.Single */ +.codehilite .cs { color: #60a0b0; background-color: #fff0f0 } /* Comment.Special */ +.codehilite .gd { color: #A00000 } /* Generic.Deleted */ +.codehilite .ge { font-style: italic } /* Generic.Emph */ +.codehilite .gr { color: #FF0000 } /* Generic.Error */ +.codehilite .gh { color: #000080; font-weight: bold } /* Generic.Heading */ +.codehilite .gi { color: #00A000 } /* Generic.Inserted */ +.codehilite .go { color: #888888 } /* Generic.Output */ +.codehilite .gp { color: #c65d09; font-weight: bold } /* Generic.Prompt */ +.codehilite .gs { font-weight: bold } /* Generic.Strong */ +.codehilite .gu { color: #800080; font-weight: bold } /* Generic.Subheading */ +.codehilite .gt { color: #0044DD } /* Generic.Traceback */ +.codehilite .kc { color: #007020; font-weight: bold } /* Keyword.Constant */ +.codehilite .kd { color: #007020; font-weight: bold } /* Keyword.Declaration */ +.codehilite .kn { color: #007020; font-weight: bold } /* Keyword.Namespace */ +.codehilite .kp { color: #007020 } /* Keyword.Pseudo */ +.codehilite .kr { color: #007020; font-weight: bold } /* Keyword.Reserved */ +.codehilite .kt { color: #902000 } /* Keyword.Type */ +.codehilite .m { color: #40a070 } /* Literal.Number */ +.codehilite .s { color: #4070a0 } /* Literal.String */ +.codehilite .na { color: #4070a0 } /* Name.Attribute */ +.codehilite .nb { color: #007020 } /* Name.Builtin */ +.codehilite .nc { color: #0e84b5; font-weight: bold } /* Name.Class */ +.codehilite .no { color: #60add5 } /* Name.Constant */ +.codehilite .nd { color: #555555; font-weight: bold } /* Name.Decorator */ +.codehilite .ni { color: #d55537; font-weight: bold } /* Name.Entity */ +.codehilite .ne { color: #007020 } /* Name.Exception */ +.codehilite .nf { color: #06287e } /* Name.Function */ +.codehilite .nl { color: #002070; font-weight: bold } /* Name.Label */ +.codehilite .nn { color: #0e84b5; font-weight: bold } /* Name.Namespace */ +.codehilite .nt { color: #062873; font-weight: bold } /* Name.Tag */ +.codehilite .nv { color: #bb60d5 } /* Name.Variable */ +.codehilite .ow { color: #007020; font-weight: bold } /* Operator.Word */ +.codehilite .w { color: #bbbbbb } /* Text.Whitespace */ +.codehilite .mb { color: #40a070 } /* Literal.Number.Bin */ +.codehilite .mf { color: #40a070 } /* Literal.Number.Float */ +.codehilite .mh { color: #40a070 } /* Literal.Number.Hex */ +.codehilite .mi { color: #40a070 } /* Literal.Number.Integer */ +.codehilite .mo { color: #40a070 } /* Literal.Number.Oct */ +.codehilite .sa { color: #4070a0 } /* Literal.String.Affix */ +.codehilite .sb { color: #4070a0 } /* Literal.String.Backtick */ +.codehilite .sc { color: #4070a0 } /* Literal.String.Char */ +.codehilite .dl { color: #4070a0 } /* Literal.String.Delimiter */ +.codehilite .sd { color: #4070a0; font-style: italic } /* Literal.String.Doc */ +.codehilite .s2 { color: #4070a0 } /* Literal.String.Double */ +.codehilite .se { color: #4070a0; font-weight: bold } /* Literal.String.Escape */ +.codehilite .sh { color: #4070a0 } /* Literal.String.Heredoc */ +.codehilite .si { color: #70a0d0; font-style: italic } /* Literal.String.Interpol */ +.codehilite .sx { color: #c65d09 } /* Literal.String.Other */ +.codehilite .sr { color: #235388 } /* Literal.String.Regex */ +.codehilite .s1 { color: #4070a0 } /* Literal.String.Single */ +.codehilite .ss { color: #517918 } /* Literal.String.Symbol */ +.codehilite .bp { color: #007020 } /* Name.Builtin.Pseudo */ +.codehilite .fm { color: #06287e } /* Name.Function.Magic */ +.codehilite .vc { color: #bb60d5 } /* Name.Variable.Class */ +.codehilite .vg { color: #bb60d5 } /* Name.Variable.Global */ +.codehilite .vi { color: #bb60d5 } /* Name.Variable.Instance */ +.codehilite .vm { color: #bb60d5 } /* Name.Variable.Magic */ +.codehilite .il { color: #40a070 } /* Literal.Number.Integer.Long */ diff --git a/app/static/css/pygments-material.css b/app/static/css/pygments-material.css new file mode 100644 index 0000000..f9a62a3 --- /dev/null +++ b/app/static/css/pygments-material.css @@ -0,0 +1,82 @@ +pre { line-height: 125%; } +td.linenos .normal { color: #37474F; background-color: #263238; padding-left: 5px; padding-right: 5px; } +span.linenos { color: #37474F; background-color: #263238; padding-left: 5px; padding-right: 5px; } +td.linenos .special { color: #607A86; background-color: #263238; padding-left: 5px; padding-right: 5px; } +span.linenos.special { color: #607A86; background-color: #263238; padding-left: 5px; padding-right: 5px; } +.codehilite .hll { background-color: #2C3B41 } +.codehilite { background: #263238; color: #EEFFFF } +.codehilite .c { color: #546E7A; font-style: italic } /* Comment */ +.codehilite .err { color: #FF5370 } /* Error */ +.codehilite .esc { color: #89DDFF } /* Escape */ +.codehilite .g { color: #EEFFFF } /* Generic */ +.codehilite .k { color: #BB80B3 } /* Keyword */ +.codehilite .l { color: #C3E88D } /* Literal */ +.codehilite .n { color: #EEFFFF } /* Name */ +.codehilite .o { color: #89DDFF } /* Operator */ +.codehilite .p { color: #89DDFF } /* Punctuation */ +.codehilite .ch { color: #546E7A; font-style: italic } /* Comment.Hashbang */ +.codehilite .cm { color: #546E7A; font-style: italic } /* Comment.Multiline */ +.codehilite .cp { color: #546E7A; font-style: italic } /* Comment.Preproc */ +.codehilite .cpf { color: #546E7A; font-style: italic } /* Comment.PreprocFile */ +.codehilite .c1 { color: #546E7A; font-style: italic } /* Comment.Single */ +.codehilite .cs { color: #546E7A; font-style: italic } /* Comment.Special */ +.codehilite .gd { color: #FF5370 } /* Generic.Deleted */ +.codehilite .ge { color: #89DDFF } /* Generic.Emph */ +.codehilite .gr { color: #FF5370 } /* Generic.Error */ +.codehilite .gh { color: #C3E88D } /* Generic.Heading */ +.codehilite .gi { color: #C3E88D } /* Generic.Inserted */ +.codehilite .go { color: #546E7A } /* Generic.Output */ +.codehilite .gp { color: #FFCB6B } /* Generic.Prompt */ +.codehilite .gs { color: #FF5370 } /* Generic.Strong */ +.codehilite .gu { color: #89DDFF } /* Generic.Subheading */ +.codehilite .gt { color: #FF5370 } /* Generic.Traceback */ +.codehilite .kc { color: #89DDFF } /* Keyword.Constant */ +.codehilite .kd { color: #BB80B3 } /* Keyword.Declaration */ +.codehilite .kn { color: #89DDFF; font-style: italic } /* Keyword.Namespace */ +.codehilite .kp { color: #89DDFF } /* Keyword.Pseudo */ +.codehilite .kr { color: #BB80B3 } /* Keyword.Reserved */ +.codehilite .kt { color: #BB80B3 } /* Keyword.Type */ +.codehilite .ld { color: #C3E88D } /* Literal.Date */ +.codehilite .m { color: #F78C6C } /* Literal.Number */ +.codehilite .s { color: #C3E88D } /* Literal.String */ +.codehilite .na { color: #BB80B3 } /* Name.Attribute */ +.codehilite .nb { color: #82AAFF } /* Name.Builtin */ +.codehilite .nc { color: #FFCB6B } /* Name.Class */ +.codehilite .no { color: #EEFFFF } /* Name.Constant */ +.codehilite .nd { color: #82AAFF } /* Name.Decorator */ +.codehilite .ni { color: #89DDFF } /* Name.Entity */ +.codehilite .ne { color: #FFCB6B } /* Name.Exception */ +.codehilite .nf { color: #82AAFF } /* Name.Function */ +.codehilite .nl { color: #82AAFF } /* Name.Label */ +.codehilite .nn { color: #FFCB6B } /* Name.Namespace */ +.codehilite .nx { color: #EEFFFF } /* Name.Other */ +.codehilite .py { color: #FFCB6B } /* Name.Property */ +.codehilite .nt { color: #FF5370 } /* Name.Tag */ +.codehilite .nv { color: #89DDFF } /* Name.Variable */ +.codehilite .ow { color: #89DDFF; font-style: italic } /* Operator.Word */ +.codehilite .w { color: #EEFFFF } /* Text.Whitespace */ +.codehilite .mb { color: #F78C6C } /* Literal.Number.Bin */ +.codehilite .mf { color: #F78C6C } /* Literal.Number.Float */ +.codehilite .mh { color: #F78C6C } /* Literal.Number.Hex */ +.codehilite .mi { color: #F78C6C } /* Literal.Number.Integer */ +.codehilite .mo { color: #F78C6C } /* Literal.Number.Oct */ +.codehilite .sa { color: #BB80B3 } /* Literal.String.Affix */ +.codehilite .sb { color: #C3E88D } /* Literal.String.Backtick */ +.codehilite .sc { color: #C3E88D } /* Literal.String.Char */ +.codehilite .dl { color: #EEFFFF } /* Literal.String.Delimiter */ +.codehilite .sd { color: #546E7A; font-style: italic } /* Literal.String.Doc */ +.codehilite .s2 { color: #C3E88D } /* Literal.String.Double */ +.codehilite .se { color: #EEFFFF } /* Literal.String.Escape */ +.codehilite .sh { color: #C3E88D } /* Literal.String.Heredoc */ +.codehilite .si { color: #89DDFF } /* Literal.String.Interpol */ +.codehilite .sx { color: #C3E88D } /* Literal.String.Other */ +.codehilite .sr { color: #89DDFF } /* Literal.String.Regex */ +.codehilite .s1 { color: #C3E88D } /* Literal.String.Single */ +.codehilite .ss { color: #89DDFF } /* Literal.String.Symbol */ +.codehilite .bp { color: #89DDFF } /* Name.Builtin.Pseudo */ +.codehilite .fm { color: #82AAFF } /* Name.Function.Magic */ +.codehilite .vc { color: #89DDFF } /* Name.Variable.Class */ +.codehilite .vg { color: #89DDFF } /* Name.Variable.Global */ +.codehilite .vi { color: #89DDFF } /* Name.Variable.Instance */ +.codehilite .vm { color: #82AAFF } /* Name.Variable.Magic */ +.codehilite .il { color: #F78C6C } /* Literal.Number.Integer.Long */ diff --git a/app/static/css/responsive.css b/app/static/css/responsive.css deleted file mode 100644 index 5aad72f..0000000 --- a/app/static/css/responsive.css +++ /dev/null @@ -1,46 +0,0 @@ -@media all and (max-width: 1399px) { - body, input { - font-size: 13px; - } - - header input[type="search"] { - font-size: 14px; - } - - #menu li { - font-size: 10px; - } - #menu a { - font-size: 13px; - } - - section { - width: 90%; - } -} - -@media all and (min-width: 1400px) { - body, input { - font-size: 13px; - } - - header input[type="search"] { - font-size: 14px; - } - - #menu li { - font-size: 11px; - } -} - -@media screen and (max-width: 1199px) { - .home-pinned-content article:nth-child(5) { - display: none; - } -} - -@media screen and (max-width: 849px) { - .home-pinned-content article:nth-child(4) { - display: none; - } -} diff --git a/app/static/css/shoutbox.css b/app/static/css/shoutbox.css index e7d2aca..8b49ee8 100644 --- a/app/static/css/shoutbox.css +++ b/app/static/css/shoutbox.css @@ -1,34 +1,34 @@ #shoutbox { - margin: 20px 5% 10px 5%; - /*box-shadow: 0 0 2px rgba(0, 0, 0, .4);*/ - background: #ffffff; - /*border: 1px solid #999999;*/ + margin: 20px 5% 10px 5%; + background: #ffffff; } - #shoutbox > div { - margin: 0; padding: 0; height: 125px; width: 100%; - overflow-y: scroll; border-bottom: 1px solid #999999; - border-radius: 5px 5px 0 0; - border: 1px solid #999999; + margin: 0; + padding: 0; + height: 125px; + width: 100%; + overflow-y: scroll; + border-bottom: 1px solid var(--border); + border-radius: 5px 5px 0 0; } -#shoutbox > input { - width: 100%; padding: 5px 0; - border-radius: 0 0 5px 5px; - border: 1px solid #999999; -} -#shoutbox > input:focus { - border-color: #a12222; - box-shadow: inset 0 1px 1px rgba(0, 0, 0, 0.075), 0 0 8px rgba(161, 34, 34, 0.6); -} - #shoutbox > div > div { - padding: 2px 10px; - border-bottom: 1px solid rgba(0, 0, 0, .3); - font-size: 11px; -} -#shoutbox > div > div:last-child { - border-bottom: none; + padding: 2px 10px; + border-bottom: 1px solid rgba(0,0,0,.3); + font-size: 11px; } #shoutbox > div > div:hover { - background: #e0e0e0; + background: var(--background); +} +#shoutbox > div > div:last-child { + border-bottom: none; +} +#shoutbox > input { + width: 100%; + padding: 5px 0; + border-radius: 0 0 5px 5px; + border: 1px solid var(--border); +} +#shoutbox > input:focus { + border-color: var(--border-focus); + box-shadow: inset 0 1px 1px rgba(0,0,0,0.075), 0 0 8px rgba(161,34,34,0.6); } \ No newline at end of file diff --git a/app/static/css/simplemde-override.css b/app/static/css/simplemde-override.css new file mode 100644 index 0000000..ae75f2b --- /dev/null +++ b/app/static/css/simplemde-override.css @@ -0,0 +1,71 @@ +/* SimpleMDE overwrite that allows us to customize from themes */ + +div.editor-toolbar { + border-color: var(--border); +} + +div.editor-toolbar > a { + color: var(--text) !important; +} +div.editor-toolbar > a.active, +div.editor-toolbar > a:hover { + background: var(--background-light); + border-color: var(--background-light); +} + +div.editor-toolbar > i.separator { + border-right-color: transparent; + border-left-color: var(--separator); +} + +div.editor-toolbar.disabled-for-preview a:not(.no-disable) { + background: none; + color: var(--text-disabled) !important; +} +div.editor-toolbar.disabled-for-preview > i.separator { + border-left-color: var(--text-disabled); +} + +div.CodeMirror, +div.editor-preview { + background: var(--background); + color: var(--text); + border-color: var(--border); +} +div.editor-preview { + background: var(--background-preview); +} + +div.editor-preview table th, +div.editor-preview-side table th, +div.editor-preview table td, +div.editor-preview-side table td { + border: inherit; + padding: inherit; +} + +div.editor-preview table.codehilitetable pre, +div.editor-preview-side table.codehilitetable pre { + background: transparent; +} + +div.CodeMirror .CodeMirror-selected, +div.CodeMirror .CodeMirror-selectedtext { + background: var(--background-light); +} +div.CodeMirror .CodeMirror-focused .CodeMirror-selected, +div.CodeMirror .CodeMirror-focused .CodeMirror-selectedtext, +div.CodeMirror .CodeMirror-line::selection, +div.CodeMirror .CodeMirror-line > span::selection, +div.CodeMirror .CodeMirror-line > span > span::selection { + background: var(--background-light); +} +div.CodeMirror .CodeMirror-line::-moz-selection, +div.CodeMirror .CodeMirror-line > span::-moz-selection, +div.CodeMirror .CodeMirror-line > span > span::-moz-selection { + background: var(--background-light); +} + +div.CodeMirror-cursor { + border-color: var(--text); +} diff --git a/app/static/css/table.css b/app/static/css/table.css index b5f9100..79f1975 100644 --- a/app/static/css/table.css +++ b/app/static/css/table.css @@ -17,57 +17,66 @@ table th { border-width: 1px 0; padding: 2px 6px; } -table td { - padding: 4px 6px; +table:not(.codehilitetable) td { + padding: 4px 6px; +} +table.codehilitetable { + border: none; + width: 100%; + table-layout: fixed; +} +table.codehilitetable tr:nth-child(even), +table.codehilitetable tr:nth-child(odd) { + background: none; +} +table.codehilitetable td { + padding: 0; +} +table.codehilitetable td.linenos { + width: 40px; + text-align: right; +} +table.codehilitetable td.linenos span { + padding-right: 8px; +} +table.codehilitetable td.code, +table.codehilitetable pre { + margin: 0; + width: 100%; +} +table.codehilitetable pre { + overflow-x: auto; } - -/* Forum and sub-forum listings */ - table.forumlist { border-collapse: separate; border-spacing: 0; - margin: 16px 0; width: 100%; } - -/* table.forumlist th { - background: #d05950; - border-color: #b04940; - color: white; -} */ - table.forumlist tr { background: unset; } +table.forumlist tr > td:last-child, +table.forumlist tr > th:last-child { + width: 20%; + text-align: center; +} table.forumlist tr:nth-child(4n+2), table.forumlist tr:nth-child(4n+3) { - background: rgba(0, 0, 0, .05); + background: rgba(0,0,0,.05); } - - -/* Topic table */ - table.topiclist { width: 100%; margin: auto; } -table.topiclist tr > *:nth-child(n+2) { - /* This matches all children except the first column */ +table.topiclist tr > * :nth-child(n+2) { text-align: center; } - - -table.forumlist th > td:last-child, -table.forumlist tr > td:last-child, -table.topiclist th > td:last-child, -table.topiclist tr > td:last-child { - width: 20%; text-align: center; +table.topiclist tr > td:last-child, +table.topiclist tr > th:last-child { + width: 20%; + text-align: center; } - - -/* Thread table */ - table.thread { width: 100%; border-width: 1px 0; @@ -75,39 +84,73 @@ table.thread { table.thread.topcomment { border: none; } -table.thread td.author { - width: 256px; +table.thread.topcomment td.message { + padding-top: 0; +} +table.thread.topcomment div.info { + padding-bottom: 4px; } table.thread td { vertical-align: top; } - -table.thread div.info { - float: right; - text-align: right; - opacity: 0.7; +table.thread td.author { + width: 256px; +} +table.thread td.message { padding-top: 8px; - margin-left: 16px; } -@media screen and (max-width: 1199px) { - table.thread div.info { - float: none; - display: flex; - flex-direction: row; - margin-left: 0; - } - table.thread div.info > *:not(:last-child):after { - content: '·'; - margin: 0 4px; - } - table.thread td.author { - /* Includes padding */ - width: 136px; - } +table.thread td.message > * :nth-child(2) { + margin-top: 0; +} +table.thread td.message img { + max-width: 100%; +} +table.thread:not(.topcomment) div.info { + float: right; +} +table.thread div.info { + text-align: right; + position: relative; + margin-left: 24px; + margin-bottom: 8px; +} +table.thread div.info > * { + display: inline-block; + vertical-align: top; +} +table.thread div.info summary { + list-style: none; + cursor: pointer; + user-select: none; +} +table.thread .topcomment-placeholder div { + font-style: italic; + opacity: 0.5; + padding: 8px 0; +} +table.thread .context-menu { + position: absolute; + right: 0; + top: 100%; + margin: 0; + padding: 4px 0; + box-shadow: var(--shadow); + border-radius: 4px; + transition: none; + background: var(--background); + z-index: 2; + border: 1px solid var(--border); +} +table.thread .context-menu a { + display: block; + padding: 4px 8px; + text-align: center; + color: inherit; +} +table.thread .context-menu a:hover { + background: var(--background-light); + text-decoration: none; } - -/* Tables with filters */ - table.filter-target th:after { content: attr(data-filter); display: block; @@ -115,3 +158,19 @@ table.filter-target th:after { font-family: monospace; font-weight: normal; } +@media screen and (max-width:1199px) { + table.thread td.author { + width: 136px; + overflow-wrap: anywhere; + } +} +@media screen and (max-width:849px) { + table.thread td.author { + width: 104px; + } +} +@media screen and (max-width:1199px) { + table.thread div.info { + float: none; + } +} \ No newline at end of file diff --git a/app/static/css/themes/FK_dark_theme.css b/app/static/css/themes/FK_dark_theme.css index 712b7a8..2a8ae11 100644 --- a/app/static/css/themes/FK_dark_theme.css +++ b/app/static/css/themes/FK_dark_theme.css @@ -10,10 +10,11 @@ */ :root { - --background: #1c2124; /*22292c, 1c2124, 1E1E1E, 242424,*/ - --text: #f2f2f2; + --background: #171a1c; /*22292c, 1c2124, 1E1E1E, 242424,*/ + --text: #eaeaea; + --text-light: #e2e2e2; - --links: #fe2d2d; + --links: #db3f3f; --ok: #149641; --ok-text: #ffffff; @@ -31,14 +32,13 @@ --info-text: #ffffff; --info-active: #215ab0; - --hr-border: 1px solid #b0b0b0; + --hr-border: 1px solid #404040; } .form { - --background: #ffffff; - --text: #000000; - --border: 1px solid #c8c8c8; - --border-focused: #7cade0; + --background: #1c2124; + --border: 1px solid #303030; + --border-focused: #577799; --shadow-focused: rgba(87, 143, 228, 0.5); } @@ -74,13 +74,13 @@ header { - --background: #0d1215; /*5a5a5a*/ + --background: #0d1215; --text: #000000; - --border: 1px solid #d0d0d0; + --border: 1px solid #404040; } footer { - --background: rgba(0, 0, 0, 1); /* #ffffff */ + --background: #0d1215; --text: #a0a0a0; --border: #d0d0d0; } @@ -102,12 +102,49 @@ footer { } .profile-xp { - --background: #e0e0e0; - --border: 1px solid #c0c0c0; + --background: #4a4a4a; + --border: 1px solid #5a5a5a; --background-xp: #f85555; --border-xp: 1px solid #d03333; } +.context-menu { + --background: #1d2326; + --shadow: 0 0 12px -9px #000000; + --border: #404040; + --background-light: #262c2f; +} -table tr:nth-child(even) { --background: rgba(255, 255, 255, 0.15); } -table tr:nth-child(odd) { --background: #1c2124; } /* 22292c = background, 1c2124, 1e1e1e*/ +table { + --border: #404040; +} +table tr:nth-child(even) { + --background: #262c2f; +} +table tr:nth-child(odd) { + --background: #1d2326; +} +table.codehilitetable { + --background: #263238; +} + +div.editor-toolbar, div.CodeMirror { + --border: #404040; + --background-light: #404040; + --background-preview: #1c2124; + --separator: #404040; + --text-disabled: #262c2f; +} + +.dl-button { + --link: #149641; + --link-text: #ffffff; + --link-active: #0f7331; + --meta: rgba(255, 255, 255, .15); + --meta-text: #ffffff; +} + +.gallery, .gallery-js { + --border: rgba(255, 255, 255, 0.8); + --selected: rgba(255, 0, 0, 1.0); +} diff --git a/app/static/css/themes/Tituya_v43_theme.css b/app/static/css/themes/Tituya_v43_theme.css new file mode 100644 index 0000000..f2cb187 --- /dev/null +++ b/app/static/css/themes/Tituya_v43_theme.css @@ -0,0 +1,174 @@ +/* Theme metadata +@NAME: Tituya's V43 theme +@AUTHOR: Tituya +*/ + +:root { + --background: #fff; + --text: #030303; + --text-light: #b62727; + + --links: #c02020; + + --ok: #b62727; + --ok-text: #ffffff; + --ok-active: #950c0c; + + --warn: #ffa01a; + --warn-text: #000; + --warn-active: #ff9600; + + --error: #d23a2f; + --error-text: #ffffff; + --error-active: #b32a20; + + --info: #2e7aec; + --info-text: #ffffff; + --info-active: #215ab0; + + --hr-border: 1px solid #aaa2a2; +} + +.form { + --background: #fff; + --text: #000; + --border: 1px solid #aaa2a2; + --border-focused: #577799; + --shadow-focused: rgba(87, 143, 228, 0.5); +} + +.editor button { + --background: #ffffff; + --text: #030303; + --border: 1px solid rgba(0, 0, 0, 0); + --border-focused: 1px solid rgba(0, 0, 0, .5); +} + +#light-menu { + --background: linear-gradient(90deg, #c01a1a 0%, #750d0d 150%); + --text: #ffffff; + --icons: #ffffff; + --shadow: 0 0 10px #a49594; + + --logo-bg: transparent; + --logo-shadow: 0 0 2px rgba(0, 0, 0, .7); + --logo-active: rgba(0, 0, 0, .15); +} + +#light-menu li > a { + opacity: 1; + margin: 0px; + padding-top: 10px; + padding-bottom: 10px; +} + +#light-menu li > a:hover { + background: rgba(0, 0, 0, .15); +} + +#menu { + --background: #fff; + --text: #030303; + --shadow: 0 0 8px rgb(155, 155, 155); + + --input-bg: #fff; + --input-text: #000; + --input-border: 1px solid #aaa2a2; +} + +#menu.opened svg > path { + fill: #be1818; +} + +header { + --background: #fff; + --border: 1px solid #be1818; +} + +header .title a { + color: var(--links); +} + +footer { + --background: #fff; + --text: #a0a0a0; +} + +.flash { + --background: #ffffff; + --text: #000; + --shadow: 0 1px 12px rgba(0, 0, 0, 0.3); + + /* Uncomment to inherit :root values + --ok: #149641; + --warn: #f59f25; + --error: #d23a2f; + --info: #2e7aec; */ + --btn-bg: rgba(0, 0, 0, 0); + --btn-text: #000000; + --btn-bg-active: rgba(0, 0, 0, .15); +} + +.profile-xp { + --background: #fff; + --border: 1px solid #be1818; + --background-xp: #be1818; + --border-xp: 1px solid #be1818; +} + +table { + --border: #aaa2a2; +} + +table tr:nth-child(even) { + --background: #fff; +} + +table tr:nth-child(odd) { + --background: #ecb0b0; +} + +/*background of the code block. Match with friendly theme Pygments*/ +table.codehilitetable tr { + --background: #f0f0f0; +} + +table th { + --background: #b62727; + color: #fff; +} + +table.thread { + border: 1px dashed #b62727; +} + +table.thread.topcomment { + border: 1px solid #c0c0c0; +} + +div.editor-toolbar { + --border: #aaa2a2; + --background-light: #c0c0c0; + --separator: #aaa2a2; + --text-disabled: #c0c0c0; + --text: #797474; + opacity: 1; +} + +div.editor-toolbar:hover { + opacity: 1; +} + +div.CodeMirror { + --border: #aaa2a2; + --background-preview: #fff; +} + +div.CodeMirror:hover { + box-shadow: 0px 0px 5px rgba(0, 0, 0, .2); +} + +div.pagination { + font-size: 14px; + margin: 13px; +} diff --git a/app/static/css/theme.css b/app/static/css/themes/default_theme.css similarity index 69% rename from app/static/css/theme.css rename to app/static/css/themes/default_theme.css index e1f1954..b87e4f8 100644 --- a/app/static/css/theme.css +++ b/app/static/css/themes/default_theme.css @@ -3,6 +3,7 @@ :root { --background: #ffffff; --text: #000000; + --text-light: #101010; --links: #c61a1a; @@ -22,18 +23,18 @@ --info-text: #ffffff; --info-active: #215ab0; - --hr-border: 1px solid #b0b0b0; + --hr-border: 1px solid #d8d8d8; } table { --border: #d8d8d8; } -table tr:nth-child(even) { +table tr:nth-child(odd) { --background: rgba(0, 0, 0, .1); } table th { - --background: #e0e0e0; - --border: #d0d0d0; + --background: #e0e0e0; + --border: #d0d0d0; } .form { @@ -76,7 +77,7 @@ table th { header { --background: #f4f4f6; --text: #000000; - --border: 1px solid #d0d0d0; + --border: 1px solid #d8d8d8; } footer { @@ -108,3 +109,36 @@ footer { --background-xp-100: #d03333; --border-xp: 1px solid #d03333; } + +.context-menu { + --background: #ffffff; + --shadow: 0 0 12px -9px #000000; + --border: #d0d0d0; + --background-light: #f0f0f0; +} + +div.editor-toolbar, div.CodeMirror { + --border: #c0c0c0; + --background-light: #d9d9d9; + --background-preview: #f4f4f6; + --separator: #a0a0a0; + --text-disabled: #c0c0c0; +} + +.dl-button { + --link: #149641; + --link-text: #ffffff; + --link-active: #0f7331; + --meta: rgba(0, 0, 0, .15); + --meta-text: #000000; +} + +.gallery, .gallery-js { + --border: rgba(0, 0, 0, 0.5); + --selected: rgba(0, 0, 0, 0.75); +} + +/* Extra style on top of the Pygments style */ +table.codehilitetable td.linenos { + color: #808080; +} diff --git a/app/static/css/vars.css b/app/static/css/vars.css new file mode 100644 index 0000000..e69de29 diff --git a/app/static/css/widgets.css b/app/static/css/widgets.css index 984b6e3..a7bb6eb 100644 --- a/app/static/css/widgets.css +++ b/app/static/css/widgets.css @@ -1,139 +1,210 @@ -/* Profile summaries */ - .profile { - display: flex; - align-items: center; - width: 265px; + display: flex; + align-items: center; + width: 265px; } - .profile-avatar { - width: 128px; - height: 128px; - margin-right: 16px; + width: 128px; + height: 128px; + margin-right: 16px; } - .profile-name { - font-weight: bold; + font-weight: bold; } - .profile-title { - margin-bottom: 8px; + margin-bottom: 8px; } - .profile-points { - font-size: 11px; + font-size: 11px; } - .profile-points span { - color: gray; + color: gray; } - .profile-points-small { - display: none; + display: none; } - .profile-xp { - height: 10px; - min-width: 96px; - background: var(--background); - border: var(--border); + height: 10px; + min-width: 96px; + max-width: 96px; + background: var(--background); + border: var(--border); } - .profile-xp-100 { - background: var(--background-xp); - border: var(--border-xp); + background: var(--background-xp); + border: var(--border-xp); } - .profile-xp div { - height: 10px; - background: var(--background-xp); - border: var(--border-xp); - margin: -1px; + height: 10px; + background: var(--background-xp); + border: var(--border-xp); + margin: -1px; } - .profile-xp-100 div { - background: var(--background-xp-100); + background: var(--background-xp-100); } - .profile.guest { - flex-direction: column; - width: 100%; - padding-top: 12px; - text-align: center; + flex-direction: column; + width: 100%; + padding-top: 12px; + text-align: center; } - .profile.guest em { - display: block; - font-weight: bold; - font-style: normal; - margin-bottom: 8px; + display: block; + font-weight: bold; + font-style: normal; + margin-bottom: 8px; } - -@media screen and (max-width: 1199px) { - table.thread .profile { - flex-direction: column; - width: 128px; - } - - table.thread .profile-avatar { - order: 1; - margin-right: 0; - } - - table.thread .profile-title, - table.thread .profile-points, - table.thread .profile-xp { - display: none; - } - - table.thread .profile-points-small { - display: inline; - } +@media (max-width:1199px) { + table.thread .profile { + flex-direction: column; + width: 96px; + text-align: center; + } + table.thread .profile-avatar { + order: 1; + margin-right: 0; + margin-top: 4px; + width: 96px; + height: 96px; + } + table.thread .profile-title, + table.thread .profile-points, + table.thread .profile-xp { + display: none; + } + table.thread .profile-points-small { + display: inline; + } } - -/* Trophies */ -.trophies { - display: flex; - flex-wrap: wrap; - justify-content: space-between; +@media (max-width:849px) { + table.thread .profile { + width: 96px; + } + table.thread .profile-avatar { + width: 64px; + height: 64px; + } } - -.trophy { - display: flex; - align-items: center; - width: 260px; - margin: 5px; - padding: 5px; - border: 1px solid #c5c5c5; - border-left: 5px solid var(--links); - border-radius: 2px; -} - -.trophy img { - height: 48px; - margin-right: 8px; -} - -.trophy div > * { - display: block; -} - -.trophy em { - font-style: normal; - font-weight: bold; - margin-bottom: 3px; -} - -.trophy span { - font-size: 80%; -} - -.trophy.disabled { - filter: grayscale(100%); - opacity: .5; - border-left: 1px solid #c5c5c5; -} - hr.signature { - opacity: 0.2; + opacity: 0.2; } +.trophies { + display: flex; + flex-wrap: wrap; + justify-content: space-between; +} +.trophy { + display: flex; + align-items: center; + width: 260px; + margin: 5px; + padding: 5px; + border: 1px solid #c5c5c5; + border-left: 5px solid var(--links); + border-radius: 2px; +} +.trophy.disabled { + filter: grayscale(100%); + opacity: .5; + border-left: 1px solid #c5c5c5; +} +.trophy.form-disabled { + border-left: 1px solid #c5c5c5; + flex-grow: 1; +} +.trophy img { + height: 48px; + margin-right: 8px; +} +.trophy div > * { + display: block; +} +.trophy em { + font-style: normal; + font-weight: bold; + margin-bottom: 3px; +} +.trophy span { + font-size: 80%; +} +.dl-button { + display: inline-flex; + flex-direction: row; + align-items: stretch; + border-radius: 5px; + overflow: hidden; + margin: 3px 5px; + vertical-align: middle; +} +.dl-button a { + display: flex; + align-items: center; + padding: 5px 15px; + font-size: 110%; + background: var(--link); + color: var(--link-text); +} +.dl-button a:hover, +.dl-button a:focus, +.dl-button a:active { + background: var(--link-active); + text-decoration: none; +} +.dl-button span { + display: flex; + align-items: center; + padding: 5px 8px; + background: var(--meta); + color: var(--meta-text); + font-size: 90%; +} +.gallery { + display: flex; + flex-wrap: wrap; + justify-content: center; + margin: auto; +} +.gallery * { + margin: 3px; + border: 1px solid var(--border); +} +.gallery-js { + display: flex; + overflow-x: auto; + overflow-y: hidden; + margin: auto; + padding: 15px; + height: 180px; +} +.gallery-js img, +.gallery-js video { + height: 100%; + border: 1px solid var(--border); + cursor: pointer; +} +.gallery-js img:not(:first-child), +.gallery-js video:not(:first-child) { + margin-left: 15px; +} +.gallery-js img.selected, +.gallery-js video.selected { + box-shadow: 0 0 7.5px var(--selected); +} +@media screen and (max-width:1199px) { + .gallery-js { + height: 150px; + } +} +@media screen and (max-width:499px) { + .gallery-js { + height: 130px; + } +} +.gallery-spot { + justify-content: center; + margin: 10px auto; +} +.gallery-spot * { + cursor: pointer; +} \ No newline at end of file diff --git a/app/static/less/container.less b/app/static/less/container.less new file mode 100644 index 0000000..94b9bb2 --- /dev/null +++ b/app/static/less/container.less @@ -0,0 +1,21 @@ +@import "vars"; + +.container { + margin-left: 110px; + + @media screen and (max-width: @tiny) { + margin-left: 0; + } +} + +section { + width: 80%; + margin: 20px auto 0 auto; + + @media screen and (max-width: @normal) { + width: 90%; + } + @media screen and (max-width: @small) { + width: 95%; + } +} diff --git a/app/static/less/flash.less b/app/static/less/flash.less new file mode 100644 index 0000000..e75f5c1 --- /dev/null +++ b/app/static/less/flash.less @@ -0,0 +1,39 @@ +/* + flash overlay +*/ + +.flash { + margin: 5px auto; + display: flex; + align-items: center; + width: 80%; + font-size: 14px; + border-bottom: 5px solid var(--info); + border-radius: 1px; + box-shadow: var(--shadow); + + &.info { + border-color: var(--info); + } + + &.ok { + border-color: var(--ok); + } + + &.warning { + border-color: var(--warn); + } + + &.error { + border-color: var(--error); + } + + span { + flex-grow: 1; + margin: 15px 10px 10px 0; + } + + svg { + margin: 15px 20px 10px 30px; + } +} diff --git a/app/static/less/footer.less b/app/static/less/footer.less new file mode 100644 index 0000000..0091485 --- /dev/null +++ b/app/static/less/footer.less @@ -0,0 +1,14 @@ +/* + Footer +*/ + +footer { + margin: 20px 0 0 0; padding: 10px 10%; + text-align: center; font-size: 11px; font-style: italic; + background: var(--background); color: var(--text); + border-top: var(--border); + + p { + margin: 3px 0; + } +} diff --git a/app/static/less/form.less b/app/static/less/form.less new file mode 100644 index 0000000..c391989 --- /dev/null +++ b/app/static/less/form.less @@ -0,0 +1,152 @@ +/* Full-page forms */ + +.form { + form { + & > div:not(:last-child):not(.editor-toolbar) { + margin-bottom: 16px; + } + + label { + display: inline-block; + margin: 0 5px 4px 0; + + & + .desc { + margin: 0 0 4px 0; + font-size: 80%; + opacity: .75; + } + } + + .avatar { + width: 128px; height: 128px; + + & + input[type="file"] { + margin: 16px 0 0 0; + vertical-align: middle; + } + } + } + + input[type='text'], + input[type='email'], + input[type='date'], + input[type='password'], + input[type='search'], + textarea, + select { + display: block; + width: 100%; padding: 6px 8px; + background: var(--background); color: var(--text); + border: var(--border); + + /* Transitions when resizing with the mouse produces apparent lag */ + transition: all .15s ease, width 0s, height 0s; + + &:focus { + border-color: var(--border-focused); + box-shadow: 0 0 0 3px var(--shadow-focused); + } + + &:focus-within { + /* Override an annoying Firefox default */ + outline: none; + } + } + + input[type='checkbox'], + input[type='radio'] { + display: inline; + vertical-align: middle; + margin: 0 4px 0 0; + } + + textarea { + max-width: 100%; + resize: vertical; + } + + select { + width: auto; + } + + progress.entropy { + display: none; /* Display with Js enabled */ + width: 100%; margin-top: 5px; + background: var(--background); + border: var(--border); + + &.low { + &::-moz-progress-bar, + &::-webkit-progress-bar { + background: var(--error); + } + } + &.medium { + &::-moz-progress-bar, + &::-webkit-progress-bar { + background: var(--warn); + } + } + &.high { + &::-moz-progress-bar, + &::-webkit-progress-bar { + background: var(--ok); + } + } + } + + hr { + height: 3px; + border: var(--hr-border); + border-width: 1px 0; + margin: 24px 0; + } + + .msgerror { + color: var(--error); + font-weight: 400; + margin-top: 5px; + } + + /* anti-bots field */ + .abfield { + display: none; + } +} + + +/* Interactive filter forms */ + +.form.filter { + margin-bottom: 16px; + + & > p:first-child { + font-size: 80%; + color: gray; + margin-bottom: 2px; + } + + input { + font-family: monospace; + } + + .syntax-explanation { + font-size: 80%; + color: gray; + margin-top: 8px; + + ul { + font-size: inherit; + color: inherit; + padding-left: 16px; + line-height: 20px; + margin-top: 2px; + } + + code { + background: rgba(0,0,0,.05); + padding: 1px 2px; + border-radius: 2px; + } + } +} diff --git a/app/static/less/global.less b/app/static/less/global.less new file mode 100644 index 0000000..cfbf990 --- /dev/null +++ b/app/static/less/global.less @@ -0,0 +1,154 @@ +@import "vars"; + +/* Fonts */ + +@font-face { font-family: NotoSans; src: url(../fonts/noto_sans.ttf); font-display: swap; } +@font-face { font-family: Twemoji; src: url(../fonts/TwitterColorEmoji.ttf); font-display: swap; } +@font-face { font-family: Cantarell; font-weight: normal; src: url(../fonts/Cantarell-Regular.otf); font-display: swap; } +@font-face { font-family: Cantarell; font-weight: bold; src: url(../fonts/Cantarell-Bold.otf); font-display: swap; } + +/* Whole page */ + +* { + box-sizing: border-box; + /* This transition value is replicated everywhere transitions are customized, + make sure to track them when editing */ + transition: .15s ease; +} + +body { + margin: 0; + background: var(--background); + color: var(--text); + font-family: 'DejaVu Sans', sans-serif; + font-size: 13px; + + @media screen and (min-width: @normal) { + font-size: 14px; + } +} + +/* General */ + +a { + text-decoration: none; + color: var(--links); + + &:hover, &:focus { + text-decoration: underline; + outline: none; + } +} + +img.pixelated { + image-rendering: pixelated; +} + +section { + p { + line-height: 20px; + word-wrap: anywhere; + } + + ul { + line-height: 24px; + } + + h1 { + margin-top: 0; + border-bottom: var(--hr-border); + font-family: Cantarell; font-weight: bold; + font-size: 26px; + color: var(--text-light); + } + + h2 { + margin: 24px 0 16px 0; + border-bottom: var(--hr-border); + font-family: Cantarell; font-weight: bold; + font-size: 18px; + color: var(--text-light); + padding-bottom: 2px; + } +} + +/* Buttons */ +.button, input[type="button"], input[type="submit"] { + padding: 6px 10px; border-radius: 2px; + cursor: pointer; + font-family: 'DejaVu Sans', sans-serif; font-weight: 400; + border: 0; + + &:hover, &:focus { + text-decoration: none; + } +} + + +/* Bootstrap-style rules */ +.flex { + display: flex; +} + +.bg-ok { + background: var(--ok); + color: var(--ok-text); + + &:hover, &:focus, &:active { + background: var(--ok-active); + } +} + +.bg-error { + background: var(--error); + color: var(--error-text); + + &:hover, &:focus, &:active { + background: var(--error-active); + } +} + +.bg-warn { + background: var(--warn); + color: var(--warn-text); + + &:hover, &:focus, &:active { + background: var(--warn-active); + } +} + +.align-left { + text-align: left; +} +.align-center { + display: block; + margin-left: auto; + margin-right: auto; +} +.align-right { + display: block; + margin-left: auto; +} +.float-left { + float: left; +} +.float-right { + float: right; +} + + +.skip-to-content-link { + height: 30px; + left: 50%; + padding: 8px; + position: absolute; + transform: translateY(-100%); + transition: transform 0.3s; + background: var(--links); + color: var(--warn-text); + border-radius: 1px; + + &:focus { + transform: translateY(0%); + } +} diff --git a/app/static/less/header.less b/app/static/less/header.less new file mode 100644 index 0000000..c4436ca --- /dev/null +++ b/app/static/less/header.less @@ -0,0 +1,105 @@ +@import "vars"; + +header { + margin: 0; padding: 8px 16px; + background: var(--background); border-bottom: var(--border); + + display: flex; align-items: center; justify-content: space-between; + flex-flow: row wrap; + + overflow: hidden; + + .title { + margin: 4px 0; + + a { + color: inherit; + } + + h1 { + font-family: Cantarell; font-weight: bold; font-size: 18px; + display: inline; + } + } + + .spacer { + flex: 1 0 auto; + } + + .links { + margin-left: 16px; + } + + .form { + margin-right: -26px; + + @media screen and (max-width: @tiny) { + display: none; + } + + input[type="search"] { + display: inline-block; width: 250px; + padding: 5px 35px 5px 10px; + + @media screen and (max-width: @small) { + width: 200px; + } + @media screen and (min-width: @normal) { + font-size: 14px; + } + + &:focus { + & ~ a { + opacity: 1; + } + } + + & ~ a { + position: relative; left: -33px; + opacity: .7; + + & > svg > path { + fill: var(--text); + } + } + } + + a { + fill: #363636; + cursor: pointer; + + &:hover, &:focus { + & > svg { + fill: var(--text); + } + } + } + svg { + width: 24px; height: 24px; vertical-align: middle; + transition: .15s ease; + } + } + + #spotlight { + margin-left: 16px; + + @media screen and (max-width: @small) { + display: none; + } + + a { + display: block; + } + } +} + +#server-speed-warning { + background: var(--warn); + color: var(--warn-text); + text-align: center; + border-radius: 2px; + padding: 4px; + margin: 0 8px; + font-weight: bold; + text-shadow: 0 1px 1px rgba(0,0,0,.5); +} diff --git a/app/static/less/homepage.less b/app/static/less/homepage.less new file mode 100644 index 0000000..37eda35 --- /dev/null +++ b/app/static/less/homepage.less @@ -0,0 +1,140 @@ +/* + home-title +*/ + +.home-title { + margin: 20px 0; padding: 10px 5%; + background: #bf1c11; box-shadow: 0 2px 2px rgba(0, 0, 0, .3); + border-top: 10px solid #ab170c; + + h1 { + margin-top: 0; + color: #ffffff; border-color: #ffffff; + } + + p { + margin-bottom: 0; text-align: justify; + color: #ffffff; + } + + a { + color: inherit; text-decoration: underline; + } +} + + +/* + pinned-content +*/ + +.home-pinned-content { + & > div { + display: flex; justify-content: space-between; + } + + h2 { + display: block; margin: 5px 0; + font-size: 18px; font-family: NotoSans; font-weight: 200; + line-height: 20px; + } + + a { + display: block; + + &:hover, &:focus { + img { + filter: blur(3px); + } + div { + padding: 200px 5% 10px 5%; + background-image: linear-gradient(180deg,transparent 0,rgba(0,0,0,.7) 40px,rgba(0,0,0,.8)); + } + } + } + + img { + width: 100%; filter: blur(0px); + } + + article { + flex-grow: 1; margin: 0 1px; padding: 0; + position: relative; + max-width: 250px; overflow: hidden; + + div { + position: absolute; bottom: 0; z-index: 3; + width: 90%; margin: 0; + padding: 30px 5% 10px 5%; + color: #ffffff; text-shadow: 1px 1px 0 rgba(0,0,0,.6); + background-image: linear-gradient(180deg,transparent 0,rgba(0,0,0,.7) 40px,rgba(0,0,0,.8)); + } + } +} + + +/* + home-articles +*/ + +.home-articles { + display: flex; justify-content: space-between; + + & > div { + flex-grow: 1; max-width: 48%; + } + + h1 { + display: flex; justify-content: space-between; align-items: center; + + a { + padding: 0; + font-family: NotoSans; font-size: 16px; + font-weight: 400; color: /*#015078*/ /*#bf1c11*/ #234d5f; + + &:hover, &:focus { + padding-right: 10px; + } + } + } + + p { + margin: 5px 0; + text-align: justify; + color: #808080; + } + + article { + padding: 10px; margin: 10px 0; display: flex; align-items: center; + background: #ffffff; border: 1px solid rgba(0, 0, 0, .2); + + & > img { + float: left; margin-right: 10px; flex-shrink: 0; + + &.screeshot { + width: 128px; height: 64px; + } + } + + & > div { + flex-shrink: 1; + } + + h3 { + margin: 0; + color: #424242; font-weight: normal; + } + + a:hover, a:focus { + text-decoration: underline; + } + } + + .metadata { + margin: 0; + color: #22292c; + + a { + color: #22292c; font-weight: 400; font-style: italic; + } + } +} diff --git a/app/static/less/navbar.less b/app/static/less/navbar.less new file mode 100644 index 0000000..a9143c7 --- /dev/null +++ b/app/static/less/navbar.less @@ -0,0 +1,279 @@ +@import "vars"; + +nav a { + opacity: .8; + cursor: pointer; + + &:hover, &:focus { + opacity: 1; + } +} + + +/* Menu */ + +#logo { + position: relative; display: block; + width: 100%; + margin-bottom: 10px; + opacity: 1; + background: var(--logo-bg); + transition: .15s ease; + + @media screen and (max-width: @tiny) { + width: auto; height: 100%; margin-bottom: 0; + } + + &:hover, &:focus { + background: var(--logo-active); + + img { + filter: drop-shadow(var(--logo-shadow)); + } + } + + img { + display: block; height: 65px; + margin: 0 auto; padding: 0; + filter: drop-shadow(0 0 2px rgba(0, 0, 0, .0)); + transition: filter .15s ease; + + @media screen and (max-width: @small) { + width: 60px; height: inherit; + margin-bottom: -4.5px; + } + @media screen and (max-width: @micro) { + width: 50px; + } + } +} + +#light-menu { + position: fixed; z-index: 10; + list-style: none; + width: 110px; + height: 100%; overflow-y: auto; + margin: 0; padding: 0; + text-indent: 0; font-size: 13px; + background: var(--background); box-shadow: var(--shadow); + + @media screen and (max-width: @tiny) { + position: unset; + display: flex; flex-direction: row; align-items: center; + width: 100%; height: 60px; + overflow-x: auto; overflow-y: hidden; + } + @media screen and (max-width: @micro) { + height: 50px; + } + + + li { + width: 100%; + color: var(--text); + + @media screen and (max-width: @tiny) { + display: flex; flex-direction: column; + align-items: center; flex-grow: 1; + padding: 0 2px; font-size: 12px; + } + + & > a { + display: flex; flex-direction: column; flex-grow: 0; + align-items: center; justify-content: center; + width: 100%; height: 100%; + margin: 20px 0; + color: var(--text); + transition: opacity .15s ease; /* because Chrome sucks */ + + /* Avatar */ + & > img { + display: block; width: 60px; flex-shrink: 0; flex-grow: 0; + margin: 0 7px 5px 7px; + border-radius: 10%; + + @media screen and (max-width: @tiny) { + width: 45px; + margin: 0; + + & ~ div { + display: none; + } + } + @media screen and (max-width: @micro) { + width: 40px; + } + } + + & > svg { + display: block; width: 25px; flex-shrink: 0; flex-grow: 0; + margin: 0 7px; + + & > path { + fill: var(--icons); + } + } + + & > div { + @media screen and (max-width: @micro) { + display: none; + } + } + } + } +} + + +/* Overlay */ +#menu { + position: fixed; z-index: 5; + left: -190px; width: 300px; /* default: left-to-right animation */ + height: 100%; overflow-x: hidden; overflow-y: auto; + font-size: 13px; + background: var(--background); color: var(--text); + box-shadow: var(--shadow); + transition: .15s ease; + + @media screen and (max-width: @tiny) { + width: 100%; height: 0; overflow-x: hidden; + font-family: NotoSans; + transition: .1s ease; + position: unset; + left: unset; + } + + &.opened { + left: 110px; + + @media screen and (max-width: @tiny) { + height: 100%; + overflow-y: auto; + left: unset; + } + } + +/* Set class="scroll-animation" to menu to apply scroll animation */ + &.scroll-animation { + left: 110px; width: 0; + &.opened { + width: 300px; + } + } + + & > div { + width: 300px; + padding: 16px; + display: none; + + @media screen and (max-width: @tiny) { + width: 100%; + padding-bottom: 2px; + } + + &.opened { + display: block; + } + } + + h2 { + margin: 0 0 20px 0; + font-family: Cantarell; font-weight: bold; font-size: 18px; + color: var(--text); + display: flex; align-items: center; + + @media screen and (max-width: @micro) { + font-size: 15px; + } + + a { + margin: 0; + font-size: inherit; opacity: inherit; + + &:hover, &:focus { + text-decoration: underline; + } + } + + & > svg { + width: 32px; vertical-align: middle; margin-right: 8px; + + @media screen and (max-width: @micro) { + width: 24px; + } + } + + img { + height: 48px; vertical-align: middle; margin-right: 10px; + } + } + + h3 { + margin: 16px 0; + font-family: Cantarell; font-weight: bold; font-size: 15px; + color: var(--text); + } + + hr { + margin: 15px 0; + border: none; + border-bottom: 1px solid rgba(255, 255, 255, 0.1); + } + + ul { + margin: 0; padding: 0; list-style: none; + } + + a, li { + display: block; margin: 10px 0; + color: var(--text); + transition: opacity .15s ease; + } + + li > a { + display: inline; + margin: 0; font-style: normal; + font-size: 13px; + } + + a > img { + vertical-align: middle; + margin-right: 15px; + } + + a > svg { + width: 20px; height: 20px; vertical-align: middle; + margin-right: 10px; + } + + /* Login form */ + form { + padding: 0 8%; + + @media screen and (max-width: @tiny) { + padding: 0; + } + + input[type="text"], + input[type="password"] { + margin: 8px 0; padding: 5px 2%; + font-size: 14px; + border: var(--input-border); + background: var(--input-bg); + color: var(--input-text); + opacity: .8; + + &:focus { + opacity: 1; + } + } + + input[type="submit"] { + width: 100%; + margin: 8px 0 5px 0; + } + + label { + font-size: 13px; opacity: .8; + } + } +} diff --git a/app/static/less/pagination.less b/app/static/less/pagination.less new file mode 100644 index 0000000..2f59ca5 --- /dev/null +++ b/app/static/less/pagination.less @@ -0,0 +1,4 @@ +.pagination { + text-align: center; + margin: 5px 0; +} diff --git a/app/static/less/shoutbox.less b/app/static/less/shoutbox.less new file mode 100644 index 0000000..957aea1 --- /dev/null +++ b/app/static/less/shoutbox.less @@ -0,0 +1,35 @@ +#shoutbox { + margin: 20px 5% 10px 5%; + background: #ffffff; + + & > div { + margin: 0; padding: 0; height: 125px; width: 100%; + overflow-y: scroll; border-bottom: 1px solid var(--border); + border-radius: 5px 5px 0 0; + + & > div { + padding: 2px 10px; + border-bottom: 1px solid rgba(0, 0, 0, .3); + font-size: 11px; + + &:hover { + background: var(--background); + } + + &:last-child { + border-bottom: none; + } + } + } + + & > input { + width: 100%; padding: 5px 0; + border-radius: 0 0 5px 5px; + border: 1px solid var(--border); + + &:focus { + border-color: var(--border-focus); + box-shadow: inset 0 1px 1px rgba(0, 0, 0, 0.075), 0 0 8px rgba(161, 34, 34, 0.6); + } + } +} diff --git a/app/static/less/table.less b/app/static/less/table.less new file mode 100644 index 0000000..de1cfcf --- /dev/null +++ b/app/static/less/table.less @@ -0,0 +1,220 @@ +@import "vars"; + +table { + border-collapse: collapse; + border-color: var(--border); + border-style: solid; + border-width: 0 0 1px 0; + + tr { + &:nth-child(even) { + background: var(--background); + } + &:nth-child(odd) { + background: var(--background); + } + } + + th { + background: var(--background); + border-color: var(--border); + border-style: solid; + border-width: 1px 0; + padding: 2px 6px; + } + + &:not(.codehilitetable) td { + padding: 4px 6px; + } + + /* Code fragments */ + &.codehilitetable { + border: none; + width: 100%; +/* This seems to be the only way to prevent the automatic table size + algorithm from assigning extreme widths when content overflows. + Fortunately the structure of the table is very simple, so we can afford to + set a fixed width on line numbers and move on */ + table-layout: fixed; + + tr:nth-child(even), + tr:nth-child(odd) { + background: none; + } + + td { + padding: 0; + + &.linenos { + width: 40px; + text-align: right; + span { + padding-right: 8px; + } + } + } + + td.code, + pre { + margin: 0; + width: 100%; + } + + pre { + overflow-x: auto; + } + } + + /* Forum and sub-forum listings */ + &.forumlist { + border-collapse: separate; + border-spacing: 0; + margin: 16px 0; + width: 100%; + + tr { + background: unset; + + & > td:last-child, + & > th:last-child { + width: 20%; text-align: center; + } + + &:nth-child(4n+2), + &:nth-child(4n+3) { + background: rgba(0, 0, 0, .05); + } + } + } + + /* Topic table */ + &.topiclist { + width: 100%; + margin: auto; + + tr { + & > *:nth-child(n+2) { + /* This matches all children except the first column */ + text-align: center; + } + + & > td:last-child, + & > th:last-child { + width: 20%; text-align: center; + } + } + } + + /* Thread table */ + &.thread { + width: 100%; + border-width: 1px 0; + + &.topcomment { + border: none; + + td.message { + padding-top: 0; + } + + div.info { + padding-bottom: 4px; + } + } + + td { + vertical-align: top; + + &.author { + width: 256px; + + @media screen and (max-width: @small) { + /* Includes padding */ + width: 136px; + overflow-wrap: anywhere; + } + + @media screen and (max-width: @tiny) { + width: 104px; + } + } + + &.message { + padding-top: 8px; + + & > *:nth-child(2) { + margin-top: 0; + } + + img { + max-width: 100%; + } + } + } + + &:not(.topcomment) div.info { + float: right; + } + div.info { + text-align: right; + position: relative; + + @media screen and (max-width: @small) { + float: none; + } + + & > * { + display: inline-block; + vertical-align: top; + } + + summary { + list-style: none; + cursor: pointer; + user-select: none; + } + } + + .topcomment-placeholder div { + font-style: italic; + opacity: 0.5; + padding: 8px 0; + } + + .context-menu { + position: absolute; + right: 0; + top: 100%; + margin: 0; + padding: 4px 0; + box-shadow: var(--shadow); + border-radius: 4px; + transition: none; + + background: var(--background); + z-index: 2; + border: 1px solid var(--border); + + a { + display: block; + padding: 4px 8px; + text-align: center; + color: inherit; + + &:hover { + background: var(--background-light); + text-decoration: none; + } + } + } + } + + /* Tables with filters */ + &.filter-target th:after { + content: attr(data-filter); + display: block; + font-size: 80%; + font-family: monospace; + font-weight: normal; + } +} diff --git a/app/static/less/vars.less b/app/static/less/vars.less new file mode 100644 index 0000000..2da7160 --- /dev/null +++ b/app/static/less/vars.less @@ -0,0 +1,14 @@ +/* Currently 3 screen sizes supported : + - micro: < 500px + - tiny: < 850px + - small: < 1200px + - normal: >= 1200px + + Ex: + @media screen and (max-width: @var) +*/ + +@micro: 499px; +@tiny: 849px; +@small: 1199px; +@normal: 1449px; diff --git a/app/static/less/widgets.less b/app/static/less/widgets.less new file mode 100644 index 0000000..c86f2e0 --- /dev/null +++ b/app/static/less/widgets.less @@ -0,0 +1,241 @@ +@import "vars"; + +/* Profile summaries */ + +.profile { + display: flex; + align-items: center; + width: 265px; +} + +.profile-avatar { + width: 128px; + height: 128px; + margin-right: 16px; +} + +.profile-name { + font-weight: bold; +} + +.profile-title { + margin-bottom: 8px; +} + +.profile-points { + font-size: 11px; +} + +.profile-points span { + color: gray; +} + +.profile-points-small { + display: none; +} + +.profile-xp { + height: 10px; + min-width: 96px; + max-width: 96px; + background: var(--background); + border: var(--border); +} + +.profile-xp-100 { + background: var(--background-xp); + border: var(--border-xp); +} + +.profile-xp div { + height: 10px; + background: var(--background-xp); + border: var(--border-xp); + margin: -1px; +} + +.profile-xp-100 div { + background: var(--background-xp-100); +} + +.profile.guest { + flex-direction: column; + width: 100%; + padding-top: 12px; + text-align: center; +} + +.profile.guest em { + display: block; + font-weight: bold; + font-style: normal; + margin-bottom: 8px; +} + +@media (max-width: @small) { + table.thread { + .profile { + flex-direction: column; + width: 96px; + text-align: center; + } + + .profile-avatar { + order: 1; + margin-right: 0; + margin-top: 4px; + width: 96px; + height: 96px; + } + + .profile-title, + .profile-points, + .profile-xp { + display: none; + } + + .profile-points-small { + display: inline; + } + } +} +@media (max-width: @tiny) { + table.thread .profile { + width: 96px; + } + table.thread .profile-avatar { + width: 64px; + height: 64px; + } +} + +hr.signature { + opacity: 0.2; +} + + +/* Trophies */ +.trophies { + display: flex; + flex-wrap: wrap; + justify-content: space-between; +} + +.trophy { + display: flex; + align-items: center; + width: 260px; + margin: 5px; + padding: 5px; + border: 1px solid #c5c5c5; + border-left: 5px solid var(--links); + border-radius: 2px; + + &.disabled { + filter: grayscale(100%); + opacity: .5; + border-left: 1px solid #c5c5c5; + } + + &.form-disabled { + border-left: 1px solid #c5c5c5; + flex-grow: 1; + } + + img { + height: 48px; + margin-right: 8px; + } + + div > * { + display: block; + } + + em { + font-style: normal; + font-weight: bold; + margin-bottom: 3px; + } + + span { + font-size: 80%; + } +} + + +/* Download button */ +.dl-button { + display: inline-flex; flex-direction: row; align-items: stretch; + + border-radius: 5px; overflow: hidden; + margin: 3px 5px; vertical-align: middle; + + a { + display:flex; align-items:center; + padding: 5px 15px; + + font-size: 110%; + background: var(--link); color: var(--link-text); + + &:hover, &:focus, &:active { + background: var(--link-active); + text-decoration: none; + } + } + + span { + display: flex; align-items:center; + padding: 5px 8px; + background: var(--meta); color: var(--meta-text); + font-size: 90%; + } +} + +/* Gallery without Javascript */ +.gallery { + display: flex; flex-wrap: wrap; + justify-content: center; margin: auto; + + * { + margin: 3px; + border: 1px solid var(--border); + } +} + +/* Gallery with Javascript */ +.gallery-js { + @padding: 15px; + display: flex; overflow-x: auto; overflow-y: hidden; + margin: auto; padding: @padding; + height: 150px + 2 * @padding; + + @media screen and (max-width: @small) { + height: 120px + 2 * @padding; + } + @media screen and (max-width: @micro) { + height: 100px + 2 * @padding; + } + + img, video { + height: 100%; + border: 1px solid var(--border); + cursor: pointer; //box-sizing: content-box; + + &:not(:first-child) { + margin-left: @padding; + } + + &.selected { + box-shadow: 0 0 @padding/2 var(--selected); + } + } +} + +.gallery-spot { + justify-content: center; + margin: 10px auto; + + * { + cursor: pointer; + } +} diff --git a/app/static/scripts/gallery.js b/app/static/scripts/gallery.js new file mode 100644 index 0000000..f6ba5b6 --- /dev/null +++ b/app/static/scripts/gallery.js @@ -0,0 +1,55 @@ +document.querySelectorAll(".gallery").forEach(item => { + // Switch to gallery-js stylesheet + item.className = "gallery-js"; + + // Create the spotlight container + let spot = document.createElement('div'); + spot.className = "gallery-spot"; + spot.style.display = "none"; + spot.appendChild(item.firstElementChild.cloneNode(true)); + item.after(spot); + + // Add some logic + // item.addEventListener("click", function(e) { + // console.log(e.target); + // console.log(e.currentTarget); + // // Select the clicked media + // Array.from(item.children).forEach(child => { + // child.classList.remove('selected'); + // }); + // e.target.classList.add('selected'); + // + // // Display the current + // e.currentTarget.nextElementSibling.querySelector('div').innerHTML = e.target.outerHTML; + // }); +}); + +document.querySelectorAll(".gallery-js > *").forEach(item => { + item.addEventListener("click", function(e) { + console.log(e.target); + // Manage selected media + if(e.target.classList.contains('selected')) { + e.target.classList.remove('selected'); + } else { + e.target.classList.add('selected'); + } + Array.from(e.target.parentElement.children).forEach(el => { + if(el != e.target) el.classList.remove('selected'); + }); + + // Change content of spotlight + let spot = e.target.parentElement.nextElementSibling; + spot.replaceChild(e.target.cloneNode(true), spot.firstElementChild); + // Open spotlight media in a new tab + spot.firstElementChild.addEventListener("click", function(e) { + window.open(spot.firstElementChild.src, "_blank"); + }); + + // Display the spotlight + if(e.target.classList.contains('selected')) { + spot.style.display = "flex"; + } else { + spot.style.display = "none"; + } + }); +}); diff --git a/app/static/scripts/pc-utils.js b/app/static/scripts/pc-utils.js index 16c7619..98948d4 100644 --- a/app/static/scripts/pc-utils.js +++ b/app/static/scripts/pc-utils.js @@ -11,3 +11,17 @@ function getCookie(name) { if( end == -1 ) end = document.cookie.length; return unescape( document.cookie.substring( debut+name.length+1, end ) ); } + +/* Automatically close context menus when clicking out of them */ +function closeContextMenus(e) { + document.querySelectorAll('details[open]>.context-menu').forEach(menu => { + if(!menu.contains(event.target)) { + menu.parentElement.open = false; + e.preventDefault(); + } + }); +} + +(function(){ + window.addEventListener("click", closeContextMenus); +})(); diff --git a/app/static/scripts/trigger_menu.js b/app/static/scripts/trigger_menu.js index ed00cf5..7c3fcfa 100644 --- a/app/static/scripts/trigger_menu.js +++ b/app/static/scripts/trigger_menu.js @@ -69,5 +69,5 @@ var keyboard_trigger = function(event) { } } -document.onclick = mouse_trigger; -document.onkeypress = keyboard_trigger; +document.addEventListener("click", mouse_trigger); +document.addEventListener("keydown", keyboard_trigger); diff --git a/app/templates/account/account.html b/app/templates/account/account.html index 03981da..ee80383 100644 --- a/app/templates/account/account.html +++ b/app/templates/account/account.html @@ -89,6 +89,16 @@ {{ error }} {% endfor %} +
+ {{ form.theme.label }} + {% for subfield in form.theme %} +
{{ subfield }} {{ subfield.label }}
+ {% endfor %} +
{{ form.theme.description }}
+ {% for error in form.theme.errors %} + {{ error }} + {% endfor %} +
{{ form.submit(class_="bg-ok") }}
diff --git a/app/templates/account/delete_account.html b/app/templates/account/delete_account.html index 5ad5260..e4f09ca 100644 --- a/app/templates/account/delete_account.html +++ b/app/templates/account/delete_account.html @@ -6,6 +6,13 @@
{{ del_form.hidden_tag() }}
+ {{ del_form.transfer.label }} + {{ del_form.transfer() }} +
{{ del_form.transfer.description }}
+ {% for error in del_form.transfer.errors %} + {{ error }} + {% endfor %} +
{{ del_form.delete.label }} {{ del_form.delete(checked=False) }}
{{ del_form.delete.description }}
diff --git a/app/templates/account/polls.html b/app/templates/account/polls.html index 6e6d627..6fcca85 100644 --- a/app/templates/account/polls.html +++ b/app/templates/account/polls.html @@ -10,8 +10,8 @@

Créer un sondage

- {{ form.title.label }}
- {{ form.title(size=32) }}
+ {{ form.title.label }} + {{ form.title(size=32) }} {% for error in form.title.errors %} {{ error }} {% endfor %} @@ -24,8 +24,8 @@ {% endfor %}
- {{ form.type.label }}
- {{ form.type }}
+ {{ form.type.label }} + {{ form.type }} {% for error in form.type.errors %} {{ error }} {% endfor %} diff --git a/app/templates/account/user.html b/app/templates/account/user.html index 8bb6ba1..4db27fc 100644 --- a/app/templates/account/user.html +++ b/app/templates/account/user.html @@ -13,7 +13,7 @@ {% if current_user.is_authenticated %} {% if current_user == member %} - {% elif current_user.priv('access-admin-panel') %} + {% elif current_user.priv('edit.accounts') %} {% endif %} {% endif %} @@ -68,7 +68,7 @@ Forum Création - {% for t in member.topics %} + {% for t in member.topics() %} {{ t.title }} {{ t.forum.name }} diff --git a/app/templates/admin/attachments.html b/app/templates/admin/attachments.html index f5f468e..dd08fc0 100644 --- a/app/templates/admin/attachments.html +++ b/app/templates/admin/attachments.html @@ -18,28 +18,9 @@ {{ a.id }} {{ a.name }} {{ a.comment.author.name }} - {{ a.size }} + {{ a.size | humanize(unit='o') }} {% endfor %} - -

Liste des groupes

- - - - - {% for group in groups %} - - {% endfor %} -
GroupeMembresPrivilèges
{{ group.name }} - {% for user in group.members %} - {{ user.name }} - {% endfor %} - - {% for priv in group.privs() %} - {{ priv }} - {{- ', ' if not loop.last }} - {% endfor %} -
{% endblock %} diff --git a/app/templates/admin/delete_account.html b/app/templates/admin/delete_account.html index 6df1cd7..4bd6fa1 100644 --- a/app/templates/admin/delete_account.html +++ b/app/templates/admin/delete_account.html @@ -7,16 +7,29 @@ {% block content %}

Confirmer la suppression du compte

-

Le compte '{{ user.name }}' que vous allez supprimer est lié à :

+

Le compte '{{ user.name }}' possède les posts (migrables) suivants :

    -
  • {{ user.groups | length }} groupe{{ user.groups|length|pluralize }}
  • -
  • {% set sp = user.special_privileges() | length %} - {{- sp }} privilège{{sp|pluralize}} spéci{{sp|pluralize("al","aux")}}
  • +
  • {{ stats.comments }} commentaire{{ stats.comments | pluralize }}
  • +
  • {{ stats.topics }} topic{{ stats.topics | pluralize }}
  • +
  • {{ stats.programs }} programme{{ stats.programs | pluralize }}
  • +
+

Les propriétés suivantes seront supprimées :

+
    +
  • {{ stats.groups }} groupe{{ stats.groups | pluralize }}
  • +
  • {{ stats.privs }} privilège{{ stats.privs | pluralize }} + spéci{{ stats.privs | pluralize("al","aux") }}
{{ del_form.hidden_tag() }}
+ {{ del_form.transfer.label }} + {{ del_form.transfer() }} +
{{ del_form.transfer.description }}
+ {% for error in del_form.transfer.errors %} + {{ error }} + {% endfor %} +
{{ del_form.delete.label }} {{ del_form.delete(checked=False) }}
{{ del_form.delete.description }}
diff --git a/app/templates/admin/edit_account.html b/app/templates/admin/edit_account.html index df17371..724ef25 100644 --- a/app/templates/admin/edit_account.html +++ b/app/templates/admin/edit_account.html @@ -40,6 +40,9 @@ {{ form.email_confirmed.label }} {{ form.email_confirmed(checked=user.email_confirmed) }}
{{ form.email_confirmed.description }}
+ {% for error in form.email_confirmed.errors %} + {{ error }} + {% endfor %}
{{ form.password.label }} @@ -107,15 +110,16 @@ {{ trophy_form.hidden_tag() }}

Trophées

-
- {% for id, input in trophy_form.__dict__.items() %} - {% if id[0] == "t" %} -
- {# TODO: add trophies icons #} - {{ input(checked=id in trophies_owned) }} - {{ input.label }} +
+ {% for id, t in trophy_form.trophies.items() %} +
+ {{ trophy_form[id](checked=id in trophies_owned) }} + +
+ {{ t.name }} + {{ t.description }} +
- {% endif %} {% endfor %}
{{ trophy_form.submit(class_="bg-ok") }}
@@ -127,14 +131,11 @@ {{ group_form.hidden_tag() }}

Groupes

- {% for id, input in group_form.__dict__.items() %} - {% if id[0] == "g" %} + {% for id, g in group_form.groups.items() %}
- {# TODO: add trophies icons #} - {{ input(checked=id in groups_owned) }} - {{ input.label }} + {{ group_form[id](checked=id in groups_owned) }} + {{ group_form[id].label(style=g.css) }}
- {% endif %} {% endfor %}
{{ group_form.submit(class_="bg-ok") }}
diff --git a/app/templates/admin/index.html b/app/templates/admin/index.html index 26694eb..6cc7a28 100644 --- a/app/templates/admin/index.html +++ b/app/templates/admin/index.html @@ -12,8 +12,10 @@
  • Liste des membres
  • Titres et trophées
  • Arbre des forums
  • +
  • Sondages
  • Pièces-jointes
  • Configuration du site
  • +
  • Vandalisme
  • {% endblock %} diff --git a/app/templates/admin/login_as.html b/app/templates/admin/login_as.html new file mode 100644 index 0000000..0576763 --- /dev/null +++ b/app/templates/admin/login_as.html @@ -0,0 +1,21 @@ +{% extends "base/base.html" %} + +{% block title %} +

    Vandaliser un compte

    +{% endblock %} + +{% block content %} +
    + + {{ form.hidden_tag() }} +

    + {{ form.username.label }}
    + {{ form.username(size=32) }}
    + {% for error in form.username.errors %} + [{{ error }}] + {% endfor %} +

    +

    {{ form.submit(class_="bg-ok") }}

    + +
    +{% endblock %} diff --git a/app/templates/base/base.html b/app/templates/base/base.html index 92d8b1b..3ff2a65 100644 --- a/app/templates/base/base.html +++ b/app/templates/base/base.html @@ -3,6 +3,7 @@ {% include "base/head.html" %} + {% include "base/navbar.html" %}
    @@ -10,8 +11,10 @@
    {% block title %}

    Planète Casio

    {% endblock %}
    {% include "base/header.html" %} + {% include "base/flash.html" %} +
    {% block content %} {% endblock %} diff --git a/app/templates/base/footer.html b/app/templates/base/footer.html index 15079a1..2df21e7 100644 --- a/app/templates/base/footer.html +++ b/app/templates/base/footer.html @@ -1,8 +1,8 @@

    Planète Casio est un site communautaire non affilié à CASIO. Toute reproduction de Planète Casio, même partielle, est interdite.

    Les programmes et autres publications présentes sur Planète Casio restent la propriété de leurs auteurs et peuvent être soumis à des licences ou des copyrights.

    - {% if current_user.is_authenticated and current_user.priv('footer-statistics') %} -

    Page générée en {{ g.request_time() }}

    + {% if current_user.is_authenticated and current_user.priv('misc.dev-infos') %} +

    Page générée en {{ "%.3f" % g.request_time() }} secondes.

    {% endif %} <<<<<<< HEAD <<<<<<< HEAD diff --git a/app/templates/base/head.html b/app/templates/base/head.html index 347760d..3a91943 100644 --- a/app/templates/base/head.html +++ b/app/templates/base/head.html @@ -9,5 +9,4 @@ {% for s in styles %} {% endfor %} - - + diff --git a/app/templates/base/header.html b/app/templates/base/header.html index 46d25a0..932bae6 100644 --- a/app/templates/base/header.html +++ b/app/templates/base/header.html @@ -11,3 +11,10 @@ + +{% if current_user.is_authenticated and current_user.priv('misc.dev-infos') %} + {% set reqtime = g.request_time() %} + {% if reqtime > V5Config.SLOW_REQUEST_THRESHOLD %} +
    Génération
    {{ "%.3f" % reqtime }} s
    + {% endif %} +{% endif %} diff --git a/app/templates/base/navbar/account.html b/app/templates/base/navbar/account.html index de139f7..f34ac61 100644 --- a/app/templates/base/navbar/account.html +++ b/app/templates/base/navbar/account.html @@ -26,7 +26,7 @@ Topics favoris - {% if current_user.priv('access-admin-panel') %} + {% if current_user.priv('misc.admin-panel') %} @@ -41,11 +41,19 @@ Paramètres + {% if is_vandal() %} + + + + Fuir ce compte + + {% else %} Déconnexion + {% endif %}
    {% else %}
    diff --git a/app/templates/base/navbar/forum.html b/app/templates/base/navbar/forum.html index 785dffb..29688c6 100644 --- a/app/templates/base/navbar/forum.html +++ b/app/templates/base/navbar/forum.html @@ -10,7 +10,10 @@
    {% for f in main_forum.sub_forums %} - {{ f.name }} + {% if f.is_default_accessible() or + (current_user.is_authenticated and current_user.can_access_forum(f)) %} + {{ f.name }} + {% endif %} {% endfor %}
    diff --git a/app/templates/forum/edit_comment.html b/app/templates/forum/edit_comment.html index de4d7af..d57ec1b 100644 --- a/app/templates/forum/edit_comment.html +++ b/app/templates/forum/edit_comment.html @@ -11,10 +11,10 @@

    Édition de commentaire

    Commentaire actuel

    - +
    - +
    {{ widget_user.profile(comment.author) }}
    {{ comment.text }}
    {{ comment.text | md }}
    @@ -24,15 +24,35 @@ {{ form.hidden_tag() }} {% if form.pseudo %} - {{ form.pseudo.label }} - {{ form.pseudo }} - {% for error in form.pseudo.errors %} - {{ error }} - {% endfor %} +
    + {{ form.pseudo.label }} + {{ form.pseudo }} + {% for error in form.pseudo.errors %} + {{ error }} + {% endfor %} +
    {% endif %} {{ widget_editor.text_editor(form.message, label=False, autofocus=True) }} + {% if form.attachment_list %} +
    Supprimer des pièces jointes
    + {% for id, a in form.attachment_list.items() %} + {{ form[id]() }} {{ a.name }} ({{ a.size }} octets)
    + {% endfor %} +
    + {% endif %} + +
    + {{ form.attachments.label }} +
    + {{ form.attachments }} + {% for error in form.attachments.errors %} + {{ error }} + {% endfor %} +
    +
    +
    {{ form.submit(class_='bg-ok') }}
    diff --git a/app/templates/forum/edit_topic.html b/app/templates/forum/edit_topic.html index 91701cd..6c1b1d0 100644 --- a/app/templates/forum/edit_topic.html +++ b/app/templates/forum/edit_topic.html @@ -1,19 +1,82 @@ {% extends "base/base.html" %} +{% import "widgets/attachments.html" as widget_attachments %} +{% import "widgets/thread.html" as widget_thread with context %} {% import "widgets/editor.html" as widget_editor %} -{% import "widgets/member.html" as widget_member %} +{% import "widgets/user.html" as widget_user %} {% block title %} -Forum de Planète Casio » {{ t.forum.name }} »

    {{ t.title }}

    +Forum de Planète Casio » {{ t.forum.name }} »

    Édition de sujet

    {% endblock %} {% block content %}
    -

    Édition du topic {{ t.title }}

    +

    Édition du sujet: {{ t.title }}

    -
    -

    Commenter le sujet

    +

    Sujet actuel

    + +

    Pour modifier substantiellement ou réécrire le commentaire d'en-tête, il vaut mieux poster un nouveau commentaire et le désigner comme en-tête ; ça permet à la conversation de rester dans son contexte.

    + + {% call widget_thread.thread_leader(t.thread.top_comment) %} +
    +
    Posté le {{ t.date_created | dyndate }}
    + {{ widget_thread.post_actions(t) }} +
    + {{ t.thread.top_comment.text | md }} + {{ widget_attachments.attachments(t.thread.top_comment) }} + {% endcall %} + +
    +

    Nouveau sujet

    - Un formulaire + {{ form.hidden_tag() }} + +
    + {{ form.forum.label }} + {{ form.forum }} + {% for error in form.forum.errors %} + {{ error }} + {% endfor %} +
    + +
    + {{ form.title.label }} + {{ form.title }} + {% for error in form.title.errors %} + {{ error }} + {% endfor %} +
    + + {% if form.pseudo %} +
    + {{ form.pseudo.label }} + {{ form.pseudo }} + {% for error in form.pseudo.errors %} + {{ error }} + {% endfor %} +
    + {% endif %} + + {{ widget_editor.text_editor(form.message, label=False, autofocus=True) }} + + {% if form.attachment_list %} +
    Supprimer des pièces jointes
    + {% for id, a in form.attachment_list.items() %} + {{ form[id]() }} {{ a.name }} ({{ a.size }} octets)
    + {% endfor %} +
    + {% endif %} + +
    + {{ form.attachments.label }} +
    + {{ form.attachments }} + {% for error in form.attachments.errors %} + {{ error }} + {% endfor %} +
    +
    + +
    {{ form.submit(class_='bg-ok') }}
    diff --git a/app/templates/forum/forum.html b/app/templates/forum/forum.html index ae59fda..0c6e440 100644 --- a/app/templates/forum/forum.html +++ b/app/templates/forum/forum.html @@ -23,7 +23,7 @@ {{ t.title }} {{ t.author.name }} {{ t.date_created | date }} - {{ t.thread.comments.count() }} + {{ comment_counts[t.thread.id] }} {{ t.views }} {% endfor %} @@ -48,10 +48,8 @@ {% endif %} - {% if (current_user.is_authenticated and current_user.priv('write-anywhere')) - or ("/actus" in f.url and current_user.is_authenticated and current_user.priv('write-news')) - or ("/actus" not in f.url and not f.sub_forums) - and (current_user.is_authenticated or V5Config.ENABLE_GUEST_POST) %} + {% if (V5Config.ENABLE_GUEST_POST and f.is_default_postable()) + or (current_user.is_authenticated and current_user.can_post_in_forum(f)) %}

    Créer un nouveau sujet

    @@ -64,6 +62,7 @@ {{ error }} {% endfor %}
    + {{ form.ab }} {% endif %}
    @@ -76,10 +75,15 @@ {{ widget_editor.text_editor(form.message) }} - {{ form.attachments }} - {% for error in form.attachments.errors %} - {{ error }} - {% endfor %} +
    + {{ form.attachments.label }} +
    + {{ form.attachments }} + {% for error in form.attachments.errors %} + {{ error }} + {% endfor %} +
    +
    {{ form.submit(class_='bg-ok') }}
    diff --git a/app/templates/forum/index.html b/app/templates/forum/index.html index 769a660..55f7130 100644 --- a/app/templates/forum/index.html +++ b/app/templates/forum/index.html @@ -9,9 +9,9 @@

    Bienvenue sur le forum de Planète Casio ! Vous pouvez créer des nouveaux sujets ou poster des réponses avec un compte - {% if not current_user.is_authenticated %} + {%- if not current_user.is_authenticated %} ou en postant en tant qu'invité - {% endif %} + {%- endif -%} .

    @@ -20,22 +20,28 @@ {% else %} {% for l1 in main_forum.sub_forums %} - - + {% if l1.is_default_accessible() or + (current_user.is_authenticated and current_user.can_access_forum(l1)) %} +
    {{ l1.name }}Nombre de sujets
    + - {% if l1.sub_forums == [] %} - - - - {% endif %} + {% if l1.sub_forums == [] %} + + + + {% endif %} - {% for l2 in l1.sub_forums %} - - - - {% endfor %} + {% for l2 in l1.sub_forums %} + {% if l2.is_default_accessible() or + (current_user.is_authenticated and current_user.can_access_forum(l2)) %} + + + + {% endif %} + {% endfor %} -
    {{ l1.name }}Nombre de sujets
    {{ l1.name }}{{ l1.topics.count() }}
    {{ l1.descr }}
    {{ l1.name }}{{ l1.topics.count() }}
    {{ l1.descr }}
    {{ l2.name }}{{ l2.topics.count() }}
    {{ l2.descr }}
    {{ l2.name }}{{ l2.topics.count() }}
    {{ l2.descr }}
    + + {% endif %} {% endfor %} {% endif %} diff --git a/app/templates/forum/topic.html b/app/templates/forum/topic.html index a71391c..f4bcbd7 100644 --- a/app/templates/forum/topic.html +++ b/app/templates/forum/topic.html @@ -1,8 +1,9 @@ {% extends "base/base.html" %} {% import "widgets/editor.html" as widget_editor %} -{% import "widgets/thread.html" as widget_thread %} +{% import "widgets/thread.html" as widget_thread with context %} {% import "widgets/user.html" as widget_user %} {% import "widgets/pagination.html" as widget_pagination with context %} +{% import "widgets/attachments.html" as widget_attachments %} {% block title %} Forum de Planète Casio » {{ t.forum.name }} »

    {{ t.title }}

    @@ -11,7 +12,17 @@ {% block content %}

    {{ t.title }}

    - {{ widget_thread.thread([t.thread.top_comment], None) }} + + {% if t.thread.top_comment %} + {% call widget_thread.thread_leader(t.thread.top_comment) %} +
    +
    Posté le {{ t.date_created | dyndate }}
    + {{ widget_thread.post_actions(t) }} +
    + {{ t.thread.top_comment.text | md }} + {{ widget_attachments.attachments(t.thread.top_comment) }} + {% endcall %} + {% endif %} {{ widget_pagination.paginate(comments, 'forum_topic', t, {'f': t.forum}) }} @@ -25,30 +36,39 @@
    {% endif %} - {% if current_user.is_authenticated or V5Config.ENABLE_GUEST_POST %} + {% if V5Config.ENABLE_GUEST_POST + or (current_user.is_authenticated and current_user.can_post_in_forum(t.forum)) %}

    Commenter le sujet

    {{ form.hidden_tag() }} {% if form.pseudo %} +
    {{ form.pseudo.label }} {{ form.pseudo }} {% for error in form.pseudo.errors %} {{ error }} {% endfor %} + {{ form.ab }} +
    {% endif %} {{ widget_editor.text_editor(form.message, label=False) }} - {{ form.attachments }} - {% for error in form.attachments.errors %} - {{ error }} - {% endfor %} +
    + {{ form.attachments.label }} +
    + {{ form.attachments }} + {% for error in form.attachments.errors %} + {{ error }} + {% endfor %} +
    +
    {{ form.submit(class_='bg-ok') }}
    - {% endif %}
    + {% endif %} {% endblock %} diff --git a/app/templates/post/move_post.html b/app/templates/post/move_post.html new file mode 100644 index 0000000..9b4a212 --- /dev/null +++ b/app/templates/post/move_post.html @@ -0,0 +1,52 @@ +{% extends "base/base.html" %} +{% import "widgets/editor.html" as widget_editor %} +{% import "widgets/user.html" as widget_user %} + +{% block title %} +Forum de Planète Casio » Édition de commentaire +{% endblock %} + +{% block content %} +
    +

    Déplacer un commentaire

    + + + + + + +
    {{ widget_user.profile(comment.author) }}
    {{ comment.text | md }}
    + +
    +
    +

    Chercher un thread

    + {{ search_form.hidden_tag() }} + +
    + {{ search_form.name.label }} + {{ search_form.name() }} + {% for error in search_form.name.errors %} + {{ error }} + {% endfor %} +
    + +
    {{ search_form.search(class_='bg-ok') }}
    +
    + +
    +

    Nouveau thread

    + {{ move_form.hidden_tag() }} + +
    + {{ move_form.thread.label }} + {{ move_form.thread }} + {% for error in move_form.thread.errors %} + {{ error }} + {% endfor %} +
    + +
    {{ move_form.submit(class_='bg-ok') }}
    +
    +
    +
    +{% endblock %} diff --git a/app/templates/programs/program.html b/app/templates/programs/program.html new file mode 100644 index 0000000..a6ca9fe --- /dev/null +++ b/app/templates/programs/program.html @@ -0,0 +1,81 @@ +{% extends "base/base.html" %} +{% import "widgets/editor.html" as widget_editor %} +{% import "widgets/thread.html" as widget_thread with context %} +{% import "widgets/user.html" as widget_user %} +{% import "widgets/pagination.html" as widget_pagination with context %} +{% import "widgets/attachments.html" as widget_attachments %} + + +{% block title %} +

    Programme {{ program.name }}

    +{% endblock %} + +{% block content %} +
    +
    +
    + {{ widget_user.profile(program.author) }} +
    +
    +
    + {{ program.title }} +
    +
    +

    Lorem ipsum dolor sit amet, consectetur adipiscing elit. Etiam vitae + feugiat ante. Pellentesque luctus lorem tincidunt vestibulum condimentum. + Nullam sed tempus sem. Phasellus quis diam vitae sapien luctus consequat + ac eget lacus. Sed rutrum condimentum sagittis. Nullam erat nibh, euismod + ac metus at, consequat tincidunt ipsum. Fusce sagittis iaculis orci sedporta. + Etiam bibendum purus et ipsum pellentesque, quis sodales libero congue. Nunc + lectus quam, cursus non dictum nec, volutpat eget felis. Praesent sollicitudin + massa erat, nec venenatis lorem lacinia et. Etiam ullamcorper neque quis + nisi sodales vulputate. Integer scelerisque luctus arcu, ut elementum justo + auctor a. Praesent sit amet libero risus.

    + + + +

    Lorem ipsum dolor sit amet, consectetur adipiscing elit. Etiam vitae + feugiat ante. Pellentesque luctus lorem tincidunt vestibulum condimentum. + Nullam sed tempus sem. Phasellus quis diam vitae sapien luctus consequat + ac eget lacus. Sed rutrum condimentum sagittis. Nullam erat nibh, euismod + ac metus at, consequat tincidunt ipsum. Fusce sagittis iaculis orci sedporta. + Etiam bibendum purus et ipsum pellentesque, quis sodales libero congue. Nunc + lectus quam, cursus non dictum nec, volutpat eget felis. Praesent sollicitudin + massa erat, nec venenatis lorem lacinia et. Etiam ullamcorper neque quis + nisi sodales vulputate. Integer scelerisque luctus arcu, ut elementum justo + auctor a. Praesent sit amet libero risus.

    + + + +

    Lorem ipsum dolor sit amet, consectetur adipiscing elit. Etiam vitae + feugiat ante. Pellentesque luctus lorem tincidunt vestibulum condimentum. + Nullam sed tempus sem. Phasellus quis diam vitae sapien luctus consequat + ac eget lacus. Sed rutrum condimentum sagittis. Nullam erat nibh, euismod + ac metus at, consequat tincidunt ipsum. Fusce sagittis iaculis orci sedporta. + Etiam bibendum purus et ipsum pellentesque, quis sodales libero congue. Nunc + lectus quam, cursus non dictum nec, volutpat eget felis. Praesent sollicitudin + massa erat, nec venenatis lorem lacinia et. Etiam ullamcorper neque quis + nisi sodales vulputate. Integer scelerisque luctus arcu, ut elementum justo + auctor a. Praesent sit amet libero risus.

    +
    + +{% endblock %} diff --git a/app/templates/widgets/attachments.html b/app/templates/widgets/attachments.html index 7c11844..529698d 100644 --- a/app/templates/widgets/attachments.html +++ b/app/templates/widgets/attachments.html @@ -8,7 +8,7 @@ {% for a in comment.attachments %} {{ a.name }} - {{ a.size }} + {{ a.size | humanize(unit='o') }} {% endfor %} diff --git a/app/templates/widgets/download_button.html b/app/templates/widgets/download_button.html new file mode 100644 index 0000000..4f004dc --- /dev/null +++ b/app/templates/widgets/download_button.html @@ -0,0 +1,8 @@ +{% macro download_button(file) %} + + {{ file.name }} + {{ file.size | humanize(unit='o') }} + +{% endmacro %} + +{{ download_button(file) if file }} diff --git a/app/templates/widgets/thread.html b/app/templates/widgets/thread.html index d929333..c315060 100644 --- a/app/templates/widgets/thread.html +++ b/app/templates/widgets/thread.html @@ -1,25 +1,73 @@ {% import "widgets/user.html" as widget_user %} {% import "widgets/attachments.html" as widget_attachments %} -{% macro thread(comments, top_comment) %} - -{% for c in comments %} - - {% if c != top_comment %} - -
    {{ widget_user.profile(c.author) }} -
    -
    Posté le {{ c.date_created|date }}
    - {% if c.date_created != c.date_modified %} -
    Modifié le {{ c.date_modified|date }}
    +{# Post actions: this widget expands to a context menu with actions controlling + a post, supporting different types of posts. #} +{% macro post_actions(post) %} + {# TODO (Guest edit): determine permissions in post_actions widget #} + + {% set auth = current_user.is_authenticated %} + {% set can_edit = auth and current_user.can_edit_post(post) %} + {% set can_delete = auth and current_user.can_delete_post(post) %} + {% set can_punish = auth and current_user.can_punish_post(post) %} + {% set can_topcomm = auth and current_user.can_set_topcomment(post) %} + {% set can_move = auth and current_user.can_edit_post(post) and post.type == "comment" %} + + {% if post.type == "topic" %} + {% set suffix = " le sujet" %} + {% elif post.type == "program" %} + {% set suffix = " le programme" %} + {% endif %} + + {% if can_edit or can_delete or can_punish or can_topcomm %} +
    + +
    + {% if can_edit %} + Modifier{{ suffix }} {% endif %} - - - + + {% if can_move %} + Déplacer + {% endif %} + + {% if can_punish %} + Supprimer{{ suffix }} (normal) + Supprimer{{ suffix }} (pénalité) + {% elif can_delete %} + Supprimer{{ suffix }} + {% endif %} + + {% if can_topcomm %} + Utiliser comme en-tête + {% endif %} +
    +
    + {% endif %} +{% endmacro %} + +{# Thread widget: this widget expands to a table that shows a list of comments + from a thread, along with message controls. + + comments: List of comments to render + top_comment: Thread's top comment (will be elided if encountered) #} + +{% macro thread(comments, top_comment, owner=None) %} + +{% for c in comments %} + {% if c != top_comment %} + + + - {% elif loop.index0 != 0 %} -
    Ce message est le top comment
    - {% endif %} + {% elif loop.index0 != 0 %} + + + + + {% endif %} {% endfor %}
    {{ widget_user.profile(c.author) }} +
    + + {% if c.date_created != c.date_modified %} + + {% endif %} + {{ post_actions(c) }}
    {{ c.text|md }} - {{ widget_attachments.attachments(c) }} {% if c.author.signature %} @@ -27,10 +75,30 @@ {{ c.author.signature|md }} {% endif %}
    Le commentaire à cet endroit est actuellement utilisé comme en-tête.
    {% endmacro %} + +{# Thread leader widget: this widget expands to a single-message thread which + can show more text when called. This is intended for programs and similar + objects which display metadata before description and comments. + + leader: Posts's top comment (actual rendering is delegated to caller) #} + +{% macro thread_leader(leader) %} + + {# Empty line to get normal background (instead of alternate one) #} + + + + + +
    {{ widget_user.profile(leader.author) }}{{ caller() }}
    +{% endmacro %} diff --git a/app/utils/antibot_field.py b/app/utils/antibot_field.py new file mode 100644 index 0000000..d7a8c44 --- /dev/null +++ b/app/utils/antibot_field.py @@ -0,0 +1,20 @@ +from wtforms.fields.simple import EmailField +from wtforms.validators import Optional, ValidationError + +def antibot_validator(form, field): + if field.data: + raise ValidationError('Bas les pattes!') + return True + +class AntibotField(EmailField): + + def __init__(self, *args, **kwargs): + super().__init__( + "L'adresse email", + *args, + validators=[Optional(), antibot_validator], + **kwargs) + + def __call__(self, *args, **kwargs): + return super().__call__(*args, **kwargs, + class_="abfield", autocomplete="no", tabindex="-1") diff --git a/app/utils/bleach_allowlist.py b/app/utils/bleach_allowlist.py new file mode 100644 index 0000000..8a2883e --- /dev/null +++ b/app/utils/bleach_allowlist.py @@ -0,0 +1,28 @@ +# Tags suitable for rendering markdown +markdown_tags = [ + "h1", "h2", "h3", "h4", "h5", "h6", + "b", "i", "strong", "em", "tt", + "p", "br", + "span", "div", "blockquote", "code", "pre", "hr", + "ul", "ol", "li", "dd", "dt", + "img", + "a", + "sub", "sup", + "table", "thead", "tbody", "tr", "th", "td", + "form", "fieldset", "input", "textarea", + "label", "progress", + "video", "source", "iframe", +] + +markdown_attrs = { + "*": ["id", "class"], + "img": ["src", "alt", "title", "width", "height"], + "a": ["href", "alt", "title", "rel"], + "form": ["action", "method", "enctype"], + "input": ["id", "name", "type", "value"], + "label": ["for"], + "progress": ["value", "min", "max"], + "video": ["controls", "width", "height"], + "source": ["src"], + "iframe": ["src", "width", "height", "frameborder", "allowfullscreen"], +} diff --git a/app/utils/converters.py b/app/utils/converters.py index 3caae7b..e887f9b 100644 --- a/app/utils/converters.py +++ b/app/utils/converters.py @@ -63,7 +63,7 @@ class PageConverter(BaseConverter): t = self.object.query.filter_by(id=tid).first() if t is None: - raise Exception(f"BaseConverter: no object with id {url}") + raise ValidationError(f"BaseConverter: no object with id {url}") return (t, page) diff --git a/app/utils/filters/__init__.py b/app/utils/filters/__init__.py index ac08bc7..224d174 100644 --- a/app/utils/filters/__init__.py +++ b/app/utils/filters/__init__.py @@ -1,3 +1,3 @@ # Register filters here -from app.utils.filters import date, is_title, markdown, pluralize +from app.utils.filters import date, humanize, is_title, markdown, pluralize diff --git a/app/utils/filters/date.py b/app/utils/filters/date.py index 22b140c..a8155cc 100644 --- a/app/utils/filters/date.py +++ b/app/utils/filters/date.py @@ -13,8 +13,8 @@ def filter_date(date, format="%Y-%m-%d à %H:%M"): "Août", "Septembre", "Octobre", "Novembre","Décembre"] \ [date.month - 1] - # Omit current year in the dynamic format - if date.year == datetime.now().year: + # Omit current year in the last 8 months + if (datetime.now() - date).days <= 8 * 30: format = f"{d} {m} à %H:%M" else: format = f"{d} {m} %Y à %H:%M" diff --git a/app/utils/filters/humanize.py b/app/utils/filters/humanize.py new file mode 100644 index 0000000..22901bd --- /dev/null +++ b/app/utils/filters/humanize.py @@ -0,0 +1,26 @@ +from app import app + + +@app.template_filter('humanize') +def humanize(n:float, sd:int=4, unit:str=''): + """ + Print the number human-readable. + n: number + sd: significant digits + unit: unit + Ex: humanize(12345, 2, "o") → 12.34 ko + """ + + suffixes = ['k', 'M', 'G', 'T', 'P'] + suffix = '' + + for s in suffixes: + if abs(n) > 10**3: + n /= 10**3 + suffix = s + else: + break + + formatter = f"{{:.{sd}n}}{{}}{{}}{{}}" + spacer = ' ' if suffix + unit != '' else '' + return formatter.format(float(n), spacer, suffix, unit) diff --git a/app/utils/filters/markdown.py b/app/utils/filters/markdown.py index f7455a9..71912b4 100644 --- a/app/utils/filters/markdown.py +++ b/app/utils/filters/markdown.py @@ -4,8 +4,15 @@ from markdown import markdown from markdown.extensions.codehilite import CodeHiliteExtension from markdown.extensions.footnotes import FootnoteExtension from markdown.extensions.toc import TocExtension +from bleach import clean +from app.utils.bleach_allowlist import markdown_tags, markdown_attrs from app.utils.markdown_extensions.pclinks import PCLinkExtension +from app.utils.markdown_extensions.hardbreaks import HardBreakExtension +from app.utils.markdown_extensions.escape_html import EscapeHtmlExtension +from app.utils.markdown_extensions.linkify import LinkifyExtension +from app.utils.markdown_extensions.media import MediaExtension +from app.utils.markdown_extensions.gallery import GalleryExtension @app.template_filter('md') @@ -18,23 +25,21 @@ def md(text): extensions = [ # 'admonition', 'fenced_code', - 'nl2br', + # 'nl2br', 'sane_lists', 'tables', CodeHiliteExtension(linenums=True, use_pygments=True), + EscapeHtmlExtension(), FootnoteExtension(UNIQUE_IDS=True), + HardBreakExtension(), + LinkifyExtension(), TocExtension(baselevel=2), PCLinkExtension(), + MediaExtension(), + GalleryExtension(), ] - def escape(text): - text = text.replace("&", "&") - text = text.replace("<", "<") - text = text.replace(">", ">") - return text - - # Escape html chars because markdown does not - safe = escape(text) - out = markdown(safe, options=options, extensions=extensions) + html = markdown(text, options=options, extensions=extensions) + out = clean(html, markdown_tags, markdown_attrs) return Markup(out) diff --git a/app/utils/login_as.py b/app/utils/login_as.py new file mode 100644 index 0000000..10336ec --- /dev/null +++ b/app/utils/login_as.py @@ -0,0 +1,20 @@ +from flask import request +from itsdangerous import Serializer +from itsdangerous.exc import BadSignature +from app import app + + +def is_vandal(): + """ Return True if the current user looks like a vandal """ + s = Serializer(app.config["SECRET_KEY"]) + + vandal_token = request.cookies.get('vandale') + if vandal_token is None: + return False + + try: + s.loads(vandal_token) + except BadSignature: + return False + + return True diff --git a/app/utils/markdown_extensions/escape_html.py b/app/utils/markdown_extensions/escape_html.py new file mode 100644 index 0000000..099d82e --- /dev/null +++ b/app/utils/markdown_extensions/escape_html.py @@ -0,0 +1,7 @@ +from markdown.extensions import Extension + + +class EscapeHtmlExtension(Extension): + def extendMarkdown(self, md): + md.preprocessors.deregister('html_block') + md.inlinePatterns.deregister('html') diff --git a/app/utils/markdown_extensions/gallery.py b/app/utils/markdown_extensions/gallery.py new file mode 100644 index 0000000..ec3ac04 --- /dev/null +++ b/app/utils/markdown_extensions/gallery.py @@ -0,0 +1,40 @@ +from markdown.extensions import Extension +from markdown.treeprocessors import Treeprocessor +import xml.etree.ElementTree as etree + +class GalleryTreeprocessor(Treeprocessor): + def run(self, doc): + for parent in doc.findall(".//ul/.."): + for idx, ul in enumerate(parent): + if ul.tag != "ul" or len(ul) == 0: + continue + has_gallery = False + + # Option 1: In raw text in the
  • + if ul[-1].text and ul[-1].text.endswith("{gallery}"): + has_gallery = True + ul[-1].text = ul[-1].text[:-9] + # Option 2: After the last child (in its tail) \ + if len(ul[-1]) and ul[-1][-1].tail and \ + ul[-1][-1].tail.endswith("{gallery}"): + has_gallery = True + ul[-1][-1].tail = ul[-1][-1].tail[:-9] + + if has_gallery: + el = etree.Element("div") + el.set('class', 'gallery') + parent.remove(ul) + for li in ul: + # Filter out items that are not single medias + if len(li) == 1 and li[0].tag in ["img", "video"]: + el.append(li[0]) + + parent.insert(idx, el) + +class GalleryExtension(Extension): + def __init__(self, **kwargs): + super().__init__(**kwargs) + + def extendMarkdown(self, md): + md.treeprocessors.register(GalleryTreeprocessor(md), 'gallery', 8) + md.registerExtension(self) diff --git a/app/utils/markdown_extensions/hardbreaks.py b/app/utils/markdown_extensions/hardbreaks.py new file mode 100644 index 0000000..ecacf4c --- /dev/null +++ b/app/utils/markdown_extensions/hardbreaks.py @@ -0,0 +1,9 @@ +from markdown.extensions import Extension +from markdown.inlinepatterns import SubstituteTagPattern + + +class HardBreakExtension(Extension): + def extendMarkdown(self, md): + BREAK_RE = r' *\\\\\n' + breakPattern = SubstituteTagPattern(BREAK_RE, 'br') + md.inlinePatterns.register(breakPattern, 'hardbreak', 185) diff --git a/app/utils/markdown_extensions/linkify.py b/app/utils/markdown_extensions/linkify.py new file mode 100644 index 0000000..86e9e4b --- /dev/null +++ b/app/utils/markdown_extensions/linkify.py @@ -0,0 +1,56 @@ +# The MIT License (MIT) +# +# Copyright (c) 2013 Raitis Stengrevics +# https://github.com/daGrevis/mdx_linkify +# +# 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. + +from bleach.linkifier import Linker + +from markdown.postprocessors import Postprocessor +from markdown.extensions import Extension + + +class LinkifyExtension(Extension): + def __init__(self, **kwargs): + self.config = { + 'linker_options': [{}, 'Options for bleach.linkifier.Linker'], + } + super(LinkifyExtension, self).__init__(**kwargs) + + def extendMarkdown(self, md): + md.postprocessors.register( + LinkifyPostprocessor( + md, + self.getConfig('linker_options'), + ), + "linkify", + 50, + ) + + +class LinkifyPostprocessor(Postprocessor): + def __init__(self, md, linker_options): + super(LinkifyPostprocessor, self).__init__(md) + linker_options.setdefault("skip_tags", ["code"]) + self._linker_options = linker_options + + def run(self, text): + linker = Linker(**self._linker_options) + return linker.linkify(text) diff --git a/app/utils/markdown_extensions/media.py b/app/utils/markdown_extensions/media.py new file mode 100644 index 0000000..1e21d4e --- /dev/null +++ b/app/utils/markdown_extensions/media.py @@ -0,0 +1,220 @@ +from markdown.extensions import Extension +from markdown.inlinepatterns import LinkInlineProcessor +import xml.etree.ElementTree as etree +import urllib +import re + +class MediaExtension(Extension): + def __init__(self, **kwargs): + super().__init__(**kwargs) + + def extendMarkdown(self, md): + self.md = md + + # Override image detection + MEDIA_RE = r'\!\[' + media_processor = MediaInlineProcessor(MEDIA_RE) + media_processor.md = md + md.inlinePatterns.register(media_processor, 'media_link', 155) + +class AttrDict: + def __init__(self, attrs): + self.attrs = attrs + + def has(self, name): + return name in self.attrs + + def getString(self, name): + for attr in self.attrs: + if attr.startswith(name + "="): + return attr[len(name)+1:] + + def getInt(self, name): + try: + s = self.getString(name) + return int(s) if s is not None else None + except ValueError: + return None + + def getSize(self, name): + s = self.getString(name) + if s is None: + return None, None + dims = s.split("x", 1) + try: + if len(dims) == 1: + return int(dims[0]), None + else: + w = int(dims[0]) if dims[0] else None + h = int(dims[1]) if dims[1] else None + return w, h + except ValueError: + return None, None + + def getAlignmentClass(self): + if self.has("left"): + return "align-left" + if self.has("center"): + return "align-center" + elif self.has("right"): + return "align-right" + elif self.has("float-left"): + return "float-left" + elif self.has("float-right"): + return "float-right" + return "" + +class AttributeLinkInlineProcessor(LinkInlineProcessor): + """ + A LinkInlineProcessor which additionally supports attributes after links, + with the bracket syntax `{ , , ... }` where each item is a + key/value pair with either single or double quotes: `key='value'` or + `key="value"`. + """ + + def getAttributes(self, data, index): + current_quote = "" + has_closing_brace = False + attrs = [] + current_attr_text = "" + + if index >= len(data) or data[index] != '{': + return AttrDict([]), index, True + index += 1 + + for pos in range(index, len(data)): + c = data[pos] + index += 1 + + # Close quote + if current_quote != "" and c == current_quote: + current_quote = "" + continue + # Open new quote + if current_quote == "" and c in ["'", '"']: + current_quote = c + continue + # Close brace + if current_quote == "" and c == "}": + has_closing_brace = True + break + + if current_quote == "" and c == " ": + if current_attr_text: + attrs.append(current_attr_text) + current_attr_text = "" + else: + current_attr_text += c + + if current_attr_text: + attrs.append(current_attr_text) + + return AttrDict(attrs), index, has_closing_brace + +class MediaInlineProcessor(AttributeLinkInlineProcessor): + """ Return a media element from the given match. """ + + def isVideo(self, url): + if url.endswith(".mp4") or url.endswith(".webm"): + return True + url = urllib.parse.urlparse(url) + # TODO: Better detect YouTube URLs + return url.hostname in ["youtu.be", "www.youtube.com"] + + def isAudio(self, url): + return url.endswith(".mp3") or url.endswith(".ogg") + + def handleMatch(self, m, data): + text, index, handled = self.getText(data, m.end(0)) + if not handled: + return None, None, None + + src, title, index, handled = self.getLink(data, index) + if not handled: + return None, None, None + + attrs, index, handled = self.getAttributes(data, index) + if not handled: + return None, None, None + + kind = "image" + if attrs.has("image"): + kind = "image" + elif attrs.has("audio") or self.isAudio(src): + kind = "audio" + elif attrs.has("video") or self.isVideo(src): + kind = "video" + + if kind == "image": + w, h = attrs.getSize("size") + class_ = "" + # TODO: Media converter: Find a way to clear atfer a float + if attrs.has("pixelated"): + class_ += " pixelated" + class_ += " " + attrs.getAlignmentClass() + + el = etree.Element("img") + el.set("src", src) + + if title is not None: + el.set("title", title) + + if class_ != "": + el.set("class", class_) + if w is not None: + el.set("width", str(w)) + if h is not None: + el.set("height", str(h)) + + el.set('alt', self.unescape(text)) + return el, m.start(0), index + + elif kind == "audio": + # TODO: Media converter: support audio files + pass + + elif kind == "video": + w, h = attrs.getSize("size") + class_ = attrs.getAlignmentClass() + url = urllib.parse.urlparse(src) + args = urllib.parse.parse_qs(url.query) + youtube_source = None + + if url.hostname == "youtu.be" and \ + re.fullmatch(r'\/[a-zA-Z0-9_-]+', url.path): + youtube_source = url.path[1:] + elif url.hostname == "www.youtube.com" and "v" in args and \ + re.fullmatch(r'[a-zA-Z0-9_-]+', args["v"][0]): + youtube_source = args["v"][0] + + if youtube_source: + if w is None and h is None: + w, h = (470, 300) if attrs.has("tiny") else (560, 340) + + el = etree.Element("iframe") + el.set("src",f"https://www.youtube.com/embed/{youtube_source}") + el.set("frameborder", "0") + el.set("allowfullscreen", "") + # + pass + else: + el = etree.Element("video") + el.set("controls", "") + source = etree.Element("source") + source.set("src", src) + el.append(source) + # + + el.set("class", class_) + if w is not None: + el.set("width", str(min(w, 560))) + if h is not None: + el.set("height", str(min(h, 340))) + + return el, m.start(0), index + + return None, None, None diff --git a/app/utils/markdown_extensions/pclinks.py b/app/utils/markdown_extensions/pclinks.py index 192e628..f674f60 100644 --- a/app/utils/markdown_extensions/pclinks.py +++ b/app/utils/markdown_extensions/pclinks.py @@ -16,9 +16,12 @@ from markdown.inlinepatterns import InlineProcessor import xml.etree.ElementTree as etree from flask import url_for, render_template from app.utils.unicode_names import normalize +from app.utils.filters.humanize import humanize from app.models.poll import Poll from app.models.topic import Topic from app.models.user import Member +from app.models.attachment import Attachment + class PCLinkExtension(Extension): def __init__(self, **kwargs): @@ -33,10 +36,10 @@ class PCLinkExtension(Extension): self.md = md # append to end of inline patterns - PCLINK_RE = r'\[\[([a-z]+): ?(\w+)\]\]' + PCLINK_RE = r'<([a-z]+): ?(\w+)>' pclinkPattern = PCLinksInlineProcessor(PCLINK_RE, self.getConfigs()) pclinkPattern.md = md - md.inlinePatterns.register(pclinkPattern, 'pclink', 75) + md.inlinePatterns.register(pclinkPattern, 'pclink', 135) class PCLinksInlineProcessor(InlineProcessor): @@ -47,6 +50,7 @@ class PCLinksInlineProcessor(InlineProcessor): 'membre': handleUser, 'user': handleUser, 'u': handleUser, 'sondage': handlePoll, 'poll': handlePoll, 'topic': handleTopic, 't': handleTopic, + 'fichier': handleFile, 'file': handleFile, 'f': handleFile, } def handleMatch(self, m, data): @@ -69,12 +73,10 @@ class PCLinksInlineProcessor(InlineProcessor): # - either an xml.etree.ElementTree def handlePoll(content_id, context): - if not context.startswith("[[") or not context.endswith("]]"): - return "[Sondage invalide]" try: id = int(content_id) except ValueError: - return "[ID du sondage invalide]" + return "[ID de sondage invalide]" poll = Poll.query.get(content_id) @@ -89,7 +91,7 @@ def handleTopic(content_id, context): try: id = int(content_id) except ValueError: - return "[ID du topic invalide]" + return "[ID de topic invalide]" topic = Topic.query.get(content_id) @@ -120,3 +122,18 @@ def handleUser(content_id, context): a.set('class', 'profile-link') return a + +def handleFile(content_id, context): + try: + content_id = int(content_id) + except ValueError: + return "[ID de fichier invalide]" + + file = Attachment.query.get(content_id) + + if file is None: + return "[Fichier non trouvé]" + + html = render_template('widgets/download_button.html', file=file) + html = html.replace('\n', '') # Needed to avoid lots of
    due to etree + return etree.fromstring(html) diff --git a/app/utils/priv_required.py b/app/utils/priv_required.py index 70493af..fc198ed 100644 --- a/app/utils/priv_required.py +++ b/app/utils/priv_required.py @@ -13,7 +13,7 @@ def priv_required(*perms): Example: @app.route('/admin') - @priv_required('access-admin-board') + @priv_required('forum.access.admin') def admin_board(): pass diff --git a/app/utils/render.py b/app/utils/render.py index fa4a154..766e3f7 100644 --- a/app/utils/render.py +++ b/app/utils/render.py @@ -1,17 +1,11 @@ from flask import render_template +from flask_login import current_user def render(*args, styles=[], scripts=[], **kwargs): - # TODO: debugguer cette merde : au logout, ça foire - # if current_user.is_authenticated: - # login_form = LoginForm() - # return render_template(*args, **kwargs, login_form=login_form) - # return render_template(*args, **kwargs) - # Pour jouer sur les feuilles de style ou les scripts : # render('page.html', styles=['-css/form.css', '+css/admin/forms.css']) styles_ = [ - 'css/theme.css', 'css/global.css', 'css/navbar.css', 'css/header.css', @@ -22,18 +16,32 @@ def render(*args, styles=[], scripts=[], **kwargs): 'css/flash.css', 'css/table.css', 'css/pagination.css', - 'css/responsive.css', 'css/simplemde.min.css', - 'css/pygments.css', + 'css/simplemde-override.css', ] scripts_ = [ 'scripts/trigger_menu.js', 'scripts/pc-utils.js', 'scripts/smartphone_patch.js', 'scripts/simplemde.min.js', - 'scripts/filter.js' + 'scripts/gallery.js', + 'scripts/filter.js', ] + # Apply theme from user settings + theme = current_user.theme if current_user.is_authenticated else '' + theme = theme if theme else 'default_theme' + styles_ = [f'css/themes/{theme}.css'] + styles_ + + # Corresponding Pygments styles (might want to abstract later) + code = { + 'default_theme': 'default', + 'FK_dark_theme': 'material', + 'Tituya_v43_theme': 'friendly', + } + code = code.get(theme, 'default') + styles_.append(f'css/pygments-{code}.css') + for s in styles: if s[0] == '-': styles_.remove(s[1:]) diff --git a/app/utils/validators/__init__.py b/app/utils/validators/__init__.py index 08899e4..376d4d7 100644 --- a/app/utils/validators/__init__.py +++ b/app/utils/validators/__init__.py @@ -8,6 +8,8 @@ from app.utils.validators.file import * from app.utils.validators.name import * from app.utils.validators.password import * +import re + def email(form, email): member = Member.query.filter_by(email=email.data).first() @@ -30,8 +32,13 @@ def id_exists(object): def css(form, css): """Check if input is valid and sane CSS""" - pass + prop = r'[a-zA-Z-]+\s*:\s*[^;{}\'"]+' + stylesheet = rf'\s*(?:{prop};\s*)*{prop};?\s*' + if css.data and re.fullmatch(stylesheet, css.data) is None: + raise ValidationError('CSS invalide (les caractères ;{}\'" sont '+\ + 'interdits dans les valeurs)') + return True def own_title(form, title): # Everyone can use "Member" diff --git a/app/utils/validators/file.py b/app/utils/validators/file.py index 7c2da7b..10e1e7d 100644 --- a/app/utils/validators/file.py +++ b/app/utils/validators/file.py @@ -13,7 +13,7 @@ def optional(form, files): def count(form, files): if current_user.is_authenticated: - if current_user.priv("no-upload-limits"): + if current_user.priv("misc.no-upload-limits"): return if len(files.data) > 100: # 100 files for a authenticated user raise ValidationError("100 fichiers maximum autorisés") @@ -48,7 +48,7 @@ def size(form, files): """There is no global limit to file sizes""" size = sum([filesize(f) for f in files.data]) if current_user.is_authenticated: - if current_user.priv("no-upload-limits"): + if current_user.priv("misc.no-upload-limits"): return if size > 5e6: # 5 Mo per comment for an authenticated user raise ValidationError("Fichiers trop lourds (max 5 Mo)") diff --git a/assets/diagramme_1.dia b/assets/diagramme_1.dia index 4e72781..1714e1a 100644 Binary files a/assets/diagramme_1.dia and b/assets/diagramme_1.dia differ diff --git a/assets/diagramme_1.dia~ b/assets/diagramme_1.dia~ deleted file mode 100644 index e4d1e75..0000000 Binary files a/assets/diagramme_1.dia~ and /dev/null differ diff --git a/assets/diagramme_1.png b/assets/diagramme_1.png index 34e114d..8f9c139 100644 Binary files a/assets/diagramme_1.png and b/assets/diagramme_1.png differ diff --git a/assets/privs.txt b/assets/privs.txt deleted file mode 100644 index 4fbab33..0000000 --- a/assets/privs.txt +++ /dev/null @@ -1,42 +0,0 @@ -# Privileges - -Read/write access to forum boards: - access-admin-board Administration board of the forum - access-assoc-board CreativeCalc discussion board - write-news Post articles on the news board - -Shared file upload (like /Fr/adpc/img.php for any file): - upload-shared-files Upload files and images on the website server - delete-shared-files Delete files uploaded with upload-shared-files - -Post management: - edit-posts Edit any post on the website - delete-posts Remove any post from the website - scheduled-posting Schedule a post or content creation in the future - -Content (topic, progs, tutos, etc) management: - delete-content Delete whole topics, program pages, or tutorials - move-public-content Change the section of a page in a public section - move-private-content Change the section of a page in a private section - showcase-content Manage stocky content (post-its) - edit-static-content Edit static content pages - extract-posts Move out-of-topic posts to a new places - -Program evaluation: - delete-notes Delete program notes - delete-tests Delete program tests - -Shoutbox: - shoutbox-post Write messages in the shoutbox - shoutbox-kick Kick people using the shoutbox - shoutbox-ban Ban people using the shoutbox - -Miscellaenous: - unlimited-pms Removes the limit on the number of private messages - footer-statistics View performance statistics in the page footer - community-login Automatically login as a community account - -Administration panel: - access-admin-panel Administration panel of website - edit-account Edit details of any account - delete-account Remove member accounts diff --git a/config.py b/config.py index b666f25..8b4efe8 100644 --- a/config.py +++ b/config.py @@ -55,6 +55,10 @@ class DefaultConfig(object): GLADOS_PORT = 5555 # Time before trigerring the necropost alert NECROPOST_LIMIT = timedelta(days=31) + # Acceptable page loading time; longer generation is reported to devs. This + # is computed in the page header, so it doesn't account for most of the + # template generation. + SLOW_REQUEST_THRESHOLD = 0.400 # s class V5Config(LocalConfig, DefaultConfig): diff --git a/demo/barre_laterale.png b/demo/barre_laterale.png new file mode 100644 index 0000000..9875f57 Binary files /dev/null and b/demo/barre_laterale.png differ diff --git a/demo/forum.png b/demo/forum.png new file mode 100644 index 0000000..18b079b Binary files /dev/null and b/demo/forum.png differ diff --git a/demo/index.png b/demo/index.png new file mode 100644 index 0000000..6ba6744 Binary files /dev/null and b/demo/index.png differ diff --git a/demo/index_discussions.png b/demo/index_discussions.png new file mode 100644 index 0000000..9841f2b Binary files /dev/null and b/demo/index_discussions.png differ diff --git a/demo/mobile.png b/demo/mobile.png new file mode 100644 index 0000000..975f443 Binary files /dev/null and b/demo/mobile.png differ diff --git a/demo/parametres.png b/demo/parametres.png new file mode 100644 index 0000000..41e3730 Binary files /dev/null and b/demo/parametres.png differ diff --git a/demo/profil.png b/demo/profil.png new file mode 100644 index 0000000..d7d7fad Binary files /dev/null and b/demo/profil.png differ diff --git a/demo/topic_dark.png b/demo/topic_dark.png new file mode 100644 index 0000000..41276b0 Binary files /dev/null and b/demo/topic_dark.png differ diff --git a/local_config.py.default b/local_config.py.default index 91a67ee..f631079 100644 --- a/local_config.py.default +++ b/local_config.py.default @@ -6,7 +6,7 @@ class LocalConfig(object): USE_LDAP = True LDAP_PASSWORD = "openldap" LDAP_ENV = "o=prod" - SECRET_KEY = "a-random-secret-key" # CHANGE THIS VALUE *NOW* + SECRET_KEY = "a-random-secret-key" # CHANGE THIS VALUE *NOW* AVATARS_FOLDER = '/home/pc/data/avatars/' ENABLE_GUEST_POST = True - SEND_MAILS = True + SEND_MAILS = True diff --git a/master.py b/master.py index 114e354..6a79f4c 100755 --- a/master.py +++ b/master.py @@ -16,95 +16,48 @@ from PIL import Image help_msg = """ This is the Planète Casio master shell. Type 'exit' or C-D to leave. +Type 'help' to print this message. -Type a category name to see a list of elements. Available categories are: +Listing commands: + members Show registered community members + groups Show privilege groups + forums Show forum tree + trophies Show trophies - 'members' Registered community members - 'groups' Privilege groups - 'trophies' Trophies - 'trophy-members' Trophies owned by members - 'forums' Forum tree - -For each category, an argument can be specified: -* 'clear' will remove all entries in the category (destroys a lot of data!) -* 'update' will update from the model in app/data/, when applicable - (currently available on: forums, groups) - -Type 'create-common-accounts' to recreate 'Planète Casio' and 'GLaDOS' -Type 'add-group #' to add a new member to a group. -Type 'create-trophies' to reset trophies and titles and their icons. -Type 'enable-user' to enable a email-disabled account. +Install and update commands: + update-groups Create or update groups from app/data/ + update-forums Create or update the forum tree from app/data/ + update-trophies Create or update trophies + generate-trophy-icons Regenerate all trophy icons + create-common-accounts Remove and recreate 'Planète Casio' and 'GLaDOS' + add-group # Add to group # (presumably admins) + enable-user Manually confirm member's email address """ # -# Category viewers +# Listing commands # def members(*args): - if args == ("clear",): - for m in Member.query.all(): - m.delete() - db.session.commit() - print("Removed all members.") - return - for m in Member.query.all(): print(m) def groups(*args): - if args == ("clear",): - for g in Group.query.all(): - g.delete() - db.session.commit() - print("Removed all groups.") - return - - if args == ("update",): - update_groups() - return - for g in Group.query.all(): print(f"#{g.id} {g.name}") -def trophies(*args): - if args == ("clear",): - for t in Trophy.query.all(): - db.session.delete(t) - db.session.commit() - print("Removed all trophies.") - return - - for t in Trophy.query.all(): - print(t) - -def trophy_members(*args): - for t in Trophy.query.all(): - if t.owners == []: - continue - - print(t) - for m in t.owners: - print(f" {m}") - def forums(*args): - if args == ("clear",): - for f in Forum.query.all(): - db.session.delete(f) - db.session.commit() - print("Removed all forums.") - return - - if args == ("update",): - update_forums() - return - for f in Forum.query.all(): parent = f"in {f.parent.url}" if f.parent is not None else "root" print(f"{f.url} ({parent}) [{f.prefix}]: {f.name}") print(f" {f.descr}") +def trophies(*args): + for t in Trophy.query.all(): + print(t) + # -# Creation and edition +# Install and update commands # def update_groups(): @@ -148,90 +101,8 @@ def update_groups(): print(f"[group] Created {g.name}") - -def create_common_accounts(): - # Clean up common accounts - for name in "PlanèteCasio GLaDOS".split(): - m = Member.query.filter_by(name=name).first() - if m is not None: - m.delete() - - # Recreate theme - def addgroup(member, group): - g = Group.query.filter_by(name=group).first() - if g is not None: - member.groups.append(g) - - m = Member("PlanèteCasio", "contact@planet-casio.com", "nologin") - addgroup(m, "Compte communautaire") - addgroup(m, "No login") - db.session.add(m) - - m = Member("GLaDOS", "glados@aperture.science", "nologin") - m.xp = 1338 - addgroup(m, "Robot") - addgroup(m, "No login") - db.session.add(m) db.session.commit() - db.session.add(SpecialPrivilege(m, "edit-posts")) - db.session.add(SpecialPrivilege(m, "shoutbox-ban")) - - db.session.commit() - - -def create_trophies(): - # Clean up trophies - trophies("clear") - - # Create base trophies - tr = [] - with open(os.path.join(app.root_path, "data", "trophies.yaml")) as fp: - tr = yaml.safe_load(fp.read()) - - for t in tr: - description = t.get("description", "") - - if t["is_title"]: - trophy = Title(t["name"], description, t["hidden"], - t.get("css", "")) - else: - trophy = Trophy(t["name"], description, t["hidden"]) - db.session.add(trophy) - db.session.commit() - - print(f"Created {len(tr)} trophies.") - # Create their icons - create_trophies_icons() - - -def create_trophies_icons(): - tr = [] - with open(os.path.join(app.root_path, "data", "trophies.yaml")) as fp: - tr = yaml.safe_load(fp.read()) - - names = [slugify.slugify(t["name"]) for t in tr] - src = os.path.join(app.root_path, "data", "trophies.png") - dst = os.path.join(app.root_path, "static", "images", "trophies") - - try: - os.mkdir(dst) - except FileExistsError: - pass - - img = Image.open(src) - - def trophy_iterator(img): - for y in range(img.height // 26): - for x in range(img.width // 26): - icon = img.crop((26*x+1, 26*y+1, 26*x+25, 26*y+25)) - # Skip blank squares in the source image - if len(icon.getcolors()) > 1: - yield icon.resize((48,48)) - - for (name, icon) in zip(names, trophy_iterator(img)): - icon.save(os.path.join(dst, f"{name}.png")) - def update_forums(): # Get current forums @@ -284,6 +155,117 @@ def update_forums(): db.session.commit() + +def update_trophies(): + existing = Trophy.query.all() + + # Get the list of what we want to obtain + tr = [] + with open(os.path.join(app.root_path, "data", "trophies.yaml")) as fp: + tr = yaml.safe_load(fp.read()) + tr = { t["name"]: t for t in tr } + + # Remove trophies that we don't want or that we want as a different type + for t in existing: + if t.name not in tr or isinstance(t, Title) != tr[t.name]["is_title"]: + kind = "title" if isinstance(t, Title) else "trophy" + print(f"[trophies] Deleted '{t.name}' ({kind})") + db.session.delete(t) + db.session.commit() + + # Add missing trophies + for name, t in tr.items(): + description = t.get("description", "") + css = t.get("css", "") + trophy = Trophy.query.filter_by(name=name).first() + + if "css" in t and not t["is_title"]: + print(f"[trophies] CSS on '{name}' is meaningless (not a title)") + + # Updating existing trophies + if trophy is not None: + changes = (trophy.description != description) or \ + (trophy.hidden != t["hidden"] or (isinstance(trophy,Title) and \ + trophy.css != css)) + trophy.description = description + trophy.hidden = t["hidden"] + if isinstance(trophy, Title): + trophy.css = css + if changes: + print(f"[trophies] Updated '{name}'") + + # Add missing ones + elif t["is_title"]: + trophy = Title(name, description, t["hidden"], t.get("css", "")) + print(f"[trophies] Created '{name}' (title)") + else: + trophy = Trophy(name, description, t["hidden"]) + print(f"[trophies] Created '{name}' (trophy)") + + db.session.add(trophy) + + db.session.commit() + + +def generate_trophy_icons(): + tr = [] + with open(os.path.join(app.root_path, "data", "trophies.yaml")) as fp: + tr = yaml.safe_load(fp.read()) + + names = [slugify.slugify(t["name"]) for t in tr] + src = os.path.join(app.root_path, "data", "trophies.png") + dst = os.path.join(app.root_path, "static", "images", "trophies") + + try: + os.mkdir(dst) + except FileExistsError: + pass + + img = Image.open(src) + + def trophy_iterator(img): + for y in range(img.height // 26): + for x in range(img.width // 26): + icon = img.crop((26*x+1, 26*y+1, 26*x+25, 26*y+25)) + # Skip blank squares in the source image + if len(icon.getcolors()) > 1: + yield icon.resize((48,48), resample=Image.NEAREST) + + for (name, icon) in zip(names, trophy_iterator(img)): + icon.save(os.path.join(dst, f"{name}.png")) + + +def create_common_accounts(): + # Clean up common accounts + for name in "PlanèteCasio GLaDOS".split(): + m = Member.query.filter_by(name=name).first() + if m is not None: + m.delete() + + # Recreate theme + def addgroup(member, group): + g = Group.query.filter_by(name=group).first() + if g is not None: + member.groups.append(g) + + m = Member("PlanèteCasio", "contact@planet-casio.com", "nologin") + addgroup(m, "Compte communautaire") + addgroup(m, "No login") + db.session.add(m) + + m = Member("GLaDOS", "glados@aperture.science", "nologin") + m.xp = 1338 + addgroup(m, "Robot") + addgroup(m, "No login") + db.session.add(m) + db.session.commit() + + db.session.add(SpecialPrivilege(m, "edit.posts")) + db.session.add(SpecialPrivilege(m, "shoutbox.ban")) + + db.session.commit() + + def add_group(member, group): if group[0] != "#": print(f"error: group id {group} should start with '#'") @@ -303,6 +285,7 @@ def add_group(member, group): db.session.add(m) db.session.commit() + def enable_user(member): norm = unicode_names.normalize(member) m = Member.query.filter_by(norm=norm).first() @@ -320,14 +303,16 @@ def enable_user(member): commands = { "exit": lambda: sys.exit(0), + "help": lambda: print(help_msg), "members": members, "groups": groups, - "trophies": trophies, - "trophy-members": trophy_members, "forums": forums, + "trophies": trophies, + "update-groups": update_groups, + "update-forums": update_forums, + "update-trophies": update_trophies, + "generate-trophy-icons": generate_trophy_icons, "create-common-accounts": create_common_accounts, - "create-trophies": create_trophies, - "create-trophies-icons": create_trophies_icons, "add-group": add_group, "enable-user": enable_user, } @@ -350,6 +335,10 @@ else: try: cmd = input("@> ").split() except EOFError: + print("^D") + sys.exit(0) + except KeyboardInterrupt: + print("^C") sys.exit(0) if cmd: diff --git a/migrations/env.py b/migrations/env.py index 2d31c5a..1b619d0 100644 --- a/migrations/env.py +++ b/migrations/env.py @@ -41,7 +41,7 @@ def run_migrations_offline(): """ url = config.get_main_option("sqlalchemy.url") - context.configure(url=url) + context.configure(url=url, compare_type=True) with context.begin_transaction(): context.run_migrations() @@ -73,7 +73,8 @@ def run_migrations_online(): context.configure(connection=connection, target_metadata=target_metadata, process_revision_directives=process_revision_directives, - **current_app.extensions['migrate'].configure_args) + **current_app.extensions['migrate'].configure_args, + compare_type=True) try: with context.begin_transaction(): diff --git a/migrations/versions/0abd1c81e3aa_index_coments_by_thread.py b/migrations/versions/0abd1c81e3aa_index_coments_by_thread.py new file mode 100644 index 0000000..d3bbc63 --- /dev/null +++ b/migrations/versions/0abd1c81e3aa_index_coments_by_thread.py @@ -0,0 +1,28 @@ +"""Index coments by thread + +Revision ID: 0abd1c81e3aa +Revises: 44c2e37ef899 +Create Date: 2021-07-07 16:26:23.230696 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = '0abd1c81e3aa' +down_revision = '44c2e37ef899' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.create_index(op.f('ix_comment_thread_id'), 'comment', ['thread_id'], unique=False) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_index(op.f('ix_comment_thread_id'), table_name='comment') + # ### end Alembic commands ### diff --git a/migrations/versions/44c2e37ef899_update_trailing_field_lengths.py b/migrations/versions/44c2e37ef899_update_trailing_field_lengths.py new file mode 100644 index 0000000..cc72bab --- /dev/null +++ b/migrations/versions/44c2e37ef899_update_trailing_field_lengths.py @@ -0,0 +1,58 @@ +"""update trailing field lengths + +Revision ID: 44c2e37ef899 +Revises: cfb91e6aa9fc +Create Date: 2021-04-25 22:51:58.510197 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = '44c2e37ef899' +down_revision = 'cfb91e6aa9fc' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.alter_column('guest', 'name', + existing_type=sa.VARCHAR(length=64), + type_=sa.Unicode(length=32), + existing_nullable=True) + op.alter_column('topic', 'title', + existing_type=sa.VARCHAR(length=32), + type_=sa.Unicode(length=128), + existing_nullable=True) + op.alter_column('trophy', 'name', + existing_type=sa.TEXT(), + type_=sa.Unicode(length=64), + existing_nullable=True) + op.alter_column('user', 'type', + existing_type=sa.VARCHAR(length=20), + type_=sa.String(length=30), + existing_nullable=True) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.alter_column('user', 'type', + existing_type=sa.String(length=30), + type_=sa.VARCHAR(length=20), + existing_nullable=True) + op.alter_column('trophy', 'name', + existing_type=sa.Unicode(length=64), + type_=sa.TEXT(), + existing_nullable=True) + op.alter_column('topic', 'title', + existing_type=sa.Unicode(length=128), + type_=sa.VARCHAR(length=32), + existing_nullable=True) + op.alter_column('guest', 'name', + existing_type=sa.Unicode(length=32), + type_=sa.VARCHAR(length=64), + existing_nullable=True) + # ### end Alembic commands ### diff --git a/migrations/versions/adcd1577f301_add_theme_settings_for_members.py b/migrations/versions/adcd1577f301_add_theme_settings_for_members.py new file mode 100644 index 0000000..3e6b4cb --- /dev/null +++ b/migrations/versions/adcd1577f301_add_theme_settings_for_members.py @@ -0,0 +1,28 @@ +"""Add theme settings for Members + +Revision ID: adcd1577f301 +Revises: 0abd1c81e3aa +Create Date: 2021-07-08 11:25:19.535246 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = 'adcd1577f301' +down_revision = '0abd1c81e3aa' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.add_column('member', sa.Column('theme', sa.Unicode(length=32), nullable=True)) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_column('member', 'theme') + # ### end Alembic commands ### diff --git a/migrations/versions/bcfdb271b88d_add_a_number_of_missing_indexes.py b/migrations/versions/bcfdb271b88d_add_a_number_of_missing_indexes.py new file mode 100644 index 0000000..108b66c --- /dev/null +++ b/migrations/versions/bcfdb271b88d_add_a_number_of_missing_indexes.py @@ -0,0 +1,40 @@ +"""Add a number of missing indexes + +Revision ID: bcfdb271b88d +Revises: adcd1577f301 +Create Date: 2022-04-21 17:45:09.787769 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = 'bcfdb271b88d' +down_revision = 'adcd1577f301' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.create_index(op.f('ix_attachment_comment_id'), 'attachment', ['comment_id'], unique=False) + op.create_index(op.f('ix_notification_owner_id'), 'notification', ['owner_id'], unique=False) + op.create_index(op.f('ix_pollanswer_poll_id'), 'pollanswer', ['poll_id'], unique=False) + op.create_index(op.f('ix_post_author_id'), 'post', ['author_id'], unique=False) + op.create_index(op.f('ix_post_date_modified'), 'post', ['date_modified'], unique=False) + op.create_index(op.f('ix_topic_forum_id'), 'topic', ['forum_id'], unique=False) + op.create_index(op.f('ix_trophy_member_uid'), 'trophy_member', ['uid'], unique=False) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_index(op.f('ix_trophy_member_uid'), table_name='trophy_member') + op.drop_index(op.f('ix_topic_forum_id'), table_name='topic') + op.drop_index(op.f('ix_post_date_modified'), table_name='post') + op.drop_index(op.f('ix_post_author_id'), table_name='post') + op.drop_index(op.f('ix_pollanswer_poll_id'), table_name='pollanswer') + op.drop_index(op.f('ix_notification_owner_id'), table_name='notification') + op.drop_index(op.f('ix_attachment_comment_id'), table_name='attachment') + # ### end Alembic commands ### diff --git a/migrations/versions/d2227d2479e2_add_an_index_on_post_type.py b/migrations/versions/d2227d2479e2_add_an_index_on_post_type.py new file mode 100644 index 0000000..e1839c3 --- /dev/null +++ b/migrations/versions/d2227d2479e2_add_an_index_on_post_type.py @@ -0,0 +1,28 @@ +"""Add an index on Post.type + +Revision ID: d2227d2479e2 +Revises: bcfdb271b88d +Create Date: 2022-04-25 16:44:51.241965 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = 'd2227d2479e2' +down_revision = 'bcfdb271b88d' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.create_index(op.f('ix_post_type'), 'post', ['type'], unique=False) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_index(op.f('ix_post_type'), table_name='post') + # ### end Alembic commands ###