From 08a1a0436b110da19d775a3222ecdf4baee43544 Mon Sep 17 00:00:00 2001 From: Maxim Harder Date: Sun, 20 Sep 2026 14:49:49 +0200 Subject: [PATCH 1/5] =?UTF-8?q?feat:=20=D0=B4=D0=BE=D0=B1=D0=B0=D0=B2?= =?UTF-8?q?=D0=BB=D1=8F=D0=B5=D1=82=20=D1=83=D0=BF=D1=80=D0=B0=D0=B2=D0=BB?= =?UTF-8?q?=D0=B5=D0=BD=D0=B8=D0=B5=20=D0=BF=D1=80=D0=B0=D0=B2=D0=B0=D0=BC?= =?UTF-8?q?=D0=B8=20=D0=B3=D1=80=D1=83=D0=BF=D0=BF=20=D0=B8=20=D0=B1=D0=B5?= =?UTF-8?q?=D0=BB=D1=8B=D0=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit список предлагающих Добавляет новый шаблон для управления правами доступа групп пользователей с табличным интерфейсом и поддержкой разных типов прав (чекбоксы и числовые значения). Реализует страницу истории изменений и обработчик для сохранения настроек модуля UserLists. Добавляет репозиторий для работы с белым списком пользователей, которые могут предлагать записи в списки. --- LICENSE | 695 +----------------- manifest.json | 10 +- readme.md | 72 +- upload/devcraft/config/user_lists.json | 9 + .../UserLists/Ajax/CreateListHandler.php | 46 ++ .../UserLists/Ajax/DeleteListHandler.php | 41 ++ .../UserLists/Ajax/ModalListsHandler.php | 52 ++ .../Ajax/ModerateSuggestionHandler.php | 39 + .../UserLists/Ajax/PermissionsHandler.php | 89 +++ .../UserLists/Ajax/ReorderListsHandler.php | 68 ++ .../UserLists/Ajax/SaveListHandler.php | 88 +++ .../UserLists/Ajax/SettingsHandler.php | 33 + .../UserLists/Ajax/SuggestItemHandler.php | 42 ++ .../UserLists/Ajax/ToggleItemHandler.php | 39 + .../Filter/admin_lists.filter.schema.php | 35 + .../Filter/user_lists.filter.schema.php | 48 ++ .../UserLists/Models/GroupPermission.php | 47 ++ .../src/modules/UserLists/Models/UserList.php | 70 ++ .../modules/UserLists/Models/UserListItem.php | 42 ++ .../UserLists/Models/UserListSuggestor.php | 30 + .../UserLists/Pages/AdminListsPage.php | 115 +++ .../modules/UserLists/Pages/ChangelogPage.php | 26 + .../modules/UserLists/Pages/DashboardPage.php | 70 ++ .../modules/UserLists/Pages/EditListPage.php | 118 +++ .../UserLists/Pages/PermissionsPage.php | 66 ++ .../modules/UserLists/Pages/SettingsPage.php | 44 ++ .../modules/UserLists/Pages/TemplatesPage.php | 26 + .../modules/UserLists/Pages/UserListsPage.php | 148 ++++ .../src/modules/UserLists/Public/.htaccess | 7 + .../modules/UserLists/Public/user_lists.js | 600 +++++++++++++++ .../GroupPermissionRepository.php | 39 + .../Repositories/UserListItemRepository.php | 104 +++ .../Repositories/UserListRepository.php | 91 +++ .../UserListSuggestorRepository.php | 59 ++ .../UserLists/Services/AuditLogger.php | 22 + .../UserLists/Services/ConfigNormalizer.php | 46 ++ .../UserLists/Services/ListService.php | 492 +++++++++++++ .../UserLists/Services/PermissionService.php | 123 ++++ .../UserLists/Services/SeedService.php | 39 + .../modules/UserLists/UserListsIdentity.php | 18 + .../src/modules/UserLists/changelog.data.php | 43 ++ .../src/modules/UserLists/manifest.php | 85 +++ .../modules/UserLists/permissions.defs.php | 43 ++ .../src/modules/UserLists/settings.schema.php | 39 + .../UserLists/templates/admin_lists.twig | 52 ++ .../src/modules/UserLists/templates/edit.twig | 65 ++ .../UserLists/templates/permissions.twig | 59 ++ .../modules/UserLists/templates/settings.twig | 1 + .../UserLists/templates/templates.twig | 52 ++ .../UserLists/templates/user_lists.twig | 57 ++ upload/engine/inc/user_lists.php | 18 + .../Default/devcraft/user_lists/.htaccess | 7 + .../Default/devcraft/user_lists/button.tpl | 3 + .../Default/devcraft/user_lists/modal.tpl | 8 + .../Default/devcraft/user_lists/page.tpl | 4 + .../Default/devcraft/user_lists/proposals.tpl | 4 + .../Default/devcraft/user_lists/view.tpl | 5 + 57 files changed, 3706 insertions(+), 687 deletions(-) create mode 100644 upload/devcraft/config/user_lists.json create mode 100644 upload/devcraft/src/modules/UserLists/Ajax/CreateListHandler.php create mode 100644 upload/devcraft/src/modules/UserLists/Ajax/DeleteListHandler.php create mode 100644 upload/devcraft/src/modules/UserLists/Ajax/ModalListsHandler.php create mode 100644 upload/devcraft/src/modules/UserLists/Ajax/ModerateSuggestionHandler.php create mode 100644 upload/devcraft/src/modules/UserLists/Ajax/PermissionsHandler.php create mode 100644 upload/devcraft/src/modules/UserLists/Ajax/ReorderListsHandler.php create mode 100644 upload/devcraft/src/modules/UserLists/Ajax/SaveListHandler.php create mode 100644 upload/devcraft/src/modules/UserLists/Ajax/SettingsHandler.php create mode 100644 upload/devcraft/src/modules/UserLists/Ajax/SuggestItemHandler.php create mode 100644 upload/devcraft/src/modules/UserLists/Ajax/ToggleItemHandler.php create mode 100644 upload/devcraft/src/modules/UserLists/Filter/admin_lists.filter.schema.php create mode 100644 upload/devcraft/src/modules/UserLists/Filter/user_lists.filter.schema.php create mode 100644 upload/devcraft/src/modules/UserLists/Models/GroupPermission.php create mode 100644 upload/devcraft/src/modules/UserLists/Models/UserList.php create mode 100644 upload/devcraft/src/modules/UserLists/Models/UserListItem.php create mode 100644 upload/devcraft/src/modules/UserLists/Models/UserListSuggestor.php create mode 100644 upload/devcraft/src/modules/UserLists/Pages/AdminListsPage.php create mode 100644 upload/devcraft/src/modules/UserLists/Pages/ChangelogPage.php create mode 100644 upload/devcraft/src/modules/UserLists/Pages/DashboardPage.php create mode 100644 upload/devcraft/src/modules/UserLists/Pages/EditListPage.php create mode 100644 upload/devcraft/src/modules/UserLists/Pages/PermissionsPage.php create mode 100644 upload/devcraft/src/modules/UserLists/Pages/SettingsPage.php create mode 100644 upload/devcraft/src/modules/UserLists/Pages/TemplatesPage.php create mode 100644 upload/devcraft/src/modules/UserLists/Pages/UserListsPage.php create mode 100644 upload/devcraft/src/modules/UserLists/Public/.htaccess create mode 100644 upload/devcraft/src/modules/UserLists/Public/user_lists.js create mode 100644 upload/devcraft/src/modules/UserLists/Repositories/GroupPermissionRepository.php create mode 100644 upload/devcraft/src/modules/UserLists/Repositories/UserListItemRepository.php create mode 100644 upload/devcraft/src/modules/UserLists/Repositories/UserListRepository.php create mode 100644 upload/devcraft/src/modules/UserLists/Repositories/UserListSuggestorRepository.php create mode 100644 upload/devcraft/src/modules/UserLists/Services/AuditLogger.php create mode 100644 upload/devcraft/src/modules/UserLists/Services/ConfigNormalizer.php create mode 100644 upload/devcraft/src/modules/UserLists/Services/ListService.php create mode 100644 upload/devcraft/src/modules/UserLists/Services/PermissionService.php create mode 100644 upload/devcraft/src/modules/UserLists/Services/SeedService.php create mode 100644 upload/devcraft/src/modules/UserLists/UserListsIdentity.php create mode 100644 upload/devcraft/src/modules/UserLists/changelog.data.php create mode 100644 upload/devcraft/src/modules/UserLists/manifest.php create mode 100644 upload/devcraft/src/modules/UserLists/permissions.defs.php create mode 100644 upload/devcraft/src/modules/UserLists/settings.schema.php create mode 100644 upload/devcraft/src/modules/UserLists/templates/admin_lists.twig create mode 100644 upload/devcraft/src/modules/UserLists/templates/edit.twig create mode 100644 upload/devcraft/src/modules/UserLists/templates/permissions.twig create mode 100644 upload/devcraft/src/modules/UserLists/templates/settings.twig create mode 100644 upload/devcraft/src/modules/UserLists/templates/templates.twig create mode 100644 upload/devcraft/src/modules/UserLists/templates/user_lists.twig create mode 100644 upload/engine/inc/user_lists.php create mode 100644 upload/templates/Default/devcraft/user_lists/.htaccess create mode 100644 upload/templates/Default/devcraft/user_lists/button.tpl create mode 100644 upload/templates/Default/devcraft/user_lists/modal.tpl create mode 100644 upload/templates/Default/devcraft/user_lists/page.tpl create mode 100644 upload/templates/Default/devcraft/user_lists/proposals.tpl create mode 100644 upload/templates/Default/devcraft/user_lists/view.tpl diff --git a/LICENSE b/LICENSE index 94a9ed0..94894b3 100644 --- a/LICENSE +++ b/LICENSE @@ -1,674 +1,21 @@ - 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 -. +MIT License + +Copyright (c) 2024-2026 Maxim Harder / DevCraft Club + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/manifest.json b/manifest.json index 2802877..a6e6347 100644 --- a/manifest.json +++ b/manifest.json @@ -2,12 +2,14 @@ "version": "200.1.0", "status": "dev", "dle": [ - "20.0" + "21.0" ], "php": [ "8.3" ], - "mhadmin": "200.4.0", - "name": "Название проекта", - "description": "Описание проекта" + "mhadmin": "200.4.1", + "name": "Пользовательские списки", + "description": "UserLists — свои и общие списки новостей для DLE 21 / DevCraft Admin ≥ 200.4.1", + "code": "user_lists", + "mod": "user_lists" } diff --git a/readme.md b/readme.md index 3108cfd..ea6656d 100644 --- a/readme.md +++ b/readme.md @@ -1,11 +1,65 @@ -Просто шаблон для репозиториев +# Пользовательские списки (UserLists) -1. Обновить значения в `manifest.json` -2. Удалить `crowdin.yml`, если нет файлов локализации -3. Обновить данные в `install_archive.(sh|bat)` либо удалить эти файлы -4. Очередь Dependabot (`.github/workflows/dependabot-queue.yml`): - - Settings → General → Allow auto-merge (по желанию) - - Опциональный секрет `QUEUE_GITHUB_TOKEN` (PAT) для обхода CODEOWNERS / rulesets - - В `.github/dependabot.yml` поменять reviewers (`MaximHarder` → владелец репозитория) - - CI в `dependabot-ci-automerge.yml` подогнать под стек (без `package.json` — только минимальный gate) +Сателлит [DevCraft Admin](https://readme.devcraft.club/dev/dle/devcraft_admin/getting_started) для DataLife Engine: люди собирают новости в **свои списки**, администратор задаёт **общие имена** («В планах», «Просмотрено»). Штатное «Избранное» DLE модуль не трогает. +**Версия:** 200.1.0 + +## Документация на сайте + +| Страница | Адрес | +| -------- | ----- | +| Начало работы | https://readme.devcraft.club/dev/dle/user_lists/200.1.0/getting_started | +| Установка | https://readme.devcraft.club/dev/dle/user_lists/200.1.0/install | +| Подключение в теме | https://readme.devcraft.club/dev/dle/user_lists/200.1.0/guides/theme | +| Общая установка плагинов | https://readme.devcraft.club/instructions/install_instructions | +| Публичные стили и скрипты | https://readme.devcraft.club/dev/dle/devcraft_admin/200.4.1/guides/public_assets | + +## Требования + +| Компонент | Минимум | +| --------- | ------- | +| DataLife Engine | **≥ 21.0** | +| PHP | **≥ 8.3** | +| DevCraft Admin | **≥ 200.4.1** | + +Сначала установите и включите DevCraft Admin, затем **UserLists**. + +## Установка + +Как собрать архив или поставить zip — [Установка плагинов](https://readme.devcraft.club/instructions/install_instructions). В комплекте: `install_archive.sh` / `install_archive.bat`, затем **Панель управления → Плагины**. Нужен `install.xml` в корне архива (`needplugin` = DevCraft Admin). Отдельный SQL-файл регистрации не используется. + +После включения откройте `?mod=user_lists`. Таблицы `dc_user_lists*` появятся при первом заходе. Затем права групп и вставки в тему. + +Библиотеки PHP обычно ставятся сами (скрипт установки или раздел Composer в админке). Вручную — только если автоматом не вышло: [Composer](https://readme.devcraft.club/instructions/composer). + +## Вставки в тему + +Стили и скрипты оболочки — не через include. В `main.tpl`: `{devcraft-header}` в `` и `{devcraft-scripts}` перед ``. + +Кнопка и окно на новости: + +```smarty +{include file="devcraft/src/modules/UserLists/Controller/show_user_lists.php?news_id={news-id}&focus=button"} +{include file="devcraft/src/modules/UserLists/Controller/show_user_lists.php?news_id={news-id}&focus=modal"} +``` + +Страницы списков: + +```smarty +{include file="devcraft/src/modules/UserLists/Controller/show_user_lists_page.php?focus=mine"} +{include file="devcraft/src/modules/UserLists/Controller/show_user_lists_page.php?focus=catalog"} +{include file="devcraft/src/modules/UserLists/Controller/show_user_lists_page.php?focus=proposals"} +{include file="devcraft/src/modules/UserLists/Controller/show_user_lists_page.php?focus=view&list_id=1"} +``` + +Пути `engine/modules/devcraft/user_lists*.php` больше не входят в пакет. + +Разметка: `templates/{skin}/devcraft/user_lists/` (`button.tpl`, `modal.tpl`, `page.tpl`, `view.tpl`, `proposals.tpl`). Нет каталога в своей теме — скопируйте из `Default`. + +Запросы: `devcraft/ajax.php` (`mod=user_lists`). Отдельный `engine/ajax/user_lists.php` не создаём. + +Код модуля: `UserLists`. Код плагина в DLE: `user_lists`. Точка входа админки: `engine/inc/user_lists.php`. + +## Лицензия + +MIT — см. [LICENSE](LICENSE). diff --git a/upload/devcraft/config/user_lists.json b/upload/devcraft/config/user_lists.json new file mode 100644 index 0000000..cf7c96c --- /dev/null +++ b/upload/devcraft/config/user_lists.json @@ -0,0 +1,9 @@ +{ + "guest_can_view_public": false, + "button_label": "В списки", + "count_web": 20, + "count_admin": 50, + "bad_words": "", + "name_min": 2, + "name_max": 100 +} diff --git a/upload/devcraft/src/modules/UserLists/Ajax/CreateListHandler.php b/upload/devcraft/src/modules/UserLists/Ajax/CreateListHandler.php new file mode 100644 index 0000000..38311db --- /dev/null +++ b/upload/devcraft/src/modules/UserLists/Ajax/CreateListHandler.php @@ -0,0 +1,46 @@ +createUserList( + $userId, + (string) ($request->data['name'] ?? ''), + (string) ($request->data['visibility'] ?? UserList::VIS_PRIVATE), + (string) ($request->data['description'] ?? ''), + ); + + return JsonResponse::toast(__('Список создан'), [ + 'id' => $list->id(), + 'name' => $list->name, + ]); + } catch(Throwable $e) { + return JsonResponse::fail(__('Ошибка'), $e->getMessage(), 'error', 400); + } + } + +} diff --git a/upload/devcraft/src/modules/UserLists/Ajax/DeleteListHandler.php b/upload/devcraft/src/modules/UserLists/Ajax/DeleteListHandler.php new file mode 100644 index 0000000..0e172bc --- /dev/null +++ b/upload/devcraft/src/modules/UserLists/Ajax/DeleteListHandler.php @@ -0,0 +1,41 @@ +data['_admin']) || ($request->controller ?? '') === 'admin'; + $service = new ListService(); + $id = (int) ($request->data['id'] ?? 0); + $list = $service->listsRepo()->findOneById($id); + + if($list === null) { + return JsonResponse::fail(__('Ошибка'), __('Список не найден'), 'not_found', 404); + } + + try { + $service->deleteList($list, $actorId, $asAdmin); + + return JsonResponse::toast(__('Удалено'), ['id' => $id]); + } catch(Throwable $e) { + return JsonResponse::fail(__('Ошибка'), $e->getMessage(), 'error', 400); + } + } + +} diff --git a/upload/devcraft/src/modules/UserLists/Ajax/ModalListsHandler.php b/upload/devcraft/src/modules/UserLists/Ajax/ModalListsHandler.php new file mode 100644 index 0000000..2a94090 --- /dev/null +++ b/upload/devcraft/src/modules/UserLists/Ajax/ModalListsHandler.php @@ -0,0 +1,52 @@ +data['news_id'] ?? 0); + + if($userId <= 0) { + return JsonResponse::fail(__('Ошибка'), __('Требуется авторизация'), 'auth', 403); + } + + if(!(new PermissionService())->isEnabled($userId)) { + return JsonResponse::fail(__('Ошибка'), __('Модуль недоступен'), 'auth', 403); + } + + if($newsId <= 0) { + return JsonResponse::fail(__('Ошибка'), __('Не указана новость'), 'validation', 422); + } + + try { + $service = new ListService(); + (new SeedService())->ensureDefaults($service); + + return JsonResponse::ok([ + 'lists' => $service->modalPayload($userId, $newsId), + 'news_id' => $newsId, + ]); + } catch(Throwable $e) { + return JsonResponse::fail(__('Ошибка'), $e->getMessage(), 'error', 400); + } + } + +} diff --git a/upload/devcraft/src/modules/UserLists/Ajax/ModerateSuggestionHandler.php b/upload/devcraft/src/modules/UserLists/Ajax/ModerateSuggestionHandler.php new file mode 100644 index 0000000..67aeac6 --- /dev/null +++ b/upload/devcraft/src/modules/UserLists/Ajax/ModerateSuggestionHandler.php @@ -0,0 +1,39 @@ +data['item_id'] ?? 0); + $approve = !empty($request->data['approve']); + + if($userId <= 0) { + return JsonResponse::fail(__('Ошибка'), __('Требуется авторизация'), 'auth', 403); + } + + try { + (new ListService())->moderateSuggestion($itemId, $userId, $approve); + + return JsonResponse::toast($approve ? __('Одобрено') : __('Отклонено')); + } catch(Throwable $e) { + return JsonResponse::fail(__('Ошибка'), $e->getMessage(), 'error', 400); + } + } + +} diff --git a/upload/devcraft/src/modules/UserLists/Ajax/PermissionsHandler.php b/upload/devcraft/src/modules/UserLists/Ajax/PermissionsHandler.php new file mode 100644 index 0000000..4e6bb85 --- /dev/null +++ b/upload/devcraft/src/modules/UserLists/Ajax/PermissionsHandler.php @@ -0,0 +1,89 @@ +database()->repository(GroupPermission::class); + + $batch = $request->data['groups'] ?? null; + + if(is_array($batch) && $batch !== []) { + $saved = 0; + + foreach($batch as $groupId => $raw) { + $gid = (int) $groupId; + + if($gid <= 0 || !is_array($raw)) { + continue; + } + + $repo->upsert($gid, $this->normalizeFlags($raw)); + $saved++; + } + + if($saved === 0) { + return JsonResponse::fail(__('Ошибка'), __('Нет групп для сохранения'), 'validation', 422); + } + + return JsonResponse::toast(__('Сохранено для {n} групп', ['{n}' => (string) $saved]), [ + 'saved' => $saved, + ]); + } + + $groupId = (int) ($request->data['group_id'] ?? 0); + + if($groupId <= 0) { + return JsonResponse::fail(__('Ошибка'), __('Не указана группа'), 'validation', 422); + } + + $raw = $request->data['flags'] ?? []; + + if(!is_array($raw)) { + $raw = []; + } + + $repo->upsert($groupId, $this->normalizeFlags($raw)); + + return JsonResponse::toast(__('Сохранено'), ['group_id' => $groupId, 'saved' => 1]); + } + + /** + * @param array $raw + * + * @return array + */ + private function normalizeFlags(array $raw): array { + $values = []; + + foreach(PermissionService::defs() as $def) { + $id = $def['id']; + $type = $def['type'] ?? 'bool'; + + if($type === 'int') { + $values[$id] = max(0, (int) ($raw[$id] ?? ($def['default'] ?? 0))); + } else { + $values[$id] = !empty($raw[$id]); + } + } + + return $values; + } + +} diff --git a/upload/devcraft/src/modules/UserLists/Ajax/ReorderListsHandler.php b/upload/devcraft/src/modules/UserLists/Ajax/ReorderListsHandler.php new file mode 100644 index 0000000..073385d --- /dev/null +++ b/upload/devcraft/src/modules/UserLists/Ajax/ReorderListsHandler.php @@ -0,0 +1,68 @@ +data['_admin']) || ($request->controller ?? '') === 'admin'; + $scope = (string) ($request->data['scope'] ?? 'user'); + $ids = $request->data['ids'] ?? []; + + if(!is_array($ids)) { + $ids = []; + } + + $ids = array_values(array_filter(array_map('intval', $ids))); + + try { + $t0 = hrtime(true); + $service = new ListService(); + + if($scope === 'admin') { + if(!$asAdmin) { + return JsonResponse::fail(__('Ошибка'), __('Недостаточно прав'), 'auth', 403); + } + + $service->reorderAdminLists($ids); + } elseif($asAdmin) { + $service->reorderListedUserLists($ids); + } else { + $service->reorderUserLists($actorId, $ids); + } + + $handlerMs = (hrtime(true) - $t0) / 1e6; + + return JsonResponse::toast(__('Порядок сохранён'), [ + 'timing' => [ + 'handler_ms' => round($handlerMs, 2), + 'scope' => $scope, + 'ids' => count($ids), + ], + ]); + } catch(Throwable $e) { + return JsonResponse::fail(__('Ошибка'), $e->getMessage(), 'error', 400); + } + } + +} diff --git a/upload/devcraft/src/modules/UserLists/Ajax/SaveListHandler.php b/upload/devcraft/src/modules/UserLists/Ajax/SaveListHandler.php new file mode 100644 index 0000000..0afca7b --- /dev/null +++ b/upload/devcraft/src/modules/UserLists/Ajax/SaveListHandler.php @@ -0,0 +1,88 @@ +data['_admin']) || ($request->controller ?? '') === 'admin'; + $service = new ListService(); + + try { + $id = (int) ($request->data['id'] ?? 0); + + if($id <= 0) { + $type = (string) ($request->data['type'] ?? UserList::TYPE_USER); + + if($type === UserList::TYPE_ADMIN) { + if(!$asAdmin) { + return JsonResponse::fail(__('Ошибка'), __('Недостаточно прав'), 'auth', 403); + } + + $list = $service->createAdminList((string) ($request->data['name'] ?? ''), $actorId); + } else { + $ownerId = $actorId; + + if($asAdmin) { + $ownerId = (int) ($request->data['owner_id'] ?? 0); + + if($ownerId <= 0) { + return JsonResponse::fail( + __('Ошибка'), + __('Укажите владельца списка'), + 'validation', + 422, + ); + } + } + + $list = $service->createUserList( + $ownerId, + (string) ($request->data['name'] ?? ''), + $asAdmin + ? UserList::VIS_PRIVATE + : (string) ($request->data['visibility'] ?? UserList::VIS_PRIVATE), + (string) ($request->data['description'] ?? ''), + $asAdmin, + ); + } + } else { + $list = $service->listsRepo()->findOneById($id); + + if($list === null) { + return JsonResponse::fail(__('Ошибка'), __('Список не найден'), 'not_found', 404); + } + + $list = $service->updateList($list, $request->data, $actorId, $asAdmin); + + if(isset($request->data['suggestor_ids']) && is_array($request->data['suggestor_ids'])) { + $service->suggestorsRepo()->replaceForList($list->id(), $request->data['suggestor_ids']); + } + } + + return JsonResponse::toast(__('Сохранено'), [ + 'id' => $list->id(), + 'name' => $list->name, + ]); + } catch(Throwable $e) { + return JsonResponse::fail(__('Ошибка'), $e->getMessage(), 'error', 400); + } + } + +} diff --git a/upload/devcraft/src/modules/UserLists/Ajax/SettingsHandler.php b/upload/devcraft/src/modules/UserLists/Ajax/SettingsHandler.php new file mode 100644 index 0000000..91dc1fd --- /dev/null +++ b/upload/devcraft/src/modules/UserLists/Ajax/SettingsHandler.php @@ -0,0 +1,33 @@ + $existing + * @param array $valid + * + * @return array + */ + protected function prepareConfig(array $existing, array $valid, FormSchema $schema): array|JsonResponse { + $normalizer = new ConfigNormalizer(); + + return $normalizer->normalize(array_merge($normalizer->normalize($existing), $valid)); + } + +} diff --git a/upload/devcraft/src/modules/UserLists/Ajax/SuggestItemHandler.php b/upload/devcraft/src/modules/UserLists/Ajax/SuggestItemHandler.php new file mode 100644 index 0000000..0e191cb --- /dev/null +++ b/upload/devcraft/src/modules/UserLists/Ajax/SuggestItemHandler.php @@ -0,0 +1,42 @@ +data['list_id'] ?? 0); + $newsId = (int) ($request->data['news_id'] ?? 0); + + if($userId <= 0) { + return JsonResponse::fail(__('Ошибка'), __('Требуется авторизация'), 'auth', 403); + } + + try { + $item = (new ListService())->suggestNews($listId, $userId, $newsId); + + return JsonResponse::toast(__('Предложение отправлено'), [ + 'id' => $item->id(), + 'status' => $item->status, + ]); + } catch(Throwable $e) { + return JsonResponse::fail(__('Ошибка'), $e->getMessage(), 'error', 400); + } + } + +} diff --git a/upload/devcraft/src/modules/UserLists/Ajax/ToggleItemHandler.php b/upload/devcraft/src/modules/UserLists/Ajax/ToggleItemHandler.php new file mode 100644 index 0000000..9374c67 --- /dev/null +++ b/upload/devcraft/src/modules/UserLists/Ajax/ToggleItemHandler.php @@ -0,0 +1,39 @@ +data['list_id'] ?? 0); + $newsId = (int) ($request->data['news_id'] ?? 0); + + if($userId <= 0) { + return JsonResponse::fail(__('Ошибка'), __('Требуется авторизация'), 'auth', 403); + } + + try { + $result = (new ListService())->toggleNews($listId, $userId, $newsId); + + return JsonResponse::ok($result); + } catch(Throwable $e) { + return JsonResponse::fail(__('Ошибка'), $e->getMessage(), 'error', 400); + } + } + +} diff --git a/upload/devcraft/src/modules/UserLists/Filter/admin_lists.filter.schema.php b/upload/devcraft/src/modules/UserLists/Filter/admin_lists.filter.schema.php new file mode 100644 index 0000000..eecc07c --- /dev/null +++ b/upload/devcraft/src/modules/UserLists/Filter/admin_lists.filter.schema.php @@ -0,0 +1,35 @@ +}, + * sections: list}>}>, + * } + */ +return [ + 'sort' => [ + 'default' => 'position', + 'columns' => [ + 'id' => '#', + 'name' => __('Название'), + 'position' => __('Порядок'), + ], + ], + 'sections' => [ + [ + 'title' => __('Фильтр'), + 'fields' => [ + [ + 'id' => 'name', + 'type' => 'text', + 'label' => __('Название'), + 'metro' => ['db_column' => 'name'], + ], + ], + ], + ], +]; diff --git a/upload/devcraft/src/modules/UserLists/Filter/user_lists.filter.schema.php b/upload/devcraft/src/modules/UserLists/Filter/user_lists.filter.schema.php new file mode 100644 index 0000000..fe34927 --- /dev/null +++ b/upload/devcraft/src/modules/UserLists/Filter/user_lists.filter.schema.php @@ -0,0 +1,48 @@ +}, + * sections: list}>}>, + * } + */ +return [ + 'sort' => [ + 'default' => 'id', + 'columns' => [ + 'id' => '#', + 'name' => __('Название'), + 'owner_id' => __('Владелец'), + 'visibility' => __('Видимость'), + ], + ], + 'sections' => [ + [ + 'title' => __('Фильтр'), + 'fields' => [ + [ + 'id' => 'name', + 'type' => 'text', + 'label' => __('Название'), + 'metro' => ['db_column' => 'name'], + ], + [ + 'id' => 'owner_id', + 'type' => 'text', + 'label' => __('ID владельца'), + 'metro' => ['db_column' => 'owner_id'], + ], + [ + 'id' => 'visibility', + 'type' => 'text', + 'label' => __('Видимость (private/public)'), + 'metro' => ['db_column' => 'visibility'], + ], + ], + ], + ], +]; diff --git a/upload/devcraft/src/modules/UserLists/Models/GroupPermission.php b/upload/devcraft/src/modules/UserLists/Models/GroupPermission.php new file mode 100644 index 0000000..50af124 --- /dev/null +++ b/upload/devcraft/src/modules/UserLists/Models/GroupPermission.php @@ -0,0 +1,47 @@ +createdAt = new \DateTimeImmutable(); + } + + /** + * @return array + */ + public function values(): array { + $decoded = json_decode($this->settings, true); + + return is_array($decoded) ? $decoded : []; + } + + /** + * @param array $values + */ + public function setValues(array $values): void { + $this->settings = json_encode($values, JSON_UNESCAPED_UNICODE) ?: '{}'; + } + +} diff --git a/upload/devcraft/src/modules/UserLists/Models/UserList.php b/upload/devcraft/src/modules/UserLists/Models/UserList.php new file mode 100644 index 0000000..6ee07bd --- /dev/null +++ b/upload/devcraft/src/modules/UserLists/Models/UserList.php @@ -0,0 +1,70 @@ +createdAt = new \DateTimeImmutable(); + } + + public function isAdmin(): bool { + return $this->type === self::TYPE_ADMIN; + } + + public function isPublic(): bool { + return $this->visibility === self::VIS_PUBLIC; + } + +} diff --git a/upload/devcraft/src/modules/UserLists/Models/UserListItem.php b/upload/devcraft/src/modules/UserLists/Models/UserListItem.php new file mode 100644 index 0000000..4638fb1 --- /dev/null +++ b/upload/devcraft/src/modules/UserLists/Models/UserListItem.php @@ -0,0 +1,42 @@ +createdAt = new \DateTimeImmutable(); + } + +} diff --git a/upload/devcraft/src/modules/UserLists/Models/UserListSuggestor.php b/upload/devcraft/src/modules/UserLists/Models/UserListSuggestor.php new file mode 100644 index 0000000..dbb2ceb --- /dev/null +++ b/upload/devcraft/src/modules/UserLists/Models/UserListSuggestor.php @@ -0,0 +1,30 @@ +createdAt = new \DateTimeImmutable(); + } + +} diff --git a/upload/devcraft/src/modules/UserLists/Pages/AdminListsPage.php b/upload/devcraft/src/modules/UserLists/Pages/AdminListsPage.php new file mode 100644 index 0000000..519cc30 --- /dev/null +++ b/upload/devcraft/src/modules/UserLists/Pages/AdminListsPage.php @@ -0,0 +1,115 @@ +addBreadcrumb(__('Админ-списки')); + + $service = new ListService(); + (new SeedService())->ensureDefaults($service); + + $filterService = new FilterFormService(); + $query = $filterService->parseRequestQuery(); + $schema = $this->loadFilterSchema(); + $order = FilterFormService::normalizeOrder( + (string) ($query['order'] ?? $schema->defaultOrder), + $schema, + ); + $sort = strtoupper((string) ($query['sort'] ?? 'ASC')); + $perPage = FilterFormService::resolveListCount(); + $page = max(1, (int) ($query['page'] ?? 1)); + $rules = $filterService->parseRules($query); + $criteria = $filterService->rulesToCriteria($rules, $schema); + $criteria[] = ['column' => 'type', 'op' => 'in', 'value' => [UserList::TYPE_ADMIN]]; + + /** @var UserListRepository $repository */ + $repository = Application::instance()->database()->repository(UserList::class); + $result = $repository->findFiltered( + $criteria, + $page, + $perPage, + $order, + $sort, + $schema->sortColumnKeys(), + $schema->defaultOrder, + ); + + $rows = []; + + foreach($result['items'] as $item) { + /** @var UserList $item */ + $rows[] = [ + 'id' => $item->id(), + 'name' => $item->name, + 'position' => $item->position, + ]; + } + + $total = (int) $result['total']; + $totalPages = max(1, (int) ceil($total / max(1, $perPage))); + + return [ + 'view' => 'userlists/admin_lists.twig', + 'data' => [ + 'page_title' => __('Админ-списки'), + 'items' => $rows, + 'total' => $total, + 'per_page' => $perPage, + 'current_page' => min($page, $totalPages), + 'page_urls' => $this->buildPageUrls($query, $totalPages), + 'order' => $order, + 'sort' => $sort, + 'filter_rules' => $rules, + 'filter_chips' => $filterService->buildChipViewModel($rules, $schema), + 'filter_catalog' => $filterService->buildCatalogViewModel($schema, $repository), + 'query' => $query, + ], + ]; + } + + /** + * @param array $query + * + * @return array + */ + private function buildPageUrls(array $query, int $totalPages): array { + $urls = []; + + for($page = 1; $page <= $totalPages; $page++) { + $params = array_merge($query, [ + 'mod' => UserListsIdentity::mod(), + 'action' => 'admin_lists', + 'page' => $page, + ]); + $urls[$page] = http_build_query($params); + } + + return $urls; + } + + private function loadFilterSchema(): FilterSchema { + /** @var array $raw */ + $raw = require DLEPlugins::Check(__DIR__ . '/../Filter/admin_lists.filter.schema.php'); + + return FilterSchema::fromArray($raw); + } + +} diff --git a/upload/devcraft/src/modules/UserLists/Pages/ChangelogPage.php b/upload/devcraft/src/modules/UserLists/Pages/ChangelogPage.php new file mode 100644 index 0000000..50be2a5 --- /dev/null +++ b/upload/devcraft/src/modules/UserLists/Pages/ChangelogPage.php @@ -0,0 +1,26 @@ +addBreadcrumb($pageName); + + return [ + 'view' => 'pages/changelog.twig', + 'data' => [ + 'page_title' => $pageName, + ], + ]; + } + +} diff --git a/upload/devcraft/src/modules/UserLists/Pages/DashboardPage.php b/upload/devcraft/src/modules/UserLists/Pages/DashboardPage.php new file mode 100644 index 0000000..5f2a477 --- /dev/null +++ b/upload/devcraft/src/modules/UserLists/Pages/DashboardPage.php @@ -0,0 +1,70 @@ +registry(); + $plugin = $registry->forMod(UserListsIdentity::mod()); + $meta = $plugin?->meta() ?? []; + $context = $this->adminContext(); + $changelog = $plugin?->changelog() ?? []; + $latest = isset($changelog[0]) ? $changelog[0]->toArray() : null; + $mod = $plugin?->mod() ?? UserListsIdentity::mod(); + $menu = []; + + if($latest !== null) { + $latest['teaser_items'] = $changelog[0]->teaserItems(3); + } + + foreach($context->menu() as $link) { + if($link->type !== 'link' || $link->action === null || $link->action === 'dashboard') { + continue; + } + + $menu[] = [ + 'name' => $link->name, + 'link' => $link->link, + 'icon' => $link->extra, + 'action' => $link->action, + ]; + } + + return [ + 'view' => 'pages/dashboard.twig', + 'data' => [ + 'page_title' => (string) ($meta['name'] ?? __('Пользовательские списки')), + 'dashboard' => [ + 'app' => [ + 'name' => (string) ($meta['name'] ?? __('Пользовательские списки')), + 'version' => (string) ($meta['version'] ?? '200.1.0'), + 'description' => (string) ($meta['description'] ?? ''), + 'icon' => (string) ($meta['icon'] ?? ''), + 'docs_link' => (string) ($meta['docsLink'] ?? ''), + 'site_link' => (string) ($meta['siteLink'] ?? ''), + 'site_id' => (int) ($meta['siteId'] ?? 0), + 'code' => (string) ($meta['module_code'] ?? $mod), + ], + 'author' => $context->author()->toArray(), + 'lic_link' => $context->licLink(), + 'menu' => $menu, + 'changelog_latest' => $latest, + 'changelog_url' => '?mod=' . $mod . '&action=changelog', + 'show_assets' => false, + 'show_update' => false, + ], + ], + ]; + } + +} diff --git a/upload/devcraft/src/modules/UserLists/Pages/EditListPage.php b/upload/devcraft/src/modules/UserLists/Pages/EditListPage.php new file mode 100644 index 0000000..3d28ae8 --- /dev/null +++ b/upload/devcraft/src/modules/UserLists/Pages/EditListPage.php @@ -0,0 +1,118 @@ +listsRepo()->findOneById($id); + + $this->addBreadcrumb(__('Списки'), '?mod=user_lists&action=' . ($list?->isAdmin() ? 'admin_lists' : 'user_lists')); + $this->addBreadcrumb(__('Редактирование')); + + if($list === null) { + return [ + 'view' => 'userlists/edit.twig', + 'data' => [ + 'page_title' => __('Список не найден'), + 'list' => null, + 'users' => [], + ], + ]; + } + + $suggestors = []; + $users = []; + + if(!$list->isAdmin()) { + $suggestors = (new ListService())->suggestorsRepo()->userIdsForList($list->id()); + + foreach(DleDataService::users() as $row) { + $uid = (int) ($row['user_id'] ?? 0); + $name = trim((string) ($row['name'] ?? '')); + + if($uid <= 0 || $name === '') { + continue; + } + + $users[] = [ + 'id' => $uid, + 'name' => $name, + ]; + } + } + + $dleHome = rtrim((string) ($config['http_home_url'] ?? '/'), '/') . '/'; + + return [ + 'view' => 'userlists/edit.twig', + 'data' => [ + 'page_title' => __('Редактирование списка'), + 'dle_home' => $dleHome, + 'dle_skin' => (string) ($config['skin'] ?? 'Default'), + 'dle_login_hash' => (string) ($dle_login_hash ?? ''), + 'pm_wysiwyg' => !empty($config['allow_pm_wysiwyg']), + 'pm_editor_script' => !$list->isAdmin() ? $this->buildPmEditorScript() : '', + 'users' => $users, + 'list' => [ + 'id' => $list->id(), + 'name' => $list->name, + 'type' => $list->type, + 'visibility' => $list->visibility, + 'description' => (string) ($list->description ?? ''), + 'allow_suggestions' => $list->allow_suggestions, + 'suggestion_policy' => $list->suggestion_policy, + 'owner_id' => $list->owner_id, + 'suggestor_ids' => array_values(array_map('intval', $suggestors)), + ], + ], + ]; + } + + /** + * Скрипт DLE PM/TinyMCE для textarea.ajaxwysiwygeditor. + */ + private function buildPmEditorScript(): string { + global $config, $lang, $member_id, $user_group, $dle_login_hash, $is_logged, $db, $tpl; + + if(!isset($lang) || !is_array($lang)) { + $lang = []; + } + + if(!isset($lang['language_code'])) { + $lang['language_code'] = $lang['language_code'] ?? 'ru'; + $lang['direction'] = $lang['direction'] ?? 'ltr'; + } + + if(!isset($tpl) || !is_object($tpl)) { + if(!class_exists('dle_template', false)) { + require_once DLEPlugins::Check(ENGINE_DIR . '/classes/templates.class.php'); + } + $tpl = new \dle_template(); + $tpl->smartphone = false; + $tpl->tablet = false; + } + + $is_pm_ajax_mode = true; + $comments_mobile_editor = false; + + /** @noinspection PhpIncludeInspection */ + include DLEPlugins::Check(ENGINE_DIR . '/editor/pm.php'); + + return isset($editor_scrips) ? (string) $editor_scrips : ''; + } + +} diff --git a/upload/devcraft/src/modules/UserLists/Pages/PermissionsPage.php b/upload/devcraft/src/modules/UserLists/Pages/PermissionsPage.php new file mode 100644 index 0000000..a9c8595 --- /dev/null +++ b/upload/devcraft/src/modules/UserLists/Pages/PermissionsPage.php @@ -0,0 +1,66 @@ +addBreadcrumb(__('Права групп')); + + $groups = DleDataService::groupsFull(); + $perms = new PermissionService(); + $defs = PermissionService::defs(); + $tabs = []; + + foreach($groups as $group) { + $gid = (int) ($group['id'] ?? 0); + + if($gid <= 0) { + continue; + } + + $settings = $perms->settingsForGroup($gid); + $items = []; + + foreach($defs as $def) { + $id = $def['id']; + $type = $def['type'] ?? 'bool'; + $value = $settings[$id] ?? ($def['default'] ?? ($type === 'int' ? 5 : false)); + + $items[$id] = [ + 'id' => $id, + 'title' => $def['title'], + 'description' => $def['description'], + 'type' => $type, + 'checked' => $type !== 'int' && !empty($value), + 'value' => $type === 'int' ? (int) $value : (int) !empty($value), + ]; + } + + $tabs[] = [ + 'id' => $gid, + 'name' => (string) ($group['group_name'] ?? ('#' . $gid)), + 'flags' => $items, + ]; + } + + return [ + 'view' => 'userlists/permissions.twig', + 'data' => [ + 'page_title' => __('Права групп'), + 'tabs' => $tabs, + 'defs' => $defs, + ], + ]; + } + +} diff --git a/upload/devcraft/src/modules/UserLists/Pages/SettingsPage.php b/upload/devcraft/src/modules/UserLists/Pages/SettingsPage.php new file mode 100644 index 0000000..c8e8004 --- /dev/null +++ b/upload/devcraft/src/modules/UserLists/Pages/SettingsPage.php @@ -0,0 +1,44 @@ +addBreadcrumb(__('Настройки')); + + $normalizer = new ConfigNormalizer(); + $configFile = Paths::config() . '/user_lists.json'; + + if(!is_file($configFile)) { + DataManager::saveConfig( + UserListsIdentity::code(), + $normalizer->normalize([]), + ); + } + + return [ + 'view' => 'userlists/settings.twig', + 'data' => [ + 'page_title' => __('Настройки'), + ], + ]; + } + + public function supplementFormData(): array { + return []; + } + +} diff --git a/upload/devcraft/src/modules/UserLists/Pages/TemplatesPage.php b/upload/devcraft/src/modules/UserLists/Pages/TemplatesPage.php new file mode 100644 index 0000000..04ce1e9 --- /dev/null +++ b/upload/devcraft/src/modules/UserLists/Pages/TemplatesPage.php @@ -0,0 +1,26 @@ +addBreadcrumb($pageName); + + return [ + 'view' => 'userlists/templates.twig', + 'data' => [ + 'page_title' => $pageName, + ], + ]; + } + +} diff --git a/upload/devcraft/src/modules/UserLists/Pages/UserListsPage.php b/upload/devcraft/src/modules/UserLists/Pages/UserListsPage.php new file mode 100644 index 0000000..cb48c60 --- /dev/null +++ b/upload/devcraft/src/modules/UserLists/Pages/UserListsPage.php @@ -0,0 +1,148 @@ +addBreadcrumb(__('Списки пользователей')); + + $filterService = new FilterFormService(); + $query = $filterService->parseRequestQuery(); + $schema = $this->loadFilterSchema(); + $order = FilterFormService::normalizeOrder( + (string) ($query['order'] ?? $schema->defaultOrder), + $schema, + ); + $sort = strtoupper((string) ($query['sort'] ?? 'DESC')); + $perPage = FilterFormService::resolveListCount(); + $page = max(1, (int) ($query['page'] ?? 1)); + $rules = $filterService->parseRules($query); + $criteria = $filterService->rulesToCriteria($rules, $schema); + $criteria[] = ['column' => 'type', 'op' => 'in', 'value' => [UserList::TYPE_USER]]; + + /** @var UserListRepository $repository */ + $repository = Application::instance()->database()->repository(UserList::class); + $result = $repository->findFiltered( + $criteria, + $page, + $perPage, + $order, + $sort, + $schema->sortColumnKeys(), + $schema->defaultOrder, + ); + + $userIds = []; + + foreach($result['items'] as $item) { + /** @var UserList $item */ + if($item->owner_id !== null && $item->owner_id > 0) { + $userIds[$item->owner_id] = $item->owner_id; + } + } + + $userNames = []; + + foreach($userIds as $uid) { + $row = DleDataService::user(id: $uid); + $name = trim((string) ($row['name'] ?? '')); + $userNames[$uid] = $name !== '' ? $name : ('#' . $uid); + } + + $rows = []; + + foreach($result['items'] as $item) { + /** @var UserList $item */ + $oid = (int) ($item->owner_id ?? 0); + $rows[] = [ + 'id' => $item->id(), + 'name' => $item->name, + 'owner_id' => $oid, + 'owner_name' => $userNames[$oid] ?? ('#' . $oid), + 'visibility' => $item->visibility, + ]; + } + + $total = (int) $result['total']; + $totalPages = max(1, (int) ceil($total / max(1, $perPage))); + + $ownerOptions = []; + + foreach(DleDataService::users() as $userRow) { + $uid = (int) ($userRow['user_id'] ?? 0); + + if($uid <= 0) { + continue; + } + + $uname = trim((string) ($userRow['name'] ?? '')); + $ownerOptions[] = [ + 'id' => $uid, + 'name' => $uname !== '' ? $uname : ('#' . $uid), + ]; + } + + return [ + 'view' => 'userlists/user_lists.twig', + 'data' => [ + 'page_title' => __('Списки пользователей'), + 'items' => $rows, + 'total' => $total, + 'per_page' => $perPage, + 'current_page' => min($page, $totalPages), + 'page_urls' => $this->buildPageUrls($query, $totalPages), + 'order' => $order, + 'sort' => $sort, + 'filter_rules' => $rules, + 'filter_chips' => $filterService->buildChipViewModel($rules, $schema), + 'filter_catalog' => $filterService->buildCatalogViewModel($schema, $repository), + 'query' => $query, + 'owner_options' => $ownerOptions, + ], + ]; + } + + /** + * @param array $query + * + * @return array + */ + private function buildPageUrls(array $query, int $totalPages): array { + $urls = []; + + for($page = 1; $page <= $totalPages; $page++) { + $params = array_merge($query, [ + 'mod' => UserListsIdentity::mod(), + 'action' => 'user_lists', + 'page' => $page, + ]); + $urls[$page] = http_build_query($params); + } + + return $urls; + } + + private function loadFilterSchema(): FilterSchema { + /** @var array $raw */ + $raw = require DLEPlugins::Check(__DIR__ . '/../Filter/user_lists.filter.schema.php'); + + return FilterSchema::fromArray($raw); + } + +} diff --git a/upload/devcraft/src/modules/UserLists/Public/.htaccess b/upload/devcraft/src/modules/UserLists/Public/.htaccess new file mode 100644 index 0000000..2ab077b --- /dev/null +++ b/upload/devcraft/src/modules/UserLists/Public/.htaccess @@ -0,0 +1,7 @@ + + Require all granted + + + Order deny,allow + Allow from all + diff --git a/upload/devcraft/src/modules/UserLists/Public/user_lists.js b/upload/devcraft/src/modules/UserLists/Public/user_lists.js new file mode 100644 index 0000000..60df31b --- /dev/null +++ b/upload/devcraft/src/modules/UserLists/Public/user_lists.js @@ -0,0 +1,600 @@ +(function (window) { + 'use strict'; + + if (!window.DevCraft) { + console.error('[UserLists] Сначала должен быть загружен DevCraft core.'); + return; + } + + const Ajax = window.DevCraft.Ajax; + const Metro = window.DevCraft.Metro; + + function t(key) { + return window.__ ? window.__(key) : key; + } + + function post(method, data) { + return Ajax.post(method, data || {}).then(function (payload) { + if (Ajax.handleNotice) { + Ajax.handleNotice(payload); + } + return payload; + }); + } + + /** + * POST без полноэкранного лоадера (DnD и быстрые действия). + */ + function postSilent(method, data) { + const params = { controller: 'admin', method: method }; + const mod = document.body.dataset.mod; + + if (mod) { + params.mod = mod; + } + + const url = Ajax.url(Ajax.baseUrl(), params); + const body = new URLSearchParams({ + user_hash: Ajax.getUserHash(), + data: JSON.stringify(data || {}), + }).toString(); + + return fetch(url, { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: body, + }).then(Ajax.parseResponse).then(function (payload) { + if (Ajax.handleNotice) { + Ajax.handleNotice(payload); + } + return payload; + }); + } + + function escapeHtml(value) { + return String(value) + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"'); + } + + function noticeOk(title) { + if (Metro && typeof Metro.toast === 'function') { + Metro.toast(title || t('Готово')); + return; + } + if (Metro && typeof Metro.notify === 'function') { + Metro.notify(title || t('Готово')); + } + } + + /** + * @returns {Promise} + */ + function askAdminListName() { + return new Promise(function (resolve) { + var pending; + var settled = false; + + function done(value) { + if (settled) { + return; + } + settled = true; + resolve(value); + } + + if (!Metro || typeof Metro.dialogCreate !== 'function') { + var fallback = window.prompt(t('Название админ-списка'), ''); + done(fallback === null ? null : String(fallback).trim()); + return; + } + + Metro.dialogCreate({ + title: t('Создать админ-список'), + content: '' + + '', + closeButton: true, + defaultActions: false, + onClose: function () { + done(pending === undefined ? null : pending); + }, + customButtons: [ + { + text: t('Сохранить'), + cls: 'primary js-dialog-close', + onclick: function () { + var el = document.getElementById('dc-ul-admin-name'); + pending = el ? String(el.value).trim() : ''; + done(pending || null); + }, + }, + { + text: t('Отмена'), + cls: 'js-dialog-close', + onclick: function () { + pending = null; + done(null); + }, + }, + ], + }); + }); + } + + /** + * @param {Array<{id:number,name:string}>} owners + * @returns {Promise<{owner_id:number,name:string}|null>} + */ + function askUserListCreate(owners) { + return new Promise(function (resolve) { + var pending; + var settled = false; + + function done(value) { + if (settled) { + return; + } + settled = true; + resolve(value); + } + + var optionsHtml = (owners || []).map(function (u) { + return ''; + }).join(''); + + if (!Metro || typeof Metro.dialogCreate !== 'function') { + var ownerId = owners && owners[0] ? owners[0].id : 0; + var name = window.prompt(t('Название списка'), ''); + done(name === null || !String(name).trim() + ? null + : { owner_id: ownerId, name: String(name).trim() }); + return; + } + + Metro.dialogCreate({ + title: t('Создать список пользователя'), + content: '' + + '' + + '' + + '' + + '

' + + escapeHtml(t('Список создаётся приватным. Лимит группы для админа не действует.')) + + '

', + closeButton: true, + defaultActions: false, + onClose: function () { + done(pending === undefined ? null : pending); + }, + customButtons: [ + { + text: t('Сохранить'), + cls: 'primary js-dialog-close', + onclick: function () { + var sel = document.getElementById('dc-ul-user-owner'); + var nameEl = document.getElementById('dc-ul-user-name'); + var ownerId = sel ? parseInt(sel.value, 10) || 0 : 0; + var name = nameEl ? String(nameEl.value).trim() : ''; + pending = ownerId > 0 && name ? { owner_id: ownerId, name: name } : null; + done(pending); + }, + }, + { + text: t('Отмена'), + cls: 'js-dialog-close', + onclick: function () { + pending = null; + done(null); + }, + }, + ], + }); + }); + } + + /** + * @returns {Promise} + */ + function confirmDelete() { + return new Promise(function (resolve) { + var pending; + var settled = false; + + function done(value) { + if (settled) { + return; + } + settled = true; + resolve(value); + } + + if (!Metro || typeof Metro.dialogCreate !== 'function') { + done(window.confirm(t('Удалить список?'))); + return; + } + + Metro.dialogCreate({ + title: t('Удалить список?'), + content: '

' + escapeHtml(t('Действие необратимо.')) + '

', + closeButton: true, + defaultActions: false, + onClose: function () { + done(pending === true); + }, + customButtons: [ + { + text: t('Удалить'), + cls: 'alert js-dialog-close', + onclick: function () { + pending = true; + done(true); + }, + }, + { + text: t('Отмена'), + cls: 'js-dialog-close', + onclick: function () { + pending = false; + done(false); + }, + }, + ], + }); + }); + } + + function collectRowIds(tbody) { + return Array.from(tbody.querySelectorAll('tr[data-id]')).map(function (tr) { + return parseInt(tr.getAttribute('data-id'), 10); + }).filter(Boolean); + } + + function flagKeyFromName(name) { + var m = String(name || '').match(/^groups\[\d+]\[(.+)]$/); + if (m) { + return m[1]; + } + m = String(name || '').match(/^flags\[(.+)]$/); + return m ? m[1] : null; + } + + function collectPermFlags(scope) { + var flags = {}; + + scope.querySelectorAll('input[type="checkbox"]').forEach(function (cb) { + var key = flagKeyFromName(cb.getAttribute('name')); + if (key) { + flags[key] = cb.checked ? 1 : 0; + } + }); + + scope.querySelectorAll('input[type="number"]').forEach(function (inp) { + var key = flagKeyFromName(inp.getAttribute('name')); + if (key) { + var n = parseInt(inp.value, 10); + flags[key] = Number.isFinite(n) ? n : 0; + } + }); + + return flags; + } + + /** Собирает настройки всех вкладок групп. */ + function collectAllGroupPermissions(root) { + var groups = {}; + var scope = root.closest('.dc-ul-permissions') || root; + scope.querySelectorAll('.dc-ul-perm-panel[data-group-id]').forEach(function (panel) { + var groupId = parseInt(panel.getAttribute('data-group-id'), 10) || 0; + if (groupId > 0) { + groups[String(groupId)] = collectPermFlags(panel); + } + }); + return groups; + } + + function collectSuggestorIds(form) { + var sel = form.querySelector('[name="suggestor_ids[]"], #ul-edit-suggestors'); + if (!sel) { + return []; + } + return Array.from(sel.selectedOptions || []).map(function (opt) { + return parseInt(opt.value, 10); + }).filter(Boolean); + } + + function bindTableDnD(table) { + if (!table || table.dataset.ulDndBound) { + return; + } + + table.dataset.ulDndBound = '1'; + var scope = table.getAttribute('data-ul-dnd-scope') || 'user'; + var tbody = table.tBodies[0]; + var dragRow = null; + var reorderTimer = null; + + if (!tbody) { + return; + } + + /** Оптимистичный UI уже обновлён — без полноэкранного лоадера. */ + function scheduleReorderSave() { + if (reorderTimer) { + clearTimeout(reorderTimer); + } + reorderTimer = setTimeout(function () { + reorderTimer = null; + var ids = collectRowIds(tbody); + var debugOn = window.DevCraft && DevCraft.Debug && DevCraft.Debug.isEnabled(); + var t0 = debugOn ? performance.now() : 0; + + if (debugOn) { + DevCraft.Debug.log('UserLists', 'reorder_lists → start', { scope: scope, ids: ids.length }); + } + + postSilent('reorder_lists', { + scope: scope, + ids: ids, + _admin: 1, + }).then(function (payload) { + if (!debugOn) { + return; + } + var rtt = Math.round((performance.now() - t0) * 100) / 100; + var data = payload && payload.data ? payload.data : {}; + var timing = data.timing || null; + DevCraft.Debug.log('UserLists', 'reorder_lists ← done', { + rtt_ms: rtt, + handler_ms: timing ? timing.handler_ms : null, + pipeline_ms: data.pipeline_ms || null, + scope: timing ? timing.scope : scope, + ids: timing ? timing.ids : ids.length, + success: !(payload && payload.success === false), + }); + }).catch(function (err) { + if (!debugOn) { + return; + } + var rtt = Math.round((performance.now() - t0) * 100) / 100; + DevCraft.Debug.log('UserLists', 'reorder_lists ← fail', { rtt_ms: rtt, err: err }); + }); + }, 120); + } + + tbody.addEventListener('dragstart', function (event) { + var row = event.target.closest('tr.ul-dnd-row'); + if (!row || !tbody.contains(row)) { + return; + } + dragRow = row; + row.classList.add('ul-dnd-dragging'); + if (event.dataTransfer) { + event.dataTransfer.effectAllowed = 'move'; + event.dataTransfer.setData('text/plain', row.getAttribute('data-id') || ''); + } + }); + + tbody.addEventListener('dragend', function () { + if (dragRow) { + dragRow.classList.remove('ul-dnd-dragging'); + } + dragRow = null; + tbody.querySelectorAll('.ul-dnd-over').forEach(function (el) { + el.classList.remove('ul-dnd-over'); + }); + }); + + tbody.addEventListener('dragover', function (event) { + event.preventDefault(); + var row = event.target.closest('tr.ul-dnd-row'); + if (!row || row === dragRow) { + return; + } + tbody.querySelectorAll('.ul-dnd-over').forEach(function (el) { + el.classList.remove('ul-dnd-over'); + }); + row.classList.add('ul-dnd-over'); + if (event.dataTransfer) { + event.dataTransfer.dropEffect = 'move'; + } + }); + + tbody.addEventListener('drop', function (event) { + event.preventDefault(); + var target = event.target.closest('tr.ul-dnd-row'); + if (!dragRow || !target || dragRow === target) { + return; + } + + var rows = Array.from(tbody.querySelectorAll('tr.ul-dnd-row')); + var from = rows.indexOf(dragRow); + var to = rows.indexOf(target); + if (from < 0 || to < 0) { + return; + } + + if (from < to) { + tbody.insertBefore(dragRow, target.nextSibling); + } else { + tbody.insertBefore(dragRow, target); + } + + scheduleReorderSave(); + }); + } + + function goEdit(id) { + var mod = document.body.dataset.mod || 'user_lists'; + window.location.href = '?mod=' + encodeURIComponent(mod) + '&action=edit&id=' + encodeURIComponent(String(id)); + } + + document.addEventListener('DOMContentLoaded', function () { + document.querySelectorAll('table[data-ul-dnd-scope]').forEach(bindTableDnD); + }); + + document.addEventListener('click', function (event) { + var copyBtn = event.target.closest('.js-ul-copy-code'); + if (copyBtn) { + event.preventDefault(); + var wrap = copyBtn.closest('.remark, .d-flex, div'); + var source = wrap ? wrap.querySelector('.js-ul-copy-source') : null; + var text = source ? String(source.textContent || '').trim() : ''; + if (!text) { + return; + } + + var okMsg = t('Успешно скопировано в буфер обмена'); + + function copyViaTextarea() { + var ta = document.createElement('textarea'); + ta.value = text; + ta.setAttribute('readonly', ''); + ta.style.position = 'fixed'; + ta.style.left = '-9999px'; + document.body.appendChild(ta); + ta.select(); + var ok = false; + try { + ok = document.execCommand('copy'); + } catch (e) { + ok = false; + } + document.body.removeChild(ta); + return ok; + } + + function doneOk() { + noticeOk(okMsg); + } + + if (navigator.clipboard && typeof navigator.clipboard.writeText === 'function') { + navigator.clipboard.writeText(text).then(doneOk).catch(function () { + if (copyViaTextarea()) { + doneOk(); + } + }); + } else if (copyViaTextarea()) { + doneOk(); + } + return; + } + + var createAdmin = event.target.closest('.js-ul-create-admin'); + if (createAdmin) { + event.preventDefault(); + askAdminListName().then(function (name) { + if (!name) { + return; + } + return post('save_list', { type: 'admin', name: name, _admin: 1 }).then(function (payload) { + var id = payload && payload.data && payload.data.id; + if (payload && payload.success !== false && id) { + goEdit(id); + } + }); + }); + return; + } + + var createUser = event.target.closest('.js-ul-create-user'); + if (createUser) { + event.preventDefault(); + var owners = []; + try { + owners = JSON.parse(createUser.getAttribute('data-owners') || '[]'); + } catch (e) { + owners = []; + } + if (!owners.length) { + window.alert(t('Нет пользователей для выбора')); + return; + } + askUserListCreate(owners).then(function (data) { + if (!data) { + return; + } + return post('save_list', { + type: 'user', + owner_id: data.owner_id, + name: data.name, + _admin: 1, + }).then(function (payload) { + var id = payload && payload.data && payload.data.id; + if (payload && payload.success !== false && id) { + goEdit(id); + } + }); + }); + return; + } + + var delBtn = event.target.closest('.js-ul-delete'); + if (delBtn) { + event.preventDefault(); + var id = parseInt(delBtn.getAttribute('data-id'), 10) || 0; + if (!id) { + return; + } + confirmDelete().then(function (ok) { + if (!ok) { + return; + } + return post('delete_list', { id: id, _admin: 1 }).then(function (payload) { + if (payload && payload.success === false) { + return; + } + var row = document.querySelector('tr[data-id="' + id + '"]'); + if (row) { + row.remove(); + } + }); + }); + } + }); + + document.addEventListener('submit', function (event) { + var form = event.target.closest('#ul-edit-form'); + if (form) { + event.preventDefault(); + if (typeof tinymce !== 'undefined' && tinymce.triggerSave) { + tinymce.triggerSave(); + } + var data = { + id: parseInt(form.querySelector('[name=id]').value, 10) || 0, + _admin: 1, + name: form.querySelector('[name=name]').value, + }; + var vis = form.querySelector('[name=visibility]'); + if (vis) { + data.visibility = vis.value; + } + var desc = form.querySelector('[name=description]'); + if (desc) { + data.description = desc.value; + } + var allow = form.querySelector('[name=allow_suggestions]'); + if (allow) { + data.allow_suggestions = allow.checked ? 1 : 0; + } + var policy = form.querySelector('[name=suggestion_policy]'); + if (policy) { + data.suggestion_policy = policy.value; + } + if (form.querySelector('#ul-edit-suggestors, [name="suggestor_ids[]"]')) { + data.suggestor_ids = collectSuggestorIds(form); + } + post('save_list', data); + return; + } + + var permAll = event.target.closest('#dc-ul-perm-form-all, .dc-ul-perm-form-all'); + if (permAll) { + event.preventDefault(); + var groups = collectAllGroupPermissions(permAll); + post('permissions', { groups: groups }); + } + }); +})(window); diff --git a/upload/devcraft/src/modules/UserLists/Repositories/GroupPermissionRepository.php b/upload/devcraft/src/modules/UserLists/Repositories/GroupPermissionRepository.php new file mode 100644 index 0000000..8159bc5 --- /dev/null +++ b/upload/devcraft/src/modules/UserLists/Repositories/GroupPermissionRepository.php @@ -0,0 +1,39 @@ +select()->where('group_id', $groupId)->fetchOne(); + + return $entity; + } + + /** + * @param array $values + */ + public function upsert(int $groupId, array $values): GroupPermission { + $entity = $this->findByGroupId($groupId); + + if($entity === null) { + $entity = new GroupPermission(); + $entity->group_id = $groupId; + } + + $entity->setValues($values); + $this->saveEntity($entity); + + return $entity; + } + +} diff --git a/upload/devcraft/src/modules/UserLists/Repositories/UserListItemRepository.php b/upload/devcraft/src/modules/UserLists/Repositories/UserListItemRepository.php new file mode 100644 index 0000000..3e549e7 --- /dev/null +++ b/upload/devcraft/src/modules/UserLists/Repositories/UserListItemRepository.php @@ -0,0 +1,104 @@ +select()->where('id', $id)->fetchOne(); + + return $entity; + } + + public function findMembership(int $listId, int $userId, int $newsId): ?UserListItem { + /** @var UserListItem|null $entity */ + $entity = $this->select() + ->where('list_id', $listId) + ->where('user_id', $userId) + ->where('news_id', $newsId) + ->fetchOne(); + + return $entity; + } + + /** + * @return list + */ + public function findApprovedForUserList(int $listId, int $userId): array { + /** @var list $items */ + $items = $this->select() + ->where('list_id', $listId) + ->where('user_id', $userId) + ->where('status', UserListItem::STATUS_APPROVED) + ->orderBy('id', 'DESC') + ->fetchAll(); + + return $items; + } + + /** + * Одобренные элементы публичного списка владельца (user_id владельца или любой approved на list). + * + * @return list + */ + public function findApprovedOnList(int $listId, int $page, int $perPage): array { + /** @var list $items */ + $items = $this->select() + ->where('list_id', $listId) + ->where('status', UserListItem::STATUS_APPROVED) + ->orderBy('id', 'DESC') + ->limit($perPage) + ->offset(max(0, ($page - 1) * $perPage)) + ->fetchAll(); + + return $items; + } + + /** + * @return list + */ + public function findPendingForOwnerLists(array $listIds): array { + if($listIds === []) { + return []; + } + + /** @var list $items */ + $items = $this->select() + ->where('list_id', 'in', array_values($listIds)) + ->where('status', UserListItem::STATUS_PENDING) + ->orderBy('id', 'DESC') + ->fetchAll(); + + return $items; + } + + /** + * @return list + */ + public function newsIdsInListsForUser(int $userId, int $newsId): array { + /** @var list $items */ + $items = $this->select() + ->where('user_id', $userId) + ->where('news_id', $newsId) + ->where('status', UserListItem::STATUS_APPROVED) + ->fetchAll(); + + $ids = []; + + foreach($items as $item) { + $ids[] = $item->list_id; + } + + return $ids; + } + +} diff --git a/upload/devcraft/src/modules/UserLists/Repositories/UserListRepository.php b/upload/devcraft/src/modules/UserLists/Repositories/UserListRepository.php new file mode 100644 index 0000000..10d3100 --- /dev/null +++ b/upload/devcraft/src/modules/UserLists/Repositories/UserListRepository.php @@ -0,0 +1,91 @@ +select()->where('id', $id)->fetchOne(); + + return $entity; + } + + /** + * @return list + */ + public function findAdminLists(): array { + /** @var list $items */ + $items = $this->select() + ->where('type', UserList::TYPE_ADMIN) + ->orderBy('position', 'ASC') + ->orderBy('id', 'ASC') + ->fetchAll(); + + return $items; + } + + /** + * @return list + */ + public function findOwnedBy(int $ownerId): array { + /** @var list $items */ + $items = $this->select() + ->where('type', UserList::TYPE_USER) + ->where('owner_id', $ownerId) + ->orderBy('position', 'ASC') + ->orderBy('id', 'ASC') + ->fetchAll(); + + return $items; + } + + public function countOwnedBy(int $ownerId): int { + return (int) $this->select() + ->where('type', UserList::TYPE_USER) + ->where('owner_id', $ownerId) + ->count(); + } + + public function findAdminByName(string $name): ?UserList { + /** @var UserList|null $entity */ + $entity = $this->select() + ->where('type', UserList::TYPE_ADMIN) + ->where('name', $name) + ->fetchOne(); + + return $entity; + } + + /** + * @return list + */ + public function findPublic(int $page, int $perPage): array { + /** @var list $items */ + $items = $this->select() + ->where('type', UserList::TYPE_USER) + ->where('visibility', UserList::VIS_PUBLIC) + ->orderBy('id', 'DESC') + ->limit($perPage) + ->offset(max(0, ($page - 1) * $perPage)) + ->fetchAll(); + + return $items; + } + + public function countPublic(): int { + return (int) $this->select() + ->where('type', UserList::TYPE_USER) + ->where('visibility', UserList::VIS_PUBLIC) + ->count(); + } + +} diff --git a/upload/devcraft/src/modules/UserLists/Repositories/UserListSuggestorRepository.php b/upload/devcraft/src/modules/UserLists/Repositories/UserListSuggestorRepository.php new file mode 100644 index 0000000..5003d07 --- /dev/null +++ b/upload/devcraft/src/modules/UserLists/Repositories/UserListSuggestorRepository.php @@ -0,0 +1,59 @@ +select() + ->where('list_id', $listId) + ->where('user_id', $userId) + ->fetchOne(); + + return $entity !== null; + } + + /** + * @return list + */ + public function userIdsForList(int $listId): array { + /** @var list $items */ + $items = $this->select()->where('list_id', $listId)->fetchAll(); + $ids = []; + + foreach($items as $item) { + $ids[] = $item->user_id; + } + + return $ids; + } + + public function replaceForList(int $listId, array $userIds): void { + /** @var list $existing */ + $existing = $this->select()->where('list_id', $listId)->fetchAll(); + + foreach($existing as $row) { + $this->deleteEntity($row); + } + + foreach(array_unique(array_map('intval', $userIds)) as $uid) { + if($uid <= 0) { + continue; + } + + $entity = new UserListSuggestor(); + $entity->list_id = $listId; + $entity->user_id = $uid; + $this->saveEntity($entity); + } + } + +} diff --git a/upload/devcraft/src/modules/UserLists/Services/AuditLogger.php b/upload/devcraft/src/modules/UserLists/Services/AuditLogger.php new file mode 100644 index 0000000..70d7a92 --- /dev/null +++ b/upload/devcraft/src/modules/UserLists/Services/AuditLogger.php @@ -0,0 +1,22 @@ +log($message . $suffix, 'info'); + } + +} diff --git a/upload/devcraft/src/modules/UserLists/Services/ConfigNormalizer.php b/upload/devcraft/src/modules/UserLists/Services/ConfigNormalizer.php new file mode 100644 index 0000000..41db636 --- /dev/null +++ b/upload/devcraft/src/modules/UserLists/Services/ConfigNormalizer.php @@ -0,0 +1,46 @@ + $config + * + * @return array + */ + public function normalize(array $config): array { + $config = array_merge($this->defaults(), $config); + + $config['guest_can_view_public'] = !empty($config['guest_can_view_public']); + $config['button_label'] = trim((string) ($config['button_label'] ?? __('В списки'))); + $config['count_web'] = max(1, (int) ($config['count_web'] ?? 20)); + $config['count_admin'] = max(1, (int) ($config['count_admin'] ?? 50)); + $config['bad_words'] = trim((string) ($config['bad_words'] ?? '')); + $config['name_min'] = max(1, (int) ($config['name_min'] ?? 2)); + $config['name_max'] = max($config['name_min'], (int) ($config['name_max'] ?? 100)); + + return $config; + } + + /** + * @return array + */ + public function defaults(): array { + return [ + 'guest_can_view_public' => false, + 'button_label' => __('В списки'), + 'count_web' => 20, + 'count_admin' => 50, + 'bad_words' => '', + 'name_min' => 2, + 'name_max' => 100, + ]; + } + +} diff --git a/upload/devcraft/src/modules/UserLists/Services/ListService.php b/upload/devcraft/src/modules/UserLists/Services/ListService.php new file mode 100644 index 0000000..b2ff538 --- /dev/null +++ b/upload/devcraft/src/modules/UserLists/Services/ListService.php @@ -0,0 +1,492 @@ + + */ + public function config(): array { + return $this->configNormalizer->normalize( + DataManager::getConfig(UserListsIdentity::code()), + ); + } + + public function listsRepo(): UserListRepository { + /** @var UserListRepository $repo */ + $repo = Application::instance()->database()->repository(UserList::class); + + return $repo; + } + + public function itemsRepo(): UserListItemRepository { + /** @var UserListItemRepository $repo */ + $repo = Application::instance()->database()->repository(UserListItem::class); + + return $repo; + } + + public function suggestorsRepo(): UserListSuggestorRepository { + /** @var UserListSuggestorRepository $repo */ + $repo = Application::instance()->database()->repository( + \DevCraft\Modules\UserLists\Models\UserListSuggestor::class, + ); + + return $repo; + } + + public function validateName(string $name): string { + $cfg = $this->config(); + $name = trim($name); + $min = (int) $cfg['name_min']; + $max = (int) $cfg['name_max']; + $len = mb_strlen($name); + + if($len < $min || $len > $max) { + throw new RuntimeException( + __('Название должно быть от {min} до {max} символов', [ + '{min}' => (string) $min, + '{max}' => (string) $max, + ]), + ); + } + + $bad = array_filter(array_map('trim', explode(',', (string) $cfg['bad_words']))); + + foreach($bad as $word) { + if($word !== '' && mb_stripos($name, $word) !== false) { + throw new RuntimeException(__('В названии есть запрещённое слово')); + } + } + + return $name; + } + + public function createAdminList(string $name, int $actorId): UserList { + $name = $this->validateName($name); + + if($this->listsRepo()->findAdminByName($name) !== null) { + throw new RuntimeException(__('Админ-список с таким именем уже есть')); + } + + $list = new UserList(); + $list->name = $name; + $list->type = UserList::TYPE_ADMIN; + $list->owner_id = null; + $list->visibility = UserList::VIS_PRIVATE; + $list->position = $this->nextAdminPosition(); + $this->listsRepo()->saveEntity($list); + + AuditLogger::info(__('Создан админ-список'), [ + 'list_id' => $list->id(), + 'name' => $name, + 'actor' => $actorId, + ]); + + return $list; + } + + public function createUserList( + int $ownerId, + string $name, + string $visibility = UserList::VIS_PRIVATE, + string $description = '', + bool $asAdmin = false, + ): UserList { + if(!$this->permissions->isEnabled($ownerId)) { + throw new RuntimeException(__('Модуль недоступен для вашей группы')); + } + + $max = $this->permissions->maxLists($ownerId); + $cnt = $this->listsRepo()->countOwnedBy($ownerId); + + if(!$asAdmin && $cnt >= $max) { + throw new RuntimeException(__('Достигнут лимит списков для вашей группы ({max})', [ + '{max}' => (string) $max, + ])); + } + + $name = $this->validateName($name); + + if($visibility === UserList::VIS_PUBLIC && !$this->permissions->canPublic($ownerId)) { + $visibility = UserList::VIS_PRIVATE; + } + + $list = new UserList(); + $list->name = $name; + $list->type = UserList::TYPE_USER; + $list->owner_id = $ownerId; + $list->visibility = $visibility === UserList::VIS_PUBLIC + ? UserList::VIS_PUBLIC + : UserList::VIS_PRIVATE; + $list->description = $description; + $list->position = $this->nextUserPosition($ownerId); + $this->listsRepo()->saveEntity($list); + + AuditLogger::info(__('Создан пользовательский список'), [ + 'list_id' => $list->id(), + 'owner' => $ownerId, + 'name' => $name, + 'as_admin' => $asAdmin, + 'limit_bypass'=> $asAdmin && $cnt >= $max, + ]); + + return $list; + } + + public function updateList(UserList $list, array $data, int $actorId, bool $asAdmin = false): UserList { + if(!$asAdmin && $list->isAdmin()) { + throw new RuntimeException(__('Админ-список нельзя изменить с сайта')); + } + + if(!$asAdmin && (int) $list->owner_id !== $actorId) { + throw new RuntimeException(__('Нет прав на изменение списка')); + } + + if(isset($data['name'])) { + $list->name = $this->validateName((string) $data['name']); + } + + if(array_key_exists('description', $data)) { + $list->description = (string) $data['description']; + } + + if(isset($data['visibility']) && !$list->isAdmin()) { + $vis = (string) $data['visibility']; + + if($vis === UserList::VIS_PUBLIC) { + $owner = (int) ($list->owner_id ?? 0); + + if(!$asAdmin && !$this->permissions->canPublic($owner > 0 ? $owner : $actorId)) { + throw new RuntimeException(__('Публичные списки запрещены для группы')); + } + + $list->visibility = UserList::VIS_PUBLIC; + } else { + $list->visibility = UserList::VIS_PRIVATE; + } + } + + if(isset($data['allow_suggestions'])) { + $list->allow_suggestions = !empty($data['allow_suggestions']); + } + + if(isset($data['suggestion_policy'])) { + $policy = (string) $data['suggestion_policy']; + $list->suggestion_policy = $policy === UserList::SUGGEST_EVERYONE + ? UserList::SUGGEST_EVERYONE + : UserList::SUGGEST_WHITELIST; + } + + $this->listsRepo()->saveEntity($list); + + AuditLogger::info(__('Изменён список'), [ + 'list_id' => $list->id(), + 'actor' => $actorId, + 'data' => array_keys($data), + ]); + + return $list; + } + + public function deleteList(UserList $list, int $actorId, bool $asAdmin = false): void { + if($list->isAdmin() && !$asAdmin) { + throw new RuntimeException(__('Админ-список нельзя удалить')); + } + + if(!$asAdmin && (int) $list->owner_id !== $actorId) { + throw new RuntimeException(__('Нет прав на удаление списка')); + } + + $listId = $list->id(); + $this->listsRepo()->deleteEntity($list); + + AuditLogger::info(__('Удалён список'), [ + 'list_id' => $listId, + 'actor' => $actorId, + 'admin' => $asAdmin, + ]); + } + + /** + * Порядок админ-списков (owner_id = null) — один UPDATE. + * + * @param list $orderedIds + */ + public function reorderAdminLists(array $orderedIds): void { + $this->applyPositionOrder($orderedIds, UserList::TYPE_ADMIN, null); + } + + /** + * @param list $orderedIds + */ + public function reorderUserLists(int $ownerId, array $orderedIds): void { + $this->applyPositionOrder($orderedIds, UserList::TYPE_USER, $ownerId); + } + + /** + * Порядок user-списков из админки: position внутри каждой группы owner_id. + * + * @param list $orderedIds + */ + public function reorderListedUserLists(array $orderedIds): void { + $byOwner = []; + + foreach($orderedIds as $id) { + $list = $this->listsRepo()->findOneById((int) $id); + + if($list === null || $list->isAdmin() || $list->owner_id === null) { + continue; + } + + $byOwner[(int) $list->owner_id][] = $list->id(); + } + + foreach($byOwner as $ownerId => $ids) { + $this->reorderUserLists((int) $ownerId, $ids); + } + } + + /** + * @param list $orderedIds + */ + private function applyPositionOrder(array $orderedIds, string $type, ?int $ownerId): void { + $ids = []; + $cases = []; + $pos = 0; + + foreach($orderedIds as $rawId) { + $id = (int) $rawId; + + if($id <= 0) { + continue; + } + + $ids[] = $id; + $cases[] = 'WHEN ' . $id . ' THEN ' . $pos; + $pos++; + } + + if($ids === []) { + return; + } + + $table = PREFIX . '_dc_user_lists'; + $sql = 'UPDATE `' . $table . '` SET `position` = CASE `id` ' + . implode(' ', $cases) + . ' END WHERE `type` = :type AND `id` IN (' . implode(',', $ids) . ')'; + + $params = ['type' => $type]; + + if($ownerId === null) { + $sql .= ' AND `owner_id` IS NULL'; + } else { + $sql .= ' AND `owner_id` = :owner_id'; + $params['owner_id'] = $ownerId; + } + + Application::instance()->database()->connection()->execute($sql, $params); + } + + public function toggleNews(int $listId, int $userId, int $newsId): array { + if(!$this->permissions->isEnabled($userId)) { + throw new RuntimeException(__('Модуль недоступен для вашей группы')); + } + + $list = $this->listsRepo()->findOneById($listId); + + if($list === null) { + throw new RuntimeException(__('Список не найден')); + } + + if($list->isAdmin()) { + $ownerKey = $userId; + } elseif((int) $list->owner_id === $userId) { + $ownerKey = $userId; + } else { + throw new RuntimeException(__('Нельзя менять чужой список напрямую')); + } + + $existing = $this->itemsRepo()->findMembership($listId, $ownerKey, $newsId); + + if($existing !== null && $existing->status === UserListItem::STATUS_APPROVED) { + $this->itemsRepo()->deleteEntity($existing); + + return ['in_list' => false, 'list_id' => $listId]; + } + + if($existing !== null) { + $existing->status = UserListItem::STATUS_APPROVED; + $this->itemsRepo()->saveEntity($existing); + } else { + $item = new UserListItem(); + $item->list_id = $listId; + $item->user_id = $ownerKey; + $item->news_id = $newsId; + $item->status = UserListItem::STATUS_APPROVED; + $this->itemsRepo()->saveEntity($item); + } + + return ['in_list' => true, 'list_id' => $listId]; + } + + public function suggestNews(int $listId, int $fromUserId, int $newsId): UserListItem { + if(!$this->permissions->isEnabled($fromUserId) || !$this->permissions->canSuggest($fromUserId)) { + throw new RuntimeException(__('Предложения недоступны для вашей группы')); + } + + $list = $this->listsRepo()->findOneById($listId); + + if($list === null || $list->isAdmin() || !$list->isPublic()) { + throw new RuntimeException(__('В этот список нельзя предлагать')); + } + + if(!$list->allow_suggestions) { + throw new RuntimeException(__('Владелец запретил предложения')); + } + + if((int) $list->owner_id === $fromUserId) { + throw new RuntimeException(__('Добавляйте новости в свой список через обычное меню')); + } + + if($list->suggestion_policy === UserList::SUGGEST_WHITELIST + && !$this->suggestorsRepo()->isAllowed($listId, $fromUserId)) { + throw new RuntimeException(__('Вы не в белом списке предлагающих')); + } + + $ownerId = (int) $list->owner_id; + $existing = $this->itemsRepo()->findMembership($listId, $fromUserId, $newsId); + + if($existing !== null) { + return $existing; + } + + // Уникальный ключ list+user+news — предложение от fromUserId + $item = new UserListItem(); + $item->list_id = $listId; + $item->user_id = $fromUserId; + $item->news_id = $newsId; + $item->status = UserListItem::STATUS_PENDING; + $this->itemsRepo()->saveEntity($item); + + AuditLogger::info(__('Предложена новость в список'), [ + 'list_id' => $listId, + 'news_id' => $newsId, + 'from' => $fromUserId, + 'owner' => $ownerId, + ]); + + return $item; + } + + public function moderateSuggestion(int $itemId, int $ownerId, bool $approve): void { + $item = $this->itemsRepo()->findOneById($itemId); + + if($item === null || $item->status !== UserListItem::STATUS_PENDING) { + throw new RuntimeException(__('Предложение не найдено')); + } + + $list = $this->listsRepo()->findOneById($item->list_id); + + if($list === null || (int) $list->owner_id !== $ownerId) { + throw new RuntimeException(__('Нет прав на модерацию')); + } + + if(!$approve) { + $this->itemsRepo()->deleteEntity($item); + AuditLogger::info(__('Предложение отклонено'), [ + 'item_id' => $itemId, + 'owner' => $ownerId, + ]); + + return; + } + + // Одобрение: элемент остаётся с user_id предлагающего, status approved — + // в публичном просмотре показываем все approved на list_id. + $item->status = UserListItem::STATUS_APPROVED; + $this->itemsRepo()->saveEntity($item); + + AuditLogger::info(__('Предложение одобрено'), [ + 'item_id' => $itemId, + 'owner' => $ownerId, + ]); + } + + /** + * Списки для модалки новости: админ + свои, с флагом наличия новости. + * + * @return list> + */ + public function modalPayload(int $userId, int $newsId): array { + $checked = array_fill_keys( + $this->itemsRepo()->newsIdsInListsForUser($userId, $newsId), + true, + ); + $rows = []; + + foreach($this->listsRepo()->findAdminLists() as $list) { + $rows[] = [ + 'id' => $list->id(), + 'name' => $list->name, + 'type' => $list->type, + 'checked' => isset($checked[$list->id()]), + 'deletable'=> false, + ]; + } + + foreach($this->listsRepo()->findOwnedBy($userId) as $list) { + $rows[] = [ + 'id' => $list->id(), + 'name' => $list->name, + 'type' => $list->type, + 'checked' => isset($checked[$list->id()]), + 'deletable'=> true, + ]; + } + + return $rows; + } + + private function nextAdminPosition(): int { + $max = 0; + + foreach($this->listsRepo()->findAdminLists() as $list) { + $max = max($max, $list->position); + } + + return $max + 1; + } + + private function nextUserPosition(int $ownerId): int { + $max = 0; + + foreach($this->listsRepo()->findOwnedBy($ownerId) as $list) { + $max = max($max, $list->position); + } + + return $max + 1; + } + +} diff --git a/upload/devcraft/src/modules/UserLists/Services/PermissionService.php b/upload/devcraft/src/modules/UserLists/Services/PermissionService.php new file mode 100644 index 0000000..c7dafc4 --- /dev/null +++ b/upload/devcraft/src/modules/UserLists/Services/PermissionService.php @@ -0,0 +1,123 @@ +> */ + private array $cache = []; + + /** + * @return list + */ + public static function defs(): array { + /** @var list $defs */ + $defs = require DLEPlugins::Check( + dirname(__DIR__) . '/permissions.defs.php', + ); + + return $defs; + } + + /** + * @return array + */ + public function settingsForGroup(int $groupId): array { + if(isset($this->cache[$groupId])) { + return $this->cache[$groupId]; + } + + $entity = $this->repo()->findByGroupId($groupId); + $stored = $entity?->values() ?? []; + $out = []; + + foreach(self::defs() as $def) { + $id = $def['id']; + $type = $def['type'] ?? 'bool'; + $default = $def['default'] ?? ($type === 'int' ? 5 : true); + + if(array_key_exists($id, $stored)) { + $out[$id] = $type === 'int' ? (int) $stored[$id] : !empty($stored[$id]); + } else { + $out[$id] = $type === 'int' ? (int) $default : !empty($default); + } + } + + return $this->cache[$groupId] = $out; + } + + public function isEnabled(int $userId): bool { + return !empty($this->settingsForUser($userId)['enabled']); + } + + public function maxLists(int $userId): int { + return max(0, (int) ($this->settingsForUser($userId)['max_lists'] ?? 0)); + } + + public function canPublic(int $userId): bool { + return !empty($this->settingsForUser($userId)['can_public']); + } + + public function canSuggest(int $userId): bool { + return !empty($this->settingsForUser($userId)['can_suggest']); + } + + /** + * @return array + */ + public function settingsForUser(int $userId): array { + $groupId = $this->groupIdForUser($userId); + + if($groupId <= 0) { + return $this->defaultsMap(); + } + + return $this->settingsForGroup($groupId); + } + + public function groupIdForUser(int $userId): int { + global $member_id; + + if(!empty($member_id['user_id']) && (int) $member_id['user_id'] === $userId) { + return (int) ($member_id['user_group'] ?? 0); + } + + $row = DleDataService::user(id: $userId); + + return (int) ($row['user_group'] ?? 0); + } + + /** + * @return array + */ + private function defaultsMap(): array { + $out = []; + + foreach(self::defs() as $def) { + $type = $def['type'] ?? 'bool'; + $default = $def['default'] ?? ($type === 'int' ? 5 : false); + $out[$def['id']] = $type === 'int' ? (int) $default : !empty($default); + } + + return $out; + } + + private function repo(): GroupPermissionRepository { + /** @var GroupPermissionRepository $repo */ + $repo = Application::instance()->database()->repository(GroupPermission::class); + + return $repo; + } + +} diff --git a/upload/devcraft/src/modules/UserLists/Services/SeedService.php b/upload/devcraft/src/modules/UserLists/Services/SeedService.php new file mode 100644 index 0000000..8943484 --- /dev/null +++ b/upload/devcraft/src/modules/UserLists/Services/SeedService.php @@ -0,0 +1,39 @@ + */ + public const DEFAULT_ADMIN_NAMES = [ + 'В планах', + 'Просмотрено', + 'Избрано', + ]; + + public function ensureDefaults(ListService $lists): void { + foreach(self::DEFAULT_ADMIN_NAMES as $name) { + if($lists->listsRepo()->findAdminByName($name) !== null) { + continue; + } + + $list = new UserList(); + $list->name = $name; + $list->type = UserList::TYPE_ADMIN; + $list->owner_id = null; + $list->visibility = UserList::VIS_PRIVATE; + $list->position = (int) (array_search($name, self::DEFAULT_ADMIN_NAMES, true) ?: 0); + $lists->listsRepo()->saveEntity($list); + + AuditLogger::info(__('Системный сид админ-списка'), ['name' => $name]); + } + } + +} diff --git a/upload/devcraft/src/modules/UserLists/UserListsIdentity.php b/upload/devcraft/src/modules/UserLists/UserListsIdentity.php new file mode 100644 index 0000000..2711d3b --- /dev/null +++ b/upload/devcraft/src/modules/UserLists/UserListsIdentity.php @@ -0,0 +1,18 @@ + + */ +return [ + ChangelogBuilder::create('200.1.0') + ->date('2026-09-10') + ->added([ + __('Каркас UserLists для DevCraft Admin и DLE 21.0.'), + __('Публичные вставки: Controller/show_user_lists.php и show_user_lists_page.php.'), + __('Установка через install.xml (needplugin = DevCraft Admin).'), + __('Админ-списки с сидами «В планах», «Просмотрено», «Избрано»; содержимое per-user.'), + __('Пользовательские списки: приват/публик, WYSIWYG-описание, лимиты по группам.'), + __('Предложения новостей в публичные списки с одобрением владельца (whitelist или все).'), + __('Кнопка в short/full/custom, окно выбора, публичный AJAX через devcraft/ajax.php.'), + __('CRUD с фильтрами в админке, права групп, логи LogGenerator info.'), + ]) + ->changed([ + __('Стили и скрипты сайта — Public/ + siteAssets, теги {devcraft-header} / {devcraft-scripts}.'), + __('Форма редактирования: стандартный отступ кнопок, мультиселект предлагающих, TinyMCE для описания.'), + __('Страница шаблонов: кнопка «Копировать» у каждого include.'), + ]) + ->fixed([ + __('Права групп: одна кнопка сохраняет все группы на странице одним AJAX-запросом.'), + __('Права групп: корректный сбор чекбоксов/лимитов, без ложного reload.'), + __('Порядок списков: один SQL UPDATE вместо N persist; быстрее DnD.'), + __('Медленное сохранение при создании: переход на edit без полного reload списка.'), + ]) + ->removed([ + __('Legacy FavAll engine/ajax.'), + __('Файлы engine/modules/devcraft/user_lists.php и user_lists_page.php.'), + __('CSS и JS модуля в templates/*/devcraft/user_lists/.'), + __('Регистрация через register_plugin.sql.'), + ]) + ->build(), +]; diff --git a/upload/devcraft/src/modules/UserLists/manifest.php b/upload/devcraft/src/modules/UserLists/manifest.php new file mode 100644 index 0000000..21eddbe --- /dev/null +++ b/upload/devcraft/src/modules/UserLists/manifest.php @@ -0,0 +1,85 @@ +mod(UserListsIdentity::mod()) + ->code(UserListsIdentity::code()) + ->name('UserLists') + ->version('200.1.0') + ->description(__('Пользовательские и административные списки новостей')) + ->icon('mif-list2') + ->docsLink('https://readme.devcraft.club/dev/dle/user_lists/') + ->siteLink('https://devcraft.club/') + ->menu([ + AdminLink::page(__('Главная'), 'dashboard', DashboardPage::class, 'mif-home', UserListsIdentity::mod()), + AdminLink::page(__('Админ-списки'), 'admin_lists', AdminListsPage::class, 'mif-folder', UserListsIdentity::mod()), + AdminLink::page(__('Списки пользователей'), 'user_lists', UserListsPage::class, 'mif-users', UserListsIdentity::mod()), + AdminLink::hidden('edit', EditListPage::class), + AdminLink::page(__('Права групп'), 'permissions', PermissionsPage::class, 'mif-security', UserListsIdentity::mod()), + AdminLink::page(__('Подключение в шаблоны'), 'templates', TemplatesPage::class, 'mif-files-empty', UserListsIdentity::mod()), + AdminLink::page(__('Настройки'), 'settings', SettingsPage::class, 'mif-cog', UserListsIdentity::mod()), + AdminLink::page(__('История изменений'), 'changelog', ChangelogPage::class, 'mif-library', UserListsIdentity::mod()), + ]) + ->ajax( + ModuleAjaxConfigBuilder::create('admin') + ->methods([ + 'settings' => SettingsHandler::class, + 'permissions' => PermissionsHandler::class, + 'save_list' => SaveListHandler::class, + 'delete_list' => DeleteListHandler::class, + 'reorder_lists' => ReorderListsHandler::class, + 'modal_lists' => ModalListsHandler::class, + 'toggle_item' => ToggleItemHandler::class, + 'create_list' => CreateListHandler::class, + 'suggest_item' => SuggestItemHandler::class, + 'moderate_suggestion' => ModerateSuggestionHandler::class, + ]) + ->publicMethod('modal_lists', ModalListsHandler::class, true) + ->publicMethod('toggle_item', ToggleItemHandler::class, true) + ->publicMethod('create_list', CreateListHandler::class, true) + ->publicMethod('suggest_item', SuggestItemHandler::class, true) + ->publicMethod('moderate_suggestion', ModerateSuggestionHandler::class, true) + ->publicMethod('reorder_lists', ReorderListsHandler::class, true) + ->publicMethod('save_list', SaveListHandler::class, true) + ->publicMethod('delete_list', DeleteListHandler::class, true) + ) + ->changelog(require DLEPlugins::Check(DEVCRAFT_MODULES . '/UserLists/changelog.data.php')) + ->assets(ModuleAssetsBuilder::create()->js('user_lists.js')) + ->siteAssets( + ModuleSiteAssetsBuilder::create() + ->css('user_lists.css') + ->js('user_lists_public.js') + ) + ->build(__DIR__); diff --git a/upload/devcraft/src/modules/UserLists/permissions.defs.php b/upload/devcraft/src/modules/UserLists/permissions.defs.php new file mode 100644 index 0000000..2e29c71 --- /dev/null +++ b/upload/devcraft/src/modules/UserLists/permissions.defs.php @@ -0,0 +1,43 @@ + + */ +return [ + [ + 'id' => 'enabled', + 'title' => __('Доступ к модулю'), + 'description' => __('Группа может пользоваться пользовательскими списками на сайте.'), + 'level' => 'user', + 'type' => 'bool', + 'default' => true, + ], + [ + 'id' => 'max_lists', + 'title' => __('Лимит своих списков'), + 'description' => __('Сколько собственных списков может создать пользователь группы. Админ-списки не считаются.'), + 'level' => 'user', + 'type' => 'int', + 'default' => 5, + ], + [ + 'id' => 'can_public', + 'title' => __('Публичные списки'), + 'description' => __('Разрешить делать свои списки общедоступными.'), + 'level' => 'user', + 'type' => 'bool', + 'default' => true, + ], + [ + 'id' => 'can_suggest', + 'title' => __('Предлагать в чужие списки'), + 'description' => __('Разрешить предлагать новости в публичные списки других пользователей.'), + 'level' => 'user', + 'type' => 'bool', + 'default' => true, + ], +]; diff --git a/upload/devcraft/src/modules/UserLists/settings.schema.php b/upload/devcraft/src/modules/UserLists/settings.schema.php new file mode 100644 index 0000000..e6621cd --- /dev/null +++ b/upload/devcraft/src/modules/UserLists/settings.schema.php @@ -0,0 +1,39 @@ +layout(FormLayout::TABS) + ->section(__('Основные')) + ->checkbox('guest_can_view_public', __('Гости могут смотреть публичные списки')) + ->description(__('Если выключено, каталог и публичные списки доступны только авторизованным.')) + ->default(false) + ->text('button_label', __('Текст кнопки на новости')) + ->description(__('Надпись на кнопке «добавить в список».')) + ->default(__('В списки')) + ->number('count_web', __('Записей на странице (сайт)')) + ->description(__('Пагинация новостей внутри списка на сайте.')) + ->default(20) + ->number('count_admin', __('Записей на странице (админка)')) + ->description(__('Пагинация таблиц в админке модуля.')) + ->default(50) + ->section(__('Ограничения')) + ->text('bad_words', __('Запрещённые слова в названии')) + ->description(__('Теги через Enter или запятую. Совпадение блокирует создание/переименование.')) + ->metro([ + 'data-role' => 'tag-input', + 'data-tag-separator' => ',', + ]) + ->default('') + ->number('name_min', __('Мин. длина названия')) + ->default(2) + ->number('name_max', __('Макс. длина названия')) + ->default(100) + ->build(); diff --git a/upload/devcraft/src/modules/UserLists/templates/admin_lists.twig b/upload/devcraft/src/modules/UserLists/templates/admin_lists.twig new file mode 100644 index 0000000..60990e3 --- /dev/null +++ b/upload/devcraft/src/modules/UserLists/templates/admin_lists.twig @@ -0,0 +1,52 @@ +{% import 'core/includes/form/_macros/_field.twig' as field %} + +

{{ page_title }}

+ +{% include 'core/includes/form/filter_bar.twig' with { + mod: mod, + action: action, + filter_catalog: filter_catalog, + filter_rules: filter_rules, + filter_chips: filter_chips, + order: order, + sort: sort +} only %} + +

{{ total }} {{ 'записей'|trans }}

+ +
+ +
+ +{% if items|length > 0 %} + + + + + + + + + + + + {% for item in items %} + + + + + + + + {% endfor %} + +
#{{ 'Название'|trans }}{{ 'Порядок'|trans }}{{ 'Действия'|trans }}
{{ item.id }}{{ item.name }}{{ item.position }} +
+ {{ 'Изменить'|trans }} + +
+
+ {{ field.pages(page_urls, current_page) }} +{% else %} +

{{ 'Админ-списков пока нет.'|trans }}

+{% endif %} diff --git a/upload/devcraft/src/modules/UserLists/templates/edit.twig b/upload/devcraft/src/modules/UserLists/templates/edit.twig new file mode 100644 index 0000000..05e7537 --- /dev/null +++ b/upload/devcraft/src/modules/UserLists/templates/edit.twig @@ -0,0 +1,65 @@ +

{{ page_title }}

+ +{% if list is null %} +

{{ 'Список не найден.'|trans }}

+ {{ 'К списку'|trans }} +{% else %} +
+ + + +
+ + +
+ + {% if list.type == 'user' %} +
+ + +
+ +
+ + +
+ +
+ +
+ +
+ + +
+ +
+ + + {{ 'Мультивыбор. Используется при политике «белый список».'|trans }} +
+ {% endif %} + +
+ + {{ 'К списку'|trans }} +
+
+ + {% if list.type == 'user' and pm_wysiwyg and pm_editor_script %} + {% include 'core/includes/ui/templateIncludes/tinymce_editor_scripts.twig' %} + {% endif %} +{% endif %} diff --git a/upload/devcraft/src/modules/UserLists/templates/permissions.twig b/upload/devcraft/src/modules/UserLists/templates/permissions.twig new file mode 100644 index 0000000..3b5b792 --- /dev/null +++ b/upload/devcraft/src/modules/UserLists/templates/permissions.twig @@ -0,0 +1,59 @@ +
+

{{ page_title|default('Права групп'|trans) }}

+ + {% if tabs is empty %} +

{{ 'Группы пользователей не найдены'|trans }}

+ {% else %} +
+

{{ 'Одна кнопка сохраняет настройки всех групп на странице.'|trans }}

+ +
+ +
+
+ {% for tab in tabs %} +
+ + + {% for flag in tab.flags %} + + + + + {% endfor %} + +
+ +
{{ flag.description }} +
+ {% if flag.type == 'int' %} + + {% else %} + + {% endif %} +
+
+ {% endfor %} +
+ +
+ +
+
+ {% endif %} +
diff --git a/upload/devcraft/src/modules/UserLists/templates/settings.twig b/upload/devcraft/src/modules/UserLists/templates/settings.twig new file mode 100644 index 0000000..95f8a26 --- /dev/null +++ b/upload/devcraft/src/modules/UserLists/templates/settings.twig @@ -0,0 +1 @@ +{% include 'pages/settings.twig' %} diff --git a/upload/devcraft/src/modules/UserLists/templates/templates.twig b/upload/devcraft/src/modules/UserLists/templates/templates.twig new file mode 100644 index 0000000..d947d41 --- /dev/null +++ b/upload/devcraft/src/modules/UserLists/templates/templates.twig @@ -0,0 +1,52 @@ +

{{ page_title }}

+ +
+

{{ '1. Кнопка в нужном месте шаблона'|trans }}

+

{{ 'Этот include выводит только кнопку открытия окна. Обычно его ставят в'|trans }} shortstory.tpl {{ 'и/или'|trans }} fullstory.tpl {{ '(или свой шаблон).'|trans }}

+
+
{include file="devcraft/src/modules/UserLists/Controller/show_user_lists.php?news_id={news-id}&focus=button"}
+ +
+
+ +
+

{{ '2. Разметка окна выбора списка'|trans }}

+

{{ 'Этот include выводит разметку окна. Разместите его на той же странице новости, где есть кнопка.'|trans }}

+
+
{include file="devcraft/src/modules/UserLists/Controller/show_user_lists.php?news_id={news-id}&focus=modal"}
+ +
+
+ +
+

{{ '3. Стили и скрипты оболочки'|trans }}

+

{{ 'Не подключайте CSS и JS модуля через include и не кладите их в тему. Файлы идут из Public/ через siteAssets. В'|trans }} main.tpl {{ 'должны быть теги'|trans }} {devcraft-header} {{ 'в'|trans }} <head> {{ 'и'|trans }} {devcraft-scripts} {{ 'перед'|trans }} </body>.

+
+ +
+

{{ '4. Страницы списков на сайте'|trans }}

+

{{ 'Свои списки, каталог, просмотр и очередь предложений — отдельные includes на статическую страницу или блок:'|trans }}

+
+
{include file="devcraft/src/modules/UserLists/Controller/show_user_lists_page.php?focus=mine"}
+{include file="devcraft/src/modules/UserLists/Controller/show_user_lists_page.php?focus=catalog"}
+{include file="devcraft/src/modules/UserLists/Controller/show_user_lists_page.php?focus=proposals"}
+{include file="devcraft/src/modules/UserLists/Controller/show_user_lists_page.php?focus=view&list_id=1"}
+ +
+
+ +
+

{{ 'Рекомендуемая схема подключения'|trans }}

+
    +
  • {devcraft-header} {{ 'и'|trans }} {devcraft-scripts} {{ 'в'|trans }} main.tpl.
  • +
  • focus=button {{ 'в месте показа кнопки в шаблоне новости.'|trans }}
  • +
  • focus=modal {{ 'рядом с кнопкой или внизу того же шаблона новости.'|trans }}
  • +
+

{{ 'Штатное DLE «Избранное» модуль не меняет. Шаблоны разметки лежат в'|trans }} templates/THEME/devcraft/user_lists/.

+
diff --git a/upload/devcraft/src/modules/UserLists/templates/user_lists.twig b/upload/devcraft/src/modules/UserLists/templates/user_lists.twig new file mode 100644 index 0000000..b2854db --- /dev/null +++ b/upload/devcraft/src/modules/UserLists/templates/user_lists.twig @@ -0,0 +1,57 @@ +{% import 'core/includes/form/_macros/_field.twig' as field %} + +

{{ page_title }}

+ +{% include 'core/includes/form/filter_bar.twig' with { + mod: mod, + action: action, + filter_catalog: filter_catalog, + filter_rules: filter_rules, + filter_chips: filter_chips, + order: order, + sort: sort +} only %} + +

{{ total }} {{ 'записей'|trans }}

+ +
+ +
+ +{% if items|length > 0 %} + + + + + + + + + + + + + {% for item in items %} + + + + + + + + + {% endfor %} + +
#{{ 'Название'|trans }}{{ 'Владелец'|trans }}{{ 'Видимость'|trans }}{{ 'Действия'|trans }}
{{ item.id }}{{ item.name }} + {{ item.owner_name }} + {{ item.visibility }} +
+ {{ 'Изменить'|trans }} + +
+
+ {{ field.pages(page_urls, current_page) }} +{% else %} +

{{ 'Пользовательских списков пока нет.'|trans }}

+{% endif %} diff --git a/upload/engine/inc/user_lists.php b/upload/engine/inc/user_lists.php new file mode 100644 index 0000000..0e22996 --- /dev/null +++ b/upload/engine/inc/user_lists.php @@ -0,0 +1,18 @@ +runAdmin(moduleDir: 'UserLists'); diff --git a/upload/templates/Default/devcraft/user_lists/.htaccess b/upload/templates/Default/devcraft/user_lists/.htaccess new file mode 100644 index 0000000..2ab077b --- /dev/null +++ b/upload/templates/Default/devcraft/user_lists/.htaccess @@ -0,0 +1,7 @@ + + Require all granted + + + Order deny,allow + Allow from all + diff --git a/upload/templates/Default/devcraft/user_lists/button.tpl b/upload/templates/Default/devcraft/user_lists/button.tpl new file mode 100644 index 0000000..f2234ea --- /dev/null +++ b/upload/templates/Default/devcraft/user_lists/button.tpl @@ -0,0 +1,3 @@ + diff --git a/upload/templates/Default/devcraft/user_lists/modal.tpl b/upload/templates/Default/devcraft/user_lists/modal.tpl new file mode 100644 index 0000000..3456e2b --- /dev/null +++ b/upload/templates/Default/devcraft/user_lists/modal.tpl @@ -0,0 +1,8 @@ + diff --git a/upload/templates/Default/devcraft/user_lists/page.tpl b/upload/templates/Default/devcraft/user_lists/page.tpl new file mode 100644 index 0000000..b4eb07b --- /dev/null +++ b/upload/templates/Default/devcraft/user_lists/page.tpl @@ -0,0 +1,4 @@ +
+

{ul-title}

+ {ul-items} +
diff --git a/upload/templates/Default/devcraft/user_lists/proposals.tpl b/upload/templates/Default/devcraft/user_lists/proposals.tpl new file mode 100644 index 0000000..dcd8470 --- /dev/null +++ b/upload/templates/Default/devcraft/user_lists/proposals.tpl @@ -0,0 +1,4 @@ +
+

{ul-title}

+ {ul-items} +
diff --git a/upload/templates/Default/devcraft/user_lists/view.tpl b/upload/templates/Default/devcraft/user_lists/view.tpl new file mode 100644 index 0000000..c67c7d3 --- /dev/null +++ b/upload/templates/Default/devcraft/user_lists/view.tpl @@ -0,0 +1,5 @@ +
+

{ul-title}

+
{ul-description}
+ {ul-items} +
From 16fead6687e93a76cea314648d81aa7e3b01f0a5 Mon Sep 17 00:00:00 2001 From: Maxim Harder Date: Sun, 20 Sep 2026 14:52:10 +0200 Subject: [PATCH 2/5] =?UTF-8?q?feat:=20=D0=B4=D0=BE=D0=B1=D0=B0=D0=B2?= =?UTF-8?q?=D0=BB=D1=8F=D0=B5=D1=82=20=D0=BA=D0=BE=D0=BD=D1=82=D1=80=D0=BE?= =?UTF-8?q?=D0=BB=D0=BB=D0=B5=D1=80=D1=8B=20=D0=B8=20=D1=81=D1=82=D0=B8?= =?UTF-8?q?=D0=BB=D0=B8=20=D0=B4=D0=BB=D1=8F=20=D0=B2=D0=B8=D0=B4=D0=B6?= =?UTF-8?q?=D0=B5=D1=82=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit списков новостей Добавляет WidgetController для отображения кнопки и модального окна выбора списков на странице новости. Реализует PageController для публичных страниц со списками. Добавляет базовые стили для элементов интерфейса. Включает точки входа show_user_lists.php и show_user_lists_page.php для использования в шаблонах. --- .../UserLists/Controller/PageController.php | 226 ++++++++++++++++++ .../UserLists/Controller/WidgetController.php | 72 ++++++ .../UserLists/Controller/show_user_lists.php | 37 +++ .../Controller/show_user_lists_page.php | 32 +++ .../modules/UserLists/Public/user_lists.css | 4 + .../UserLists/Public/user_lists_public.js | 137 +++++++++++ .../modules/UserLists/Support/PublicSkin.php | 27 +++ upload/install.xml | 24 ++ 8 files changed, 559 insertions(+) create mode 100644 upload/devcraft/src/modules/UserLists/Controller/PageController.php create mode 100644 upload/devcraft/src/modules/UserLists/Controller/WidgetController.php create mode 100644 upload/devcraft/src/modules/UserLists/Controller/show_user_lists.php create mode 100644 upload/devcraft/src/modules/UserLists/Controller/show_user_lists_page.php create mode 100644 upload/devcraft/src/modules/UserLists/Public/user_lists.css create mode 100644 upload/devcraft/src/modules/UserLists/Public/user_lists_public.js create mode 100644 upload/devcraft/src/modules/UserLists/Support/PublicSkin.php create mode 100644 upload/install.xml diff --git a/upload/devcraft/src/modules/UserLists/Controller/PageController.php b/upload/devcraft/src/modules/UserLists/Controller/PageController.php new file mode 100644 index 0000000..0ac3a5e --- /dev/null +++ b/upload/devcraft/src/modules/UserLists/Controller/PageController.php @@ -0,0 +1,226 @@ +dir = ROOT_DIR . '/templates/' . $skin; + } + + $restoreDir = $tpl->dir; + $tpl->dir = ROOT_DIR . '/templates/' . $skin; + + $service = new ListService(); + (new SeedService())->ensureDefaults($service); + $perms = new PermissionService(); + + $html = match ($focus) { + 'catalog' => $this->catalog($service, $logged, $guestOk, $page, $perPage, $tpl), + 'view' => $this->view($service, $logged, $guestOk, $userId, $listId, $page, $perPage, $tpl, $db), + 'proposals' => $this->proposals($service, $perms, $logged, $userId, $tpl), + default => $this->mine($service, $perms, $logged, $userId, $tpl), + }; + + $tpl->dir = $restoreDir; + + return $html; + } + + private function catalog(ListService $service, bool $logged, bool $guestOk, int $page, int $perPage, object $tpl): string { + if(!$logged && !$guestOk) { + return $this->error(__('Каталог доступен только авторизованным')); + } + + $total = $service->listsRepo()->countPublic(); + $rows = []; + + foreach($service->listsRepo()->findPublic($page, $perPage) as $list) { + $rows[] = [ + 'name' => $list->name, + 'url' => '?ul_focus=view&list_id=' . $list->id(), + ]; + } + + return $this->listPage($tpl, $rows, __('Каталог публичных списков') . " ({$total})"); + } + + private function mine(ListService $service, PermissionService $perms, bool $logged, int $userId, object $tpl): string { + if(!$logged || !$perms->isEnabled($userId)) { + return $this->error(__('Требуется авторизация')); + } + + $rows = []; + + foreach($service->listsRepo()->findAdminLists() as $list) { + $rows[] = [ + 'name' => $list->name . ' (' . __('админ') . ')', + 'url' => '?ul_focus=view&list_id=' . $list->id(), + ]; + } + + foreach($service->listsRepo()->findOwnedBy($userId) as $list) { + $rows[] = [ + 'name' => $list->name . ($list->isPublic() ? ' [' . __('публичный') . ']' : ''), + 'url' => '?ul_focus=view&list_id=' . $list->id(), + ]; + } + + return $this->listPage($tpl, $rows, __('Мои списки')); + } + + private function proposals(ListService $service, PermissionService $perms, bool $logged, int $userId, object $tpl): string { + if(!$logged || !$perms->isEnabled($userId)) { + return $this->error(__('Требуется авторизация')); + } + + $owned = $service->listsRepo()->findOwnedBy($userId); + $listIds = array_map(static fn(UserList $list): int => $list->id(), $owned); + $html = ''; + + foreach($service->itemsRepo()->findPendingForOwnerLists($listIds) as $item) { + $html .= '
  • #' . $item->news_id + . ' ' + . ' ' + . '
  • '; + } + + $tpl->set('{ul-title}', htmlspecialchars(__('Входящие предложения'), ENT_QUOTES, 'UTF-8')); + $tpl->set('{ul-items}', $html !== '' ? '
      ' . $html . '
    ' : '

    ' . __('Нет предложений') . '

    '); + $tpl->load_template('devcraft/user_lists/proposals.tpl'); + $tpl->compile('content'); + + return (string) ($tpl->result['content'] ?? ''); + } + + private function view( + ListService $service, + bool $logged, + bool $guestOk, + int $userId, + int $listId, + int $page, + int $perPage, + object $tpl, + object $db, + ): string { + $list = $service->listsRepo()->findOneById($listId); + + if($list === null) { + return $this->error(__('Список не найден')); + } + + $desc = ''; + + if($list->isAdmin()) { + if(!$logged) { + return $this->error(__('Требуется авторизация')); + } + + $items = $service->itemsRepo()->findApprovedForUserList($listId, $userId); + $title = $list->name; + } elseif($list->isPublic()) { + if(!$logged && !$guestOk) { + return $this->error(__('Просмотр запрещён')); + } + + $items = $service->itemsRepo()->findApprovedOnList($listId, $page, $perPage); + $title = $list->name; + $desc = (string) ($list->description ?? ''); + } else { + if(!$logged || (int) $list->owner_id !== $userId) { + return $this->error(__('Список не найден')); + } + + $items = $service->itemsRepo()->findApprovedOnList($listId, $page, $perPage); + $title = $list->name; + $desc = (string) ($list->description ?? ''); + } + + $newsHtml = ''; + + foreach($items as $item) { + /** @var UserListItem $item */ + $row = $db->super_query('SELECT id, title FROM ' . PREFIX . '_post WHERE id=' . (int) $item->news_id . ' LIMIT 1'); + + if(!is_array($row) || $row === []) { + continue; + } + + $newsHtml .= '
  • #' . (int) $row['id'] . ' ' + . htmlspecialchars(stripslashes((string) $row['title']), ENT_QUOTES, 'UTF-8') + . '
  • '; + } + + $tpl->set('{ul-title}', htmlspecialchars($title, ENT_QUOTES, 'UTF-8')); + $tpl->set('{ul-description}', $desc); + $tpl->set('{ul-items}', $newsHtml !== '' ? '
      ' . $newsHtml . '
    ' : '

    ' . __('Нет новостей') . '

    '); + $tpl->load_template('devcraft/user_lists/view.tpl'); + $tpl->compile('content'); + + return (string) ($tpl->result['content'] ?? ''); + } + + /** + * @param list $rows + */ + private function listPage(object $tpl, array $rows, string $title): string { + $html = ''; + + foreach($rows as $row) { + $html .= '
  • ' + . htmlspecialchars((string) $row['name'], ENT_QUOTES, 'UTF-8') . '
  • '; + } + + $tpl->set('{ul-title}', htmlspecialchars($title, ENT_QUOTES, 'UTF-8')); + $tpl->set('{ul-items}', $html !== '' ? '
      ' . $html . '
    ' : '

    ' . __('Пусто') . '

    '); + $tpl->load_template('devcraft/user_lists/page.tpl'); + $tpl->compile('content'); + + return (string) ($tpl->result['content'] ?? ''); + } + + private function error(string $message): string { + if(function_exists('msgbox')) { + msgbox(__('Ошибка'), $message); + } + + return ''; + } + +} diff --git a/upload/devcraft/src/modules/UserLists/Controller/WidgetController.php b/upload/devcraft/src/modules/UserLists/Controller/WidgetController.php new file mode 100644 index 0000000..c7dff79 --- /dev/null +++ b/upload/devcraft/src/modules/UserLists/Controller/WidgetController.php @@ -0,0 +1,72 @@ +dir = ROOT_DIR . '/templates/' . $skin; + } + + $restoreDir = $tpl->dir; + $tpl->dir = ROOT_DIR . '/templates/' . $skin; + + $tpl->set('{news-id}', (string) $newsId); + $tpl->set('{button-label}', htmlspecialchars($buttonLabel, ENT_QUOTES, 'UTF-8')); + $tpl->set('{user-hash}', htmlspecialchars((string) ($dle_login_hash ?? ''), ENT_QUOTES, 'UTF-8')); + $tpl->set('{create-label}', htmlspecialchars(__('Новый список'), ENT_QUOTES, 'UTF-8')); + $tpl->set('{modal-title}', htmlspecialchars(__('Мои списки'), ENT_QUOTES, 'UTF-8')); + + $file = $focus === 'modal' ? 'devcraft/user_lists/modal.tpl' : 'devcraft/user_lists/button.tpl'; + $tpl->load_template($file); + $tpl->compile('content'); + $html = (string) ($tpl->result['content'] ?? ''); + $tpl->dir = $restoreDir; + + return $html; + } + +} diff --git a/upload/devcraft/src/modules/UserLists/Controller/show_user_lists.php b/upload/devcraft/src/modules/UserLists/Controller/show_user_lists.php new file mode 100644 index 0000000..df7006f --- /dev/null +++ b/upload/devcraft/src/modules/UserLists/Controller/show_user_lists.php @@ -0,0 +1,37 @@ +render($focus, $newsId); diff --git a/upload/devcraft/src/modules/UserLists/Controller/show_user_lists_page.php b/upload/devcraft/src/modules/UserLists/Controller/show_user_lists_page.php new file mode 100644 index 0000000..cefb6eb --- /dev/null +++ b/upload/devcraft/src/modules/UserLists/Controller/show_user_lists_page.php @@ -0,0 +1,32 @@ +render($focus, $listId, $page); diff --git a/upload/devcraft/src/modules/UserLists/Public/user_lists.css b/upload/devcraft/src/modules/UserLists/Public/user_lists.css new file mode 100644 index 0000000..50ee59a --- /dev/null +++ b/upload/devcraft/src/modules/UserLists/Public/user_lists.css @@ -0,0 +1,4 @@ +.user-lists-open { cursor: pointer; } +.user-lists-modal .ul-lists-body label { display: block; margin: .35rem 0; } +.ul-list, .ul-news, .ul-proposals { list-style: disc; margin-left: 1.25rem; } +.ul-description { margin-bottom: 1rem; } diff --git a/upload/devcraft/src/modules/UserLists/Public/user_lists_public.js b/upload/devcraft/src/modules/UserLists/Public/user_lists_public.js new file mode 100644 index 0000000..cf18b61 --- /dev/null +++ b/upload/devcraft/src/modules/UserLists/Public/user_lists_public.js @@ -0,0 +1,137 @@ +(function (window) { + 'use strict'; + + function t(phrase) { + return typeof window.__ === 'function' ? window.__(phrase) : phrase; + } + + function boot($) { + if (!$ || typeof $.fn.dialog !== 'function') { + console.error('[UserLists] Нужны jQuery и окно jQuery UI.'); + return; + } + + function notifyError(message) { + if (window.DevCraftPublic && DevCraftPublic.Ajax && typeof DevCraftPublic.Ajax.notify === 'function') { + DevCraftPublic.Ajax.notify(t('Ошибка'), message, 'error'); + return; + } + console.error('[UserLists]', message); + } + + function notifyOk(message) { + if (window.DevCraftPublic && DevCraftPublic.Ajax && typeof DevCraftPublic.Ajax.notify === 'function') { + DevCraftPublic.Ajax.notify(t('Готово'), message, 'success'); + return; + } + } + + function publicPost(method, data) { + if (!window.DevCraftPublic || !DevCraftPublic.Ajax) { + return Promise.reject(new Error(t('Клиент отправки не загружен'))); + } + return DevCraftPublic.Ajax.post('user_lists', method, data || {}); + } + + function getModal(newsId) { + return $('#user-lists-modal-' + newsId); + } + + function renderLists(newsId, lists) { + var $body = getModal(newsId).find('.ul-lists-body'); + var html = ''; + (lists || []).forEach(function (list) { + html += '